FileCTRL is a light, opinionated, responsive, theme-able, and simple Text User Interface (TUI) file manager for Linux and macOS
- Simple interface with sensible defaults
- Bookmarks: save and return to frequently-used folders
- Customizable colors: truecolor and 256 color themes, with
LS_COLORSintegration - Rebindable keys via TOML config
- Vim-like navigation and multi-select: hjkl movement, marks, range mode
- File operations: chmod, create directory, and copy/cut/paste across windows
- "Go to" with path completion
- Filtering, searching, and sorting
- Responsive layout: adapts columns and content to the terminal size
Download a pre-built binary. Each release publishes filectrl_{system}_{arch}.tar.gz and a .sha256 checksum for linux_x86_64, linux_arm64, and darwin_arm64 (macOS is Apple Silicon only).
curl -sL https://github.com/andornaut/filectrl/releases/latest/download/filectrl_linux_x86_64.tar.gz | tar -xz filectrl
sudo mv filectrl /usr/local/bin/The archives also contain LICENSE and README.md; tar -xz filectrl extracts only the binary.
On macOS, allow the unsigned binary to run:
xattr -d com.apple.quarantine filectrlcargo build --release && sudo cp target/release/filectrl /usr/local/bin/filectrl [OPTIONS] [DIRECTORY]| Option | Description |
|---|---|
-c, --config <PATH> |
Read the config from PATH, or write it there when combined with a --write-default-* flag |
-i, --include <PATH> |
Merge a TOML file on top of the config. Repeatable; later files take precedence |
--no-truecolor |
Use the 256-color theme instead of detecting truecolor support |
--force |
Replace an existing file when writing defaults, which fails without it |
--print-keybindings |
Print the keybindings, then exit |
--write-default-config |
Write the default config, then exit |
--write-default-themes |
Write the default theme as theme.toml beside the config, then exit |
-V, --version |
Print the version, then exit |
-h, --help |
Print usage, then exit |
The four flags that act and exit are mutually exclusive, and each accepts only the arguments that change what it does:
| Flag | Also accepts |
|---|---|
--print-keybindings |
--config, --include |
--write-default-config |
--config, --force |
--write-default-themes |
--config, --force |
--version |
nothing |
Anything else is reported rather than ignored. Both write flags print the path they wrote, which follows $XDG_CONFIG_HOME and so is not always under ~/.config.
Bookmarks are symlinks to folders, stored in a bookmarks/ directory beside the config file (e.g. ~/.config/filectrl/bookmarks/).
| Key | Action |
|---|---|
| B | Bookmark the current directory. The prompt defaults to the directory's name |
| ' or ` | Show all bookmarks in the table |
| Enter | Navigate to the linked folder |
| r, d | Rename or delete the bookmark |
Names must be unique, cannot be empty, and cannot contain a path separator.
Copying or cutting puts ${operation} ${path} on the system clipboard, where operation is cp or mv. Pasting in another FileCTRL window performs the equivalent of ${operation} ${path} ${current_directory}, e.g. cp filectrl.desktop ~/.local/share/applications/.
Without a system clipboard (e.g. over SSH or on a bare console), copy and paste still work within a single window. Pasting with nothing to paste and no system clipboard to read shows a warning, since an entry copied in another window would be unreachable.
When the destination already contains an entry with the same name, the paste stops and asks:
| Key | Action |
|---|---|
| s | Skip this entry |
| S | Skip this entry and every later collision in the same paste |
| o | Replace the existing entry |
| O | Replace this and every later collision in the same paste |
| Esc | Abandon the rest of the paste |
- An existing directory is never replaced, so only the skip choices are offered for one. Modifier chords are not choices: Ctrl+o abandons the paste.
- S and O also cover copies already running: if another program takes a name inside a directory being copied, the standing answer settles it without stopping the copy. Only S settles a directory. Anything left unsettled is reported when the copy finishes.
- A cut that skipped an entry keeps its original: the skipped entry is not at the destination, so removing the source would take the only copy of it.
- Whatever is not pasted (collisions you abandon, entries that failed) stays on the clipboard, so pasting again retries exactly those. Entries you skip deliberately do not. If nothing was pasted at all, the clipboard is unchanged.
Mark entries to apply chmod, copy, cut, or delete to several at once.
| Key | Action |
|---|---|
| v/Space | Toggle a mark on the current row |
| V | Enter range mode: the current row becomes the anchor. Press again to exit, keeping the marks |
| Esc | Clear all marks and exit range mode |
In range mode, moving the cursor or clicking extends the marked range from the anchor to the cursor. Outside range mode, clicking only moves the cursor. Marking clears the clipboard.
Marks name entries but are stored as row positions, so what becomes of them depends on why the listing changed:
| Change | Marks |
|---|---|
| Sorting, filtering, toggling hidden files | Cleared |
| Reload (Ctrl+r or a watcher refresh) | Kept, re-found by path. An entry that is gone loses its mark |
| A search finishing or being cancelled | Kept |
| Navigating to another directory | Cleared |
| chmod, copy, cut, delete | Consumed by the operation |
The filter (f/\) is a case-insensitive substring match against the Name column, so it matches what is on screen: the entry's own name in a normal listing, the path relative to the search root while searching, and the bookmark name in the bookmarks view.
Directories carry a trailing / outside the bookmarks view, so / filters a listing down to directories, and docs/ matches both the docs directory and, in search results, everything under it.
Search (/) walks the current directory recursively, matching a case-insensitive substring against each entry's name. Symlinked directories are not descended into. search_max_depth and search_max_results in [file_system] bound the walk; on reaching either, FileCTRL keeps the results it has and says so.
Results appear as the walk finds them and settle into the sort order once it ends, whether it finished or was cancelled.
n/m/s sort by name, modified time, or size; clicking a column header does the same. Sorting by the same column again reverses it. Each column starts in the direction it is usually reached for:
| Column | Default direction |
|---|---|
| Name | A-Z |
| Modified | Newest first |
| Size | Largest first |
The Name column orders by the text it displays (while searching, the path relative to the search root), ignoring case and a leading dot on each path segment, so a dot file sorts next to its neighbours the way ls -a does. sort_directories_first in the [ui] section groups directories first, for the Name column only.
All keybindings can be customized.
Normal mode
| Actions | Keys |
|---|---|
| Select next, previous row | ↓/j, ↑/k |
| Select first, middle, last row | Home/g/^, z, End/G (Uppercase)/$ |
| Select top, middle, bottom visible row | H (Uppercase), M (Uppercase), L (Uppercase) |
| Page down, up | PgDn/Ctrl+d/Ctrl+f, PgUp/Ctrl+u/Ctrl+b |
| Go to parent dir | ←/h/b/Backspace |
| Go to previous dir | - |
| Go to home dir | ~ |
| Go to path | :/Tab |
| Open | →/l/Enter |
| Open current directory | t |
| Open new window | w |
| Open with... | o |
| Mark/unmark item | v/Space |
| Range mark | V (Uppercase) |
| Copy, Cut, Paste | y/Ctrl+c, x/Ctrl+x, p/Ctrl+v |
| Rename | r/F2 |
| Chmod (octal) | P (Uppercase) |
| Create directory | c |
| Delete | d/Delete |
| Filter | f/\ |
| Search | / |
| Add bookmark | B (Uppercase) |
| Show bookmarks | '/` |
| Refresh | Ctrl+r/F5 |
| Sort by name, modified, size | n, m, s |
| Toggle show hidden files | . |
| Cancel file or search operations | K (Uppercase) |
| Clear alerts, progress | Ctrl+a, Ctrl+p |
| Clear clipboard/filter/marks/search, exit bookmarks view | Esc |
| Toggle help | ? |
| Quit | q |
Prompt mode
| Actions | Keys |
|---|---|
| Submit | Enter |
| Cancel | Esc |
| Reset to initial value | Ctrl+u/Ctrl+z |
| Select all | Ctrl+a |
| Copy, Cut, Paste text | Ctrl+c, Ctrl+x, Ctrl+v |
| Move cursor | ←/→ |
| Move cursor by word | Ctrl+←/→ |
| Move cursor to start, end | Home, Ctrl+e/End |
| Select text | Shift+←/→ |
| Select to line start, end | Shift+Home, Shift+End |
| Select by word | Ctrl+Shift+←/→ |
| Delete before, after cursor | Backspace, Delete |
| Accept path suggestion (cursor at end of input) | Tab |
| Cycle path suggestions (cursor at end of input) | ↓/↑ |
Note
Ctrl+Shift keybindings require a terminal that supports the kitty keyboard protocol (e.g. Alacritty). tmux users must also add the following to ~/.tmux.conf:
set -g extended-keys on
set -ga terminal-features ",*:extkeys"The configuration is drawn from the first of:
- The path given by
--config ~/.config/filectrl/config.toml, if it exists- The built-in default configuration
filectrl --write-default-config writes the defaults to that path. It writes the configuration keys only; the theme keys are a separate file written by --write-default-themes.
Override only what you want to change:
# ~/.config/filectrl/config.toml
[openers.linux]
open_directory = "alacritty --working-directory %s"
open_filectrl_window = "alacritty --command filectrl %s"Validation is strict: an unrecognized key (a misspelled setting or theme property), an unknown modifier name, or an invalid value (such as buffer_min_bytes exceeding buffer_max_bytes) makes FileCTRL exit with an error rather than ignore it.
| Key | Opens with |
|---|---|
| l | openers.open_file |
| t | openers.open_directory, for the current directory |
| w | openers.open_filectrl_window, a new filectrl window |
| o | A picker of the applications that can open the selection |
# Use [openers.linux] on Linux, or [openers.macos] on macOS.
# %s is replaced at runtime: the current directory, the selected entry, or a
# new window's directory. In run_in_terminal alone it is a command line
# rather than a path (see "Open with..." below).
[openers.linux]
open_directory = "alacritty --working-directory %s"
open_file = "pcmanfm %s"
open_filectrl_window = "alacritty --command filectrl %s"
run_in_terminal = "alacritty --command %s"
[openers.macos]
open_directory = "open %s"
open_file = "open %s"
open_filectrl_window = "open -a Terminal %s"
run_in_terminal = "" # Linux only, ignored hereo replaces the file table with the applications that can open the selection, leaving the breadcrumbs and status bar visible. The default application is listed first and marked (default).
| Key | Action |
|---|---|
| ↓/j, ↑/k | Move between applications |
| →/l/Enter | Open with the selected application |
| 1 to 9 | Open with that numbered application |
| o | Close the picker |
| Esc | Close the picker and reset the view (clears the clipboard, filter and marks, and leaves search or bookmarks) |
Only the first nine rows have a number; scroll to reach the rest. Applications that share a name are collapsed to the best ranked one.
The list is built per platform:
- Linux: the MIME type is resolved through the shared MIME database, including its parent types, so a
.rsfile also offers plain text editors. It is then matched againstmimeapps.listand the.desktopfiles under$XDG_DATA_DIRS/applications, per the mime-apps spec. The application directories are indexed once per run, so an application installed while FileCTRL is open is not offered until the next start. - macOS: Launch Services, which requires macOS 12 or newer. The chosen application is launched with
open -a.
Two openers settings shape the list, and setting either to "" drops its effect:
- Applications that need a terminal (
Terminal=true) run insideopeners.run_in_terminal, whose%sis a command line:xterm -e %sbecomesxterm -e vim '/some file.txt'. openers.open_file(oropeners.open_directoryfor a directory) is offered last, showing its command template beside the setting name, so the picker still works with no application database. Without it, a path that matches nothing shows "No applications found".
[theme] applies to truecolor terminals and [theme256] to 256-color terminals. FileCTRL detects truecolor support via $COLORTERM; --no-truecolor selects the 256-color theme regardless. There is no flag for the other direction: a terminal that supports truecolor but does not set $COLORTERM (common under tmux, and under some SSH and sudo sessions) gets the 256-color theme, so set the variable yourself with COLORTERM=truecolor filectrl.
Each theme entry is a style. All three properties are optional; set fg or bg to "" to inherit the parent widget's color.
| Property | Format | Default |
|---|---|---|
fg |
Color string | Inherited |
bg |
Color string | Inherited |
modifiers |
Array of strings | [] |
- Truecolor (
[theme]): hex strings like"#FF0000", or named colors like"Red" - 256 color (
[theme256]): decimal indexes"0"through"255" - Modifiers:
"bold","dim","italic","underlined","blink","rapid_blink","reversed","crossed_out"
[theme.table.selected]
bg = "#1A1A2E"
[theme.file_type.directory]
fg = "#E94560"
modifiers = ["bold"]| Section | Description |
|---|---|
[theme] / [theme256] |
Base foreground, background, and modifiers |
alert |
Alert bar (base, error, info, warn) |
breadcrumbs |
Path breadcrumbs (base, ancestor, basename, separator) |
clipboard |
Clipboard status indicators (copy, cut, delete) |
file_modified_date |
Date column by age (less_than_minute, less_than_hour, less_than_day, less_than_month, less_than_year, greater_than_year) |
file_size |
Size column by magnitude (bytes, kib, mib, gib, tib, pib) |
file_type |
Row colors by file type (directory, executable, symlink, regular_file, etc.) |
help |
Help panel (base, header, actions, shortcuts) |
notice |
Notice bar (filter, progress) |
open_with |
Open with... picker (base, detail, selected, shortcut) |
prompt |
Input prompt (cursor, input, label, selected) |
scrollbar |
Scrollbar (ends, thumb, track, plus show_ends boolean) |
status |
Status bar (detail, label) |
table |
File table (body, header, header_sorted, selected, marked, delete, bookmark) |
With ls_colors_take_precedence, colors from $LS_COLORS are applied on top of the configured file type colors, including extension patterns such as *.tar=01;31.
[theme.file_type]
ls_colors_take_precedence = trueinclude_files merges other TOML files on top of the config:
include_files = ["theme.toml"]- Relative paths resolve from the directory containing the config file; absolute paths are used as-is
- Files merge in order, later ones taking precedence over the base config and over earlier files
- The value must be an array of strings, and every listed file must exist and parse, or FileCTRL exits with an error
Export the defaults, then copy and edit:
filectrl --write-default-themes # writes ~/.config/filectrl/theme.toml
cp ~/.config/filectrl/theme.toml ~/.config/filectrl/solarized.toml--include/-i applies a theme without editing the config. It is repeatable and merges in order, later ones taking precedence. Unlike include_files, relative paths resolve against the current directory:
filectrl -i ~/.config/filectrl/solarized.toml -i overrides.toml| Theme | Inspired by | Screenshot |
|---|---|---|
| IBM1970 (default) | vscode-ibm1970-theme | ![]() |
| 42KM | vscode-42km-theme | ![]() |
filectrl --include themes/42km.tomlKeybindings live in the [keybindings] section of config.toml. A value is a single key string or an array of them.
[keybindings]
# Normal mode
quit = "q"
toggle_help = "?"
...
# Prompt mode
prompt_submit = "Enter"
prompt_reset = ["Ctrl+u", "Ctrl+z"]
...Key strings support:
| Form | Examples |
|---|---|
| Single characters | "q", "/", "~", "^", "$" |
| Uppercase (implies Shift) | "G", "V", "N" |
| Named keys | "Enter", "Esc", "Backspace", "Delete", "Space", "Tab", "BackTab", "Up", "Down", "Left", "Right", "Home", "End", "PgUp", "PgDn" |
| Function keys | "F2", "F5" |
| Modifier prefixes | "Ctrl+c", "Shift+Left", "Ctrl+Shift+a" |
"Shift+g" is equivalent to "G", and "Shift+Tab" to "BackTab".
Binding one key to two actions in the same mode prevents startup, including a collision between a key you configured and a default you did not override. Assigning the same key to one action more than once is allowed.
Some keys are hardcoded and always work alongside any configured keys, scoped to their mode:
| Mode | Hardcoded |
|---|---|
| Normal | Arrow keys, Home/End, PageUp/PageDown, Esc |
| Prompt | Esc (cancel), Tab (accept suggestion), ↓/↑ (cycle suggestions) |
Because the scoping is per mode, Tab is still configurable in normal mode, where the default goto binding uses it. Binding a hardcoded key to a different action in the same mode prevents startup; binding it to its own action is allowed.
The help view (?) reflects the configured keybindings.
To make filectrl the default application for opening directories:
cp filectrl.desktop ~/.local/share/applications/
xdg-mime default filectrl.desktop inode/directory
update-desktop-database ~/.local/share/applications/- andornaut@github /til/rust
- See Cargo.toml for dependencies.
- Download files and folders of various types to test colors
# Run against a directory, logging to ./err
RUST_LOG=debug,notify=info cargo run -- fixtures/ 2>err
# Typecheck the macOS-only code without a Mac
rustup target add aarch64-apple-darwin
cargo check --target aarch64-apple-darwinfixtures/ is a committed file tree for manual UI testing. Navigate into it with cargo run to exercise rendering edge cases:
| Path | Covers |
|---|---|
file_types/ |
Named pipe, symlinks, executable, and directory permission variants (other-writable, sticky) |
no_delete/ |
Delete and rename permission errors. Needs chmod 555 fixtures/no_delete first; git does not track the read-only bit |
scrolling/ |
48 entries with long filenames interspersed, for scrolling and multi-row truncation |
| Elsewhere | Executables, symlinks, hidden files, Unicode names, special characters, long filenames |
Date-color and size-color buckets need fixtures git cannot store (mtimes, sparse files); create them locally with touch -t and truncate.
Changing cargo-husky configuration:
- Edit the
[dev-dependencies.cargo-husky]section of Cargo.toml rm .git/hooks/pre-commit(or other hook file)cargo cleancargo test- Verify that the changes have been applied to
.git/hooks/pre-commit
Push a semantic version tag from an up-to-date main. The release workflow builds the binaries and creates the GitHub Release.
git tag -a v1.0.0 -m "Release v1.0.0"
git push origin v1.0.0Pushes to main rebuild the rolling dev release. The workflow manages that tag; do not push it manually.

