Skip to content

Repository files navigation

FileCTRL

Release License: MIT

FileCTRL is a light, opinionated, responsive, theme-able, and simple Text User Interface (TUI) file manager for Linux and macOS

42KM theme

Features

Installation

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, a binary downloaded with a browser is quarantined; allow the unsigned binary to run (curl sets no quarantine attribute, so this step is not needed after the command above):

xattr -d com.apple.quarantine filectrl

Building

Requires Rust 1.97 or later.

cargo build --release && sudo cp target/release/filectrl /usr/local/bin/

Usage

filectrl [OPTIONS] [DIRECTORY]
Option Description
-c, --config <PATH> Read the config from PATH
-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
--print-default-config Print the default config, then exit
--print-default-theme Print the default theme, then exit
--print-keybindings Print the keybindings, then exit
-V, --version Print the version, then exit
-h, --help Print usage, then exit

DIRECTORY defaults to the current working directory. If given, it must exist, be a directory, and be listable, or FileCTRL exits with an error, like ls.

The four flags below act and exit. They are mutually exclusive, and each accepts only the arguments that change what it does:

Flag Also accepts
--print-default-config nothing
--print-default-theme nothing
--print-keybindings --config, --include
--version nothing

Anything else is reported rather than ignored.

Signal handling and exit statuses are described under Signals.

Info and warning alerts clear on the next key that does something; errors stay until you clear them (Ctrl+l or a click). Esc clears every alert.

Default keybindings

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 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
Edit in $VISUAL/$EDITOR, page in $PAGER e, i
Mark/unmark item v/Space
Range mark V (Uppercase)
Mark every shown row, ending range mode Ctrl+a
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 (search results always include them) .
Cancel paste, delete or search K (Uppercase)
Clear alerts, progress Ctrl+l, Ctrl+p
Reset the view: clear the copied or cut entry, filter, marks and search, and leave the bookmarks view (with help shown, only closes help) Esc
Toggle help ?
Quit (asks first while a copy, move or delete is running, which quitting would end part way through) 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+←/→, Alt+b/f
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
Delete word before, after cursor Ctrl+w/Alt+Backspace, Alt+d/Alt+Delete
Delete to start, end Ctrl+j, Ctrl+k
Accept path suggestion (cursor at end of input) Tab
Cycle path suggestions (cursor at end of input) ↓/↑

In the Go to prompt, ~ alone or a leading ~/ stands for the home directory. Other input that is not an absolute path, ~backup included, is relative to the current directory.

A suggestion is shown with its position as (N of M), and cycling wraps in both directions. Enter with the cursor at the end of the input accepts the suggestion shown before going there, so /tmp/fo opens /tmp/foo/ when that is the suggestion. Moving the cursor off the end of the input dismisses it. A directory of more than 10,000 entries offers no suggestions.

A key with no prompt binding is passed to the text input (ratatui-textarea), whose defaults are emacs-style: besides the keys above, Ctrl+b/f move by character, Ctrl+h/d delete before and after the cursor, and Ctrl+y pastes the text last cut in the prompt. The input is one line: Tab (outside the Go to prompt), Enter with a modifier, and Ctrl+m are ignored rather than inserted.

Text pasted through the terminal (bracketed paste) goes into a text prompt as one line, with its line breaks removed. A paste anywhere else, including a y/n prompt, is ignored, so pasted text never acts as keys.

Note

Ctrl+Shift with a letter (a "Ctrl+Shift+a" binding, say) requires a terminal that supports the kitty keyboard protocol (e.g. Alacritty): the legacy encoding sends one byte for both Ctrl+a and Ctrl+Shift+a, so the Shift cannot survive it. Ctrl+Shift with an arrow key does not need the protocol, because the legacy encoding does carry modifiers for arrows.

Under tmux, add the following to ~/.tmux.conf as well:

set -g extended-keys on
set -ga terminal-features ",*:extkeys"

Bookmarks

Bookmarks are symlinks to folders, stored in a bookmarks/ directory beside the config file (e.g. ~/.config/filectrl/bookmarks/ on Linux).

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

Adding a bookmark, pasting, and creating a directory are refused while the bookmarks are shown, since each would act on the directory hidden behind them.

Names must be unique, cannot be empty, and cannot contain a path separator. A name is used as typed, surrounding whitespace included, the same as for rename and create.

Copy / paste

Copying or cutting puts cp <path> or mv <path> on the system clipboard. Pasting in any FileCTRL window then runs the equivalent of cp <path> <current directory> (or mv), e.g. cp filectrl.desktop ~/.local/share/applications/. Clipboard text is pasted only when every path in it is absolute. An entry the pasting window did not write, including one from another FileCTRL window, asks for confirmation first (y pastes, any other key cancels); one with a . or .. component is refused.

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 shows a warning. A confirmed delete clears the clipboard; declining it leaves the clipboard as it was.

When the destination already contains an entry with the same name, the paste stops and asks:

Key Action
s Skip this entry
S Skip every collision the paste meets
o Replace the existing entry
O Replace every collision the paste meets
Esc Abandon the rest of the paste
  • A directory is never replaced and never replaces anything, like cp -R and mv: pasting onto one, or pasting a directory onto any entry, asks with only the skip choices.
  • o and O answer for the name, like cp -f and mv -f: whatever holds it when that entry is pasted is replaced. A replacement that fails or is cancelled leaves the old entry.
  • A name that was free when the paste reached it and is taken by the time it is written is not replaced: that entry fails and is reported.
  • Two pasted entries of the same name never collide: like mv a/x b/x dest/, the first takes the name and the second is refused and stays on the clipboard.
  • K cancels a whole paste or delete at once. Pressed again, it reaches the paste or delete before it.
  • A paste consumes the clipboard as its entries start. What never started (collisions you abandon, entries refused before starting) stays on it, so pasting again retries exactly those.
  • A cut across filesystems that failed to copy any entry keeps its whole original.

Permissions and times, moves across filesystems, and the edge cases above are described under Copy and paste details.

Chmod

Chmod (P) refuses a symlink rather than changing its target.

Entries that change after they are listed

Rename, chmod, delete, copy and cut act on whatever the path names when they run, like mv, chmod and rm, so an entry replaced since it was listed is the one acted on. A symlink inside a tree being copied or deleted is copied or deleted as a link, never followed.

A delete continues past an entry it cannot remove, like rm -rf: it removes everything else, keeps the directories holding what failed, and reports the failures when it finishes. An empty directory it cannot open (mode 000) is removed, and an entry already gone counts as removed, so deleting marked entries that include both a directory and something inside it succeeds.

When the directory being viewed is renamed away, removed, or made unreadable, the next refresh reports it and stops watching it; Ctrl+r tries again. The bookmarks view watches the bookmarks directory instead, so one added or removed elsewhere shows up.

Multi-select

Mark entries to apply chmod, copy, cut, or delete to several at once.

Key Action
v/Space Toggle a mark on the current row. In range mode, exit it instead, keeping the marks, as v leaves Visual mode in Vim
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. Marks made before entering range mode are kept, so ranges and single marks combine. Outside range mode, clicking only moves the cursor. The mouse wheel scrolls the list three rows at a time without moving the cursor, so it never extends a range; the next key acts on the cursor and brings it back into view. Marking clears the clipboard. The notices bar shows the mark count as [Selected] N items, or [Range] N items while range mode is on.

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
Starting a search 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
Showing bookmarks Cleared
The bookmarks view reloading (a watcher refresh, or a bookmark operation finishing) Kept, re-found by path, as is the cursor
Navigating to another directory Cleared
Copying or cutting Kept, so what is on the clipboard stays marked
chmod, delete, or pasting Consumed by the operation

Range mode survives a directory reload in the same way: its anchor and the marks made before it are re-found by path, and the range extends from the anchor again once the cursor moves. It ends if the anchor entry is gone, and with every other change above.

Filtering

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. The table narrows as you type. Enter keeps the filter. Esc, or anything else that closes the prompt without submitting it (such as a double-click that opens a file), puts back the one that was applied when the prompt opened. Like any change to what is listed, the first edit clears the marks.

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.

While a filter or hidden files leave entries out of a directory listing, the status bar's # Items reads shown of total, such as 3 of 120. When the cursor is on a symlink, the status bar shows what it points to after ->, as stored in the link.

Searching

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. Directories below the one searched that the walk cannot read are skipped and counted in one warning when it ends; a directory that cannot be searched at all is reported as an error.

Results appear as the walk finds them and settle into the sort order once it ends, whether it finished or was cancelled. The cursor then goes to the top row, unless you moved it or marked a row while the results streamed in, in which case it stays on that entry. Navigating to another directory stops the walk; a reload does not. Once the walk has ended, a reload (Ctrl+r, an operation finishing, or a watcher refresh) reads the results again by path: one deleted or renamed since drops out, and the rest show what they hold now. A finished search keeps its notice, with the query and how many results it found ([Search: 42 results] query), and the status bar's # Items counts the results while they are listed.

Sorting

n/m/s sort by name, modified time, or size; clicking a column header does the same. A header shows its key in brackets ([N]ame) while that key is the column's initial. 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. Runs of digits compare as numbers, so file2 sorts before file10; set natural_sort = false in the [ui] section to compare character by character instead. sort_directories_first in the same section groups directories first, for the Name column only. Entries with the same modified time or size are ordered by name, A-Z, whichever way the column points.

Configuration

The built-in default configuration is always the base. A config file merges on top of it, read from the first of:

  1. The path given by --config
  2. config.toml in the config directory, if it exists: ~/.config/filectrl/ on Linux ($XDG_CONFIG_HOME/filectrl/ when that is set), ~/Library/Application Support/filectrl/ on macOS. The examples below use the Linux path. A symlink there whose target is missing is an error, not an absent config

--config replaces the user config rather than adding to it, so a key the given file leaves out falls back to the built-in default, not to the config directory's config.toml.

filectrl --print-default-config prints the defaults to redirect into a file. It prints the configuration keys only; --print-default-theme prints the theme keys:

mkdir -p ~/.config/filectrl
filectrl --print-default-config > ~/.config/filectrl/config.toml

Override only what you want to change:

# ~/.config/filectrl/config.toml
log_level = "warn"

[ui]
show_hidden_files = false
sort_directories_first = false

Logs are written to stderr only when it is redirected (e.g. filectrl 2>filectrl.log), since otherwise it is the terminal the interface is drawn on. log_level sets the level, and $RUST_LOG overrides it.

Validation is strict: an unrecognized key (a misspelled setting or theme property), an unknown modifier name, or an invalid value (such as a refresh_debounce_milliseconds below 100, an empty key list, or an opener without %s as its own unquoted word) makes FileCTRL exit with an error naming the file, rather than ignore it.

Opening in other applications

Key Opens with
l openers.open_file
t openers.open_directory, for the current directory
w openers.open_filectrl_window, a new filectrl window (on macOS by default, a Terminal window in the directory)
o A picker of the applications that can open the selection
e $VISUAL, else $EDITOR, else vi, in this terminal
i $PAGER, else less, in this terminal

e and i suspend FileCTRL and run the program on the entry under the cursor, then return to the listing and refresh it. The variable is split into words the way a shell splits it (code --wait is a program and an option), and the path is passed as an argument of its own, never through a shell. A directory is refused. Ctrl+c and Ctrl+\ go to the program while it runs. A program that exits with an error, or cannot be started, is reported as an alert.

Each template runs with sh -c. The path is never written into the command: %s becomes a reference to it ("$@"), and the path is passed to the shell as an argument, so the shell expands it but never parses it. A file name therefore cannot run as a command wherever %s sits. Only a template that hands the text to another parser can still run it: eval, a nested sh -c, ssh, bash arithmetic such as $(( %s )), or AppleScript's do script, which types a command line into Terminal's login shell. That is why the macOS default opens a Terminal window in the directory rather than starting filectrl in it.

Write %s unquoted, as its own word: open %s, not open "%s". The reference carries its own quotes, so a %s inside double quotes is split into words and one inside single quotes stays the literal text "$@". Neither is supported, and neither runs the name; a non-empty template without an unquoted %s word is refused when the config loads.

# Use [openers.linux] on Linux, or [openers.macos] on macOS.
# %s stands for the current directory, the selected entry, or a new window's
# directory.
[openers.linux]
open_directory = "alacritty --working-directory %s"
open_file = "pcmanfm %s"
open_filectrl_window = "alacritty --command filectrl %s"

[openers.macos]
open_directory = "open %s"
open_file = "open %s"
open_filectrl_window = "open -a Terminal %s"

The "Open with" picker

o 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
Home/g/^, End/G/$ (and their normal-mode bindings) Move to the first or last application
PageDown, PageUp (and their normal-mode bindings) Move a page down or up
→/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: clear the copied or cut entry, filter, marks and search, and leave the bookmarks view

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: requires the gio command from GLib (package libglib2.0-bin on Debian and Ubuntu, glib2 on Arch and Fedora). gio info gives the content type, and gio mime lists the default application, then the other registered and recommended ones, in GLib's order. Each is named by the Name= of its desktop file, found under $XDG_DATA_HOME/applications and $XDG_DATA_DIRS/*/applications, and launched with gio launch, which handles its Exec, Terminal= and Path= keys. Names are not localized. An application whose desktop file cannot be found is not offered. Without gio, the picker offers only the configured opener and an alert says gio is required.
  • macOS: Launch Services, which requires macOS 12 or newer. The chosen application is launched with open -a.

openers.open_file (or openers.open_directory for a directory) is offered last, showing its command template beside the setting name, so the picker still works with no application database. Set it to "" to leave it out. Without it, a path that matches nothing shows "No applications found".

Theming

[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.

Style properties

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"]

Theme sections

Section Description
[theme] / [theme256] Base foreground, background, and modifiers
alert Alert bar (its own style, plus error, info, warn)
breadcrumbs Path breadcrumbs (its own style, plus ancestor, basename, bookmarks, search, separator)
clipboard Clipboard status indicators (copy, cut)
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 (its own style, plus header, actions, shortcuts)
notice Notice bar (filter, progress, search, search_loading)
open_with Open with... picker (its own style, plus detail, selected, shortcut)
prompt Input prompt (cursor, delete, goto_suggestion, 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)

LS_COLORS integration

Off by default. With use_ls_colors in [ui], colors from $LS_COLORS are applied on top of both themes' file type colors, whichever theme is included, including patterns such as *.tar=01;31. A pattern matches the end of the whole name as ls does: *.gitignore colors the dotfile .gitignore, case is ignored unless the same pattern is listed in two cases, and the last listed match wins. An explicit reset (a value of exactly 00, 0, or nothing, as in di=00 or *.txt=) renders those entries plain, as ls does, except for the keys ls only consults while they are colored: ow, st, tw, su, sg, ex and or reset that way are skipped, so the entry takes the next rule's color (ow=00 shows other-writable directories in the di color). Other values made only of reset codes, such as 0;00, count as a color and render plain for every key. A reset clears the colors and attributes before it, so 31;00 renders plain too.

[ui]
use_ls_colors = true

External theme files

include_files merges other TOML files on top of the config:

include_files = ["theme.toml"]
  • Relative paths resolve from the directory containing the file that lists them, as named: a symlinked config or include file resolves from the directory holding the link, not the one it points to. 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, be a regular file (or a symlink to one), and parse, or FileCTRL exits with an error. The same holds for the config file and for --include

Export the default theme, then edit it:

mkdir -p ~/.config/filectrl
filectrl --print-default-theme > ~/.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

Bundled themes

Theme Inspired by Screenshot
IBM1970 (default, --print-default-theme) vscode-ibm1970-theme IBM1970
42KM vscode-42km-theme 42KM

The release archives do not include the theme files, so from a source checkout:

filectrl --include themes/42km.toml

Otherwise, download the theme file first:

curl -fsSL --create-dirs -o ~/.config/filectrl/42km.toml \
  https://raw.githubusercontent.com/andornaut/filectrl/main/themes/42km.toml
filectrl --include ~/.config/filectrl/42km.toml

Customizing keybindings

Keybindings 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 (any case) "Enter", "Esc", "Backspace", "Delete", "Space", "Tab", "BackTab", "Up", "Down", "Left", "Right", "Home", "End", "PgUp", "PgDn"
Aliases "Return" ("Enter"), "Escape" ("Esc"), "Del" ("Delete"), "PageUp" ("PgUp"), "PageDown" ("PgDn")
Function keys "F1" to "F24"
Modifier prefixes (Ctrl+, Shift+, Alt+, any case) "Ctrl+c", "Shift+Left", "Alt+x", "Ctrl+Shift+a"

"Shift+g" is equivalent to "G", and "Shift+Tab" to "BackTab". For a character key, Shift on its own applies only to letters with a single uppercase form: a shifted digit or symbol arrives as the character it produces, so bind "!" rather than "Shift+1", which is refused, as are "Shift+Space" (it arrives as a plain Space) and Shift on a letter such as ß. With Ctrl or Alt, write the letter lowercase and add Shift+ for the uppercase one ("Ctrl+Shift+g" or "Alt+Shift+g"); "Ctrl+G" and "Alt+G" are refused. "Alt+Shift+g" works in any terminal, while "Ctrl+Shift+g" needs the kitty keyboard protocol (see the note under the prompt keys).

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.

Desktop entry

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/

Details and limitations

Limitations

Area Limitation
Delete There is no trash and no undo: a delete is permanent.
Delete A large delete shows 0% while it counts the entries to remove.
Delete, copy A tree that contains a bind mount of one of its own ancestors is walked until paths grow too long, and a delete through it removes files in that ancestor.
All operations Rename, chmod, delete, copy and cut act on whatever the path names when they run, not on the entry as it was listed.
Chmod Applies only to the selected entries, never recursively. A symlink is refused.
Copy Copies like cp -R without -p: the umask applies, setuid, setgid and sticky bits are dropped, and no times are kept.
Paste Directories are never merged, and a directory never replaces or is replaced by another entry.
Paste Overwrite replaces whatever holds the name when that entry is pasted, like cp -f and mv -f.
Paste A name that was free when the paste reached it and is taken before that entry is written fails the entry; it is not asked about. On a filesystem without an atomic no-replace rename, a move can replace it instead.
Paste Replacing an entry needs room for the old and new entries at once.
Paste A cancelled copy leaves a partial file under its final name, unless it was replacing an entry.
Paste A process killed while replacing an entry leaves a hidden .filectrl-<pid>-<n> file beside it.
Paste A later "all" answer at the collision prompt replaces an earlier one, and any key that is not a choice abandons the paste.
Move across filesystems Keeps mode and modification time only: not owner, group, access time, extended attributes or ACLs. Hard links become separate files.
Move across filesystems The original is removed once everything is copied, so anything written into it during the copy is lost. If any entry fails, the whole original is kept. Once removal starts, the move cannot be cancelled.
Clipboard Only absolute paths are pasted. Without a system clipboard (over SSH, on a console), copy and paste work within one window only.
Open with Cannot set a default application (use gio mime <type> <application> or xdg-mime default). It opens the entry under the cursor and ignores marks. On macOS, requires macOS 12 or newer.
Open with On Linux, requires the gio command from GLib, and application names are shown unlocalized.
Display The truecolor theme is used only when $COLORTERM contains truecolor or 24bit; no flag forces it.
Display A shortened path is not fitted to the terminal width, so it can wrap on a narrow terminal.
Signals Ctrl+z is ignored except while an editor or pager runs. A program that stops only itself, rather than its process group, leaves FileCTRL waiting.
Signals Terminal settings an editor or pager leaves changed (such as echo off) stay changed after it exits.
Signals On macOS, a closed terminal is detected only through SIGHUP.

Signals

SIGTERM, SIGINT, SIGHUP, SIGQUIT, SIGUSR1, SIGUSR2 and SIGALRM restore the terminal and exit with status 128 plus the signal number (143 for SIGTERM), as a shell reports a process the signal killed. A terminal that closes is answered as SIGHUP (status 129) even when no SIGHUP arrives, which happens when the shell ignores it (trap "" HUP). On macOS, which cannot poll a terminal device, only the signal counts. SIGTSTP is ignored, since a stopped process would leave the terminal in raw mode. While the editor or pager runs, the terminal is back in the shell's modes, so Ctrl+z stops FileCTRL together with the program and fg resumes both, as does a program that suspends itself by signalling its process group (as vim does). SIGTERM and SIGHUP meanwhile are not passed to the program: FileCTRL exits when the program does. SIGINT and SIGQUIT are the program's alone, like system(3): FileCTRL does not act on them, even when sent with kill, since they cannot be told apart from Ctrl+c and Ctrl+\.

Copy and paste details

The confirmation for an entry from elsewhere shows each path in full, except that where the terminal is too narrow a path loses its start to …, so the file name and the question stay visible. It is asked because any program can put such text on the clipboard. A shell line such as cp build dist copied from elsewhere is ignored, since its paths are not absolute. Without a system clipboard, the warning for pasting with nothing to paste says so, since an entry copied in another window would be unreachable.

A paste copies the way cp -R does without -p: the umask applies to each entry's mode, the original's setuid, setgid and sticky bits are dropped, and no times are kept (a directory created inside a setgid directory keeps the setgid bit it inherits). A cut that crosses filesystems copies and then removes the original, the way mv does: files and directories keep their full mode and modification time, and special files their mode. Nothing else is kept: not access times, owner, group, extended attributes or ACLs. Like mv, it removes the whole original once every entry is copied without an error, so something written into the original while the copy ran is removed with it, and from that point it can no longer be cancelled; an entry of it that cannot be removed is reported and the rest are still removed. Hard links in the original are copied as separate files, as a plain cp -R copies them, so names that shared one file no longer do.

Collisions:

  • Modifier chords are not choices at a collision: Ctrl+o abandons the paste.
  • A move within one filesystem replaces an entry with a single rename. A copy, or a cut across filesystems, is written under a hidden name beside the entry, .filectrl-<pid>-<n>, and renamed over it once complete: one that fails or is cancelled leaves the old entry as it was. One left behind by a process that was killed part way can be deleted. A replacement needs room for both entries at once.
  • K stops the entry being copied, moved or removed at its next check, and the ones queued behind it end without running.
  • Entries you skip deliberately are not put back on the clipboard. If nothing started at all, the clipboard is unchanged. An entry that fails or is cancelled after it started, including while it waits behind other operations, is reported and is not put back on the clipboard; its original is left where it was.

Developing

# 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-darwin

fixtures/ is a committed file tree for manual UI testing. Navigate into it with cargo run to exercise rendering edge cases:

Path Covers
file_types/ Valid and broken symlinks, an executable, and a regular file
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

Some cases need fixtures git cannot store; create them locally:

  • Date-color and size-color buckets (mtimes, sparse files): touch -t and truncate
  • A named pipe: mkfifo
  • Other-writable and sticky directories: chmod o+w, chmod +t

Git hooks

The pre-commit hook runs cargo fmt --check, the test suite in a container (scripts/test-in-container) and cargo clippy --locked --all-targets -- -D warnings, then the same clippy for aarch64-apple-darwin when that target is installed (rustup target add aarch64-apple-darwin). It does not format for you: run cargo fmt and stage the result when the check fails.

Changing cargo-husky configuration:

  1. Edit the hook script in .cargo-husky/hooks/, or the cargo-husky entry under [dev-dependencies] in Cargo.toml
  2. rm .git/hooks/pre-commit (or other hook file)
  3. cargo clean
  4. cargo check --tests, which builds cargo-husky and installs the hook
  5. Verify that the changes have been applied to .git/hooks/pre-commit

Running the tests

The tests create, copy and delete files, so run them in a container rather than directly on the host:

scripts/test-in-container            # the whole suite
scripts/test-in-container copy       # arguments are passed to `cargo test`

It needs Docker. The checkout is mounted read-only, the tests run without network access, and the only writable mount is the filectrl-test Docker volume, which holds the cargo cache, the build output and TMPDIR. Remove it with docker volume rm filectrl-test.

Releasing

Set version in Cargo.toml, then push a matching v-prefixed semantic version tag from an up-to-date main; the release fails if the tag does not match. 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.0

Pushes to main rebuild the rolling dev release. The workflow manages that tag; do not push it manually.

About

A light, opinionated, responsive, theme-able, and simple Text User Interface (TUI) file manager for Linux and macOS

Topics

Resources

Stars

3 stars

Watchers

1 watching

Forks

Releases

Used by

Contributors

Languages