drift
A terminal TUI for browsing, diffing, and syncing files with remote hosts — think PHPStorm's "Browse Remote Host" and "Sync with Deployed To", but in your terminal.
Supports SFTP/SSH, FTP, and FTPS targets. Runs on Linux and macOS.

[!WARNING]
Alpha: drift is in an early public stage. Expect rough edges, incomplete polish, and breaking changes between releases.
Features
- Side-by-side local/remote file browser with multi-select (Space) and recursive directory marking
- Safe local/remote text preview in the opposite pane (
p), with line numbers and wrapping
- Project-wide fuzzy file finder (
f) for marking files
- Unified diff view with the file list on the left for local vs. remote files, unchanged
stretches folded away behind
@@ hunk headers
- Per-file sync direction control: upload ↑, download ↓, delete local ✗, delete remote ✗, or skip —
- Bulk sync direction toggle (A key cycles all files at once)
- Auto pre-selection of sync direction based on file modification time
- Sync current file (s) or all marked files (S) in one keystroke
- Esc hides a running connect or sync;
q / Ctrl+C cancel it. Files already transferred stay; the rest is skipped
- Per-host path mappings (like PHPStorm's Deployment Mappings tab)
- Host manager: create, duplicate, edit, delete, and test connections
- Interactive FTPS certificate verification with session-only or persistent, fingerprint-bound trust
- Nothing is written into your project: global hosts in
~/.config/drift/config.toml, per-project hosts and mappings in ~/.config/drift/projects/<slug>.toml
- Skips
.git, node_modules, .idea, and other irrelevant directories automatically
Installation
From source (requires Go 1.25+)
git clone https://github.com/WariKoda/drift.git
cd drift
make install
Directly with Go
go install github.com/WariKoda/drift@latest
Both methods install to $GOBIN, or $GOPATH/bin when unset, usually ~/go/bin.
Ensure that directory is in your $PATH. Git must also be available in $PATH;
drift uses git check-ignore in one batched process per scan so nested rules,
tracked files, worktrees, .git/info/exclude, and global excludes match Git itself. make install builds your local checkout;
go install …@latest installs the latest published version.
Optionally set a persistent install directory for all Go programs:
go env -w GOBIN="$HOME/.local/bin"
Update after code changes
make update
Usage
# Start in the current directory, last project, or dashboard (see below)
drift
# Open the project dashboard explicitly
drift dash
# Open a registered project by name or slug
drift open kunde-a
# Manage projects
drift projects list
drift projects add "KUNDE A" ~/work/kunde-a
drift projects edit kunde-a --name "KUNDE A GmbH" --path ~/work/kunde-a
drift projects archive kunde-a
drift projects remove kunde-a
# Show version
drift version
Navigate to any file or directory, press Space to mark it, then s to open the sync target picker.
Project dashboard
drift can keep a registry of your projects (one per customer, say) and show them in a
dashboard on startup. From the dashboard you select a project and drift re-roots into it:
it loads that project's config and opens the file browser in its directory — the same as
cd <path> && drift, but without leaving drift.
The dashboard appears automatically when you run drift outside any registered
project and nothing has been opened yet, as long as at least one
project is registered. If you have opened a project before, drift restores that
one instead (its path must still exist). Inside a project directory, drift
opens the browser as before. --dashboard (or drift dash) always opens the
list; --no-dashboard stays in the current directory.
From the file browser, P opens a filterable switcher and leaves the current
session alone. Esc goes back. Enter on another project re-roots. m (with an
empty filter) opens the full dashboard to add, edit, archive, or remove entries.
drift open accepts a display name or slug: exact slug, then exact name
(case-insensitive), then a unique prefix or substring of either.
~/.config/drift/projects.toml holds a slug, display name, local path and timestamps per
project. Its hosts and mappings live next to it in ~/.config/drift/projects/<slug>.toml.
The registry is also how drift knows which project a directory belongs to: the registered
project whose path is the directory or a parent of it, longest match first. Registering is
therefore what gives a directory hosts of its own.
drift offers to do it for you. Start it in a repository that no project covers and it asks,
suggesting the repository root rather than whatever subdirectory you were in — press y to
register, any other key to skip. Otherwise: drift projects add ., or n on the dashboard,
which prefills the repository root too.
Typical workflow
- Run
drift in your project directory
- Mark one or more files/directories with Space
- Press s and choose a host
- Review diffs and suggested sync directions
- Sync the current file with s or all files with S
Key Bindings
Project Dashboard
| Key |
Action |
j / k or ↑ / ↓ |
Navigate |
Enter |
Open project (re-root drift into it) |
1–9 |
Jump to / open the n-th project |
n |
New project |
e |
Edit project |
d |
Remove project (with confirmation) |
a |
Archive / unarchive project |
. |
Show / hide archived projects |
| Click / wheel |
Move the cursor; double click opens |
Esc |
Back to the browser when opened from P then m; otherwise quit |
q |
Quit drift |
Project switcher (P in the file browser)
| Key |
Action |
| type |
Filter by name, slug, or path |
↑ / ↓ or Ctrl+n / Ctrl+p |
Navigate |
Enter |
Open the selected project |
m |
Open the dashboard to manage projects (empty filter only) |
Esc |
Back to the browser (session kept) |
File Browser
| Key |
Action |
j / k or ↑ / ↓ |
Navigate in the active pane |
h / ←, l / → / Enter |
Collapse / open directory |
g / G |
Jump to top / bottom |
Tab |
Switch local / remote pane |
p |
Toggle a text preview in the opposite pane |
c |
Copy the loaded preview content to the clipboard |
PgUp / PgDn, Home / End |
Scroll / jump within an active preview |
@ |
Choose or change the remote host |
Space |
Mark / unmark file or directory in the active pane |
V / * |
Mark the current level / invert the active pane's selection |
f |
Fuzzy-find files across the project and mark them |
. / I |
Toggle hidden / gitignored entries |
s |
Sync marked local and remote files |
r / / / ? |
Refresh active pane / filter / help |
H |
Open host manager |
P |
Switch project (filterable picker; m opens the dashboard) |
Esc |
Clear filter and selections |
q / Ctrl+C |
Quit (cancels a running connect or sync first) |
While a connect or sync overlay is up, Esc hides it (the work keeps going, with [q] cancel on the status line) and q / Ctrl+C abort it. Already-transferred files are kept.
Hidden and gitignored entries are hidden in the browser by default. The two
visibility toggles are independent and do not change sync scope. A recursively
selected directory includes hidden files such as .htaccess and .well-known,
but skips gitignored paths. Drift applies the local project's ignore rules to
both local and mapped remote files, so an ignored remote-only file does not
become a local delete or download candidate. Selecting an ignored file directly
includes that file. Selecting an ignored directory does not include its contents.
.git, .svn, .hg, node_modules, .idea, and .vscode directories remain
excluded even when ignored files are included. Symlinks and paths outside the
project or active mappings keep their existing safety checks.
The diff status shows the number of pairs, hidden files, skipped ignored paths,
and fixed exclusions. Press i to rebuild that comparison with all gitignored
paths included for this operation. Drift reloads the rules and comparison; the
setting does not change the global config.
Diff View
| Key |
Action |
j / k or ↑ / ↓ |
Scroll diff content by line |
PgUp / PgDn |
Scroll diff content by page |
Ctrl+u / Ctrl+d |
Scroll diff content by half page |
Home / g, End / G |
Jump to start / end of diff content |
[ / ] |
Jump to the previous / next hunk |
Enter / l |
Expand or collapse the first fold in view |
h |
Collapse the fold around the top of the viewport |
c |
Expand or collapse every fold in the file |
Tab / Shift+Tab |
Select next / previous file |
Space |
Cycle sync direction for current file |
A |
Cycle sync direction for all files |
s |
Sync current file |
S |
Sync all files |
u / d |
Quick upload / download current file |
e |
Toggle the last bulk-sync error list (when errors occurred) |
i |
Rebuild scope with / without gitignored paths |
r |
Reload ignore rules, scope, and diffs |
q / Esc |
Back to browser |
Host Manager
| Key |
Action |
n |
New host |
e / Enter |
Edit host |
c |
Duplicate host into a pre-filled form with a free name |
d |
Delete host |
t |
Test connection |
q / Esc |
Back |
Mouse
The mouse works in the file browser, the diff view and the host manager.
| Action |
Effect |
| Wheel |
Scroll the pane under the pointer |
| Click |
Move the cursor there, and focus that pane |
| Click on a pane label |
Focus that pane |
| Click on a fold marker |
Expand or collapse that fold (diff view) |
| Double click |
Expand a directory (local), open one (remote), cycle a file's sync direction (diff view), edit a host (host manager) |
| Drag while previewing |
Select preview text with the terminal; mouse reporting resumes when the preview closes |
Shift+Click |
Select text with the terminal's own selection |
Mouse reporting pauses while a file preview is open, so preview text can be
selected without holding Shift. It resumes when the preview closes. Outside
the preview, it can be turned off entirely:
drift --no-mouse # this run only
DRIFT_NO_MOUSE=1 drift # via environment
# ~/.config/drift/config.toml — permanently
[ui]
mouse = false
show_hidden = true # initial browser visibility; default false
show_ignored = true # initial browser visibility; default false
The flag beats the environment variable, which beats the config file. Mouse
support is on unless one of them turns it off.
Theming
Drift uses the terminal palette by default and auto-detects Omarchy themes from
$XDG_STATE_HOME/omarchy/current/theme/colors.toml (normally
~/.local/state/omarchy/current/theme/colors.toml). The legacy location under
$XDG_CONFIG_HOME is supported as a fallback. You can override this with:
DRIFT_THEME=auto|ansi|omarchy|default
DRIFT_THEME_FILE=/path/to/colors.toml
auto loads Omarchy colors first and falls back to ANSI terminal colors.
default uses Drift's built-in fallback palette.
Logging
Logging is off by default. When enabled, drift writes diagnostics
(connection lifecycle and every connect/sync/diff error, with full paths) to a
file — never to the terminal, which the TUI owns. Enable it per run with a flag
or environment variable; the flag wins:
drift --debug # debug level → ~/.config/drift/drift.log
DRIFT_DEBUG=1 drift # same, via environment
drift --log /tmp/drift.log # info level → explicit path
DRIFT_LOG=/tmp/drift.log drift
--debug (or DRIFT_DEBUG=1) raises the level to debug, adding a line per
synced file; without it the log stays at info level. The log file is appended to,
not rotated. This complements the in-app [e] error list in the diff view, which
only holds the most recent bulk sync.
Configuration
Global config: ~/.config/drift/config.toml
[defaults]
user = "deploy"
[[hosts]]
name = "prod"
hostname = "example.com"
port = 22
user = "deploy"
root_path = "/var/www/html"
protocol = "sftp"
[hosts.auth]
type = "keyfile"
key_file = "~/.ssh/id_ed25519"
Project config: ~/.config/drift/projects/<slug>.toml
drift writes this file; the slug comes from the registry. Nothing is stored in the
project directory itself.
[defaults]
user = "deploy"
[[hosts]]
name = "staging"
hostname = "shopdev.example.com"
port = 21
user = "webuser"
root_path = "/var/www"
protocol = "ftps"
[hosts.auth]
type = "password"
password = "$DEPLOY_PASSWORD"
[[hosts.mappings]]
local = "plugins/plugin1"
remote = "html/custom/plugins/plugin1"
[[mappings]]
local = "src"
remote = "html"
The file holds credentials verbatim, so it is mode 600 in a 700 directory. Use
$ENV_VAR for a password or passphrase if you would rather keep the secret in your shell
environment or a password manager; drift expands it at connect time.
Keep-alive
Remote connections send protocol keep-alive probes every 60 seconds by default.
Set keep_alive_interval on a host in seconds, or edit the field in the host form:
keep_alive_interval = 60 # 0 disables keep-alive; omit for the default
TOML accepts values from 0 to 86400. In the host form, use Disable keep-alive
to turn probes off. The interval field is hidden while disabled; otherwise it
accepts 1 to 86400 seconds, with a blank field using the default. Toggling back
on restores the interval entered during the current edit. Changes apply to new
connections. This is a host setting, not a [defaults] field.
FTP and FTPS send NOOP only after an idle interval on the control connection,
never between commands of a running transfer. SFTP sends SSH keep-alive requests,
which can run alongside file transfers. A negative SSH reply still confirms that
the peer is reachable. A probe has 15 seconds to receive a response.
A failed probe marks the connection as disconnected. In the remote browser, use
r to reconnect; in the diff, return to the browser and compare again before
syncing. Completed files retain their results. An interrupted operation may have
an unknown server-side outcome. drift never reconnects or repeats transfers
automatically because of a keep-alive failure.
Keep-alive cannot prevent every server idle limit or network failure. FTP probes
are skipped during busy operations, and an SSH response does not guarantee that
the SFTP subsystem is healthy. TCP keep-alive is separate from these probes.
FTPS certificate trust
FTPS certificates are checked against the operating system trust store, including
the configured hostname and validity period. If that check fails, drift stops the
connection and shows the certificate subject, issuer, validity, problem, and full
SHA-256 fingerprint. Reject is selected by default.
After checking the fingerprint through a trusted channel, you can trust that exact
certificate for the current drift process or permanently. Permanent exceptions are
bound to the normalized hostname and port and stored in
~/.config/drift/trusted-certificates.toml with mode 600. A certificate change or
a newly occurring validation problem asks again. Use r on an FTPS host in the host
manager to reset both session and persistent trust for its endpoint.
The former insecure_tls host option is ignored and no longer written. It does not
grant trust. Custom CA files and client certificates are not supported yet. FTPS
continues to use explicit TLS on the FTP control port and TLS 1.2.
Path Mappings
local paths are relative to the project root. remote paths are relative to the host's root_path.
When effective mappings are configured, only files that fall under a mapping rule can be synced. Files outside all mappings are excluded. Without mappings, all files sync relative to root_path.
Auth types
| Type |
Fields |
keyfile |
key_file, passphrase (optional) |
password |
password (supports $ENV_VAR) |
agent |
none — uses SSH agent |
For a project host, all of these live in ~/.config/drift/access.toml rather
than in the project. For a global host they stay in the global config.
Nothing in your project
drift keeps no file in your working tree. No .drift/ directory, no dotfile, nothing to
add to .gitignore and nothing to commit by accident:
| File |
Holds |
~/.config/drift/config.toml |
global hosts, [ui] |
~/.config/drift/projects.toml |
the registry: slug, name, path, timestamps |
~/.config/drift/projects/<slug>.toml |
one project's hosts and mappings, mode 600 |
~/.config/drift/trusted-certificates.toml |
persistent FTPS certificate exceptions, mode 600 |
The trade-off is deliberate: mappings do not travel with a clone. Every developer enters
them once per machine, and when someone moves a directory in the repo there is no commit
that fixes the mapping for everyone.
Coming from 0.1.6-alpha or earlier
Those versions kept a .drift/config.toml in the project, with credentials either in it or
in ~/.config/drift/secrets.toml. 0.1.7-alpha is the release that moves all of it into
the project store, and it is the only one that can: this version no longer reads those
files at all.
So if you are upgrading from 0.1.6-alpha or earlier, install
0.1.7-alpha first and start
it once in each project — it migrates and reports what it moved — then upgrade to the
current version. Skipping it leaves your hosts in files nothing reads, and drift will look
like it has no hosts for those projects.
If such a config was ever committed, the password in it is in your repository's history,
where deleting the file later does not reach it. Rotate it.
Project Structure
internal/
config/ config types, loader, writer
project/ project registry model + store (projects.toml)
diff/ diff engine, result types, renderer
ftp/ FTP/FTPS client (jlaffaye/ftp)
fs/ local file walker, directory reader
log/ optional file-based diagnostics
pathmap/ local ↔ remote path resolution with mapping rules
remote/ protocol-agnostic Client interface and connection factory
tlstrust/ FTPS certificate verification and trust policy
sftp/ SFTP client
ssh/ SSH auth and known_hosts verification
styles/ shared palettes and lipgloss styles
sync/ sync plan types and direction policy
tui/
app.go root Bubble Tea model, screen routing
browser/ file browser screen
certtrust/ FTPS certificate trust modal
dashboard/ project dashboard screen
projectform/ project create/edit form
projectselector/ project switcher modal
diffview/ diff + sync screen
hostform/ host create/edit form (incl. mapping manager)
hostmanager/ host list screen
hostselector/sync target picker
statusbar/ reusable one-line status renderer
textfield/ shared single-line text input widget
styles.go TUI-facing style aliases
Development
go test ./...
go vet ./...
go build ./...
Local install
make install
Versioned build
make release-build VERSION=vX.Y.Z
Contributing
Issues and pull requests are welcome.
For development notes and contribution workflow, see CONTRIBUTING.md.
License
MIT — see LICENSE.