Dual-pane file manager: a Rust workspace (Axum server + Tauri shell) with a Mithril/TypeScript frontend. See file-manager-coding-agent-spec.md for the full specification and TASKS/README.md for the implementation task index.
| Tool | Minimum version | Notes |
|---|---|---|
| Rust toolchain | 1.97.1 (stable) | Pinned via rust-toolchain.toml. Install via rustup. |
| Node.js | 22 LTS | Managed by nvs or nvm. |
| pnpm | 11 | npm install -g pnpm or corepack enable. |
| cargo-watch | latest | cargo install cargo-watch — used in the recommended dev flow. |
Tauri prerequisites (desktop builds only):
- macOS: Xcode Command Line Tools (
xcode-select --install). No additional WebView runtime needed (uses system WebKit). - Windows: Microsoft C++ Build Tools (Visual Studio 2022 or Build Tools for Visual Studio) and WebView2 Runtime (ships with Windows 11; installer available at https://developer.microsoft.com/microsoft-edge/webview2/).
- Linux:
webkit2gtk-4.1,build-essential,curl,wget,file,libssl-dev,libayatana-appindicator3-dev,librsvg2-dev. See the Tauri prerequisites guide for your distro.
Cargo.toml workspace root — all Rust crates declared here
package.json root scripts (dev, test, lint, build, api:*)
apps/
fm-cli/ thin CLI wrapper (export-openapi subcommand)
fm-desktop/ Tauri shell (src-tauri/ contains tauri.conf.json)
fm-server/ Axum HTTP server and SSE endpoint
crates/
fm-application/ core application service, action registry
fm-domain/ canonical domain types (entries, locations, …)
fm-operations/ operation engine: jobs, scheduler, conflict handling
fm-platform/ platform-adapter trait and capability flags
fm-platform-macos/ macOS implementation (icons, trash, terminal, …)
fm-platform-windows/ Windows implementation (drag-out only so far)
fm-plugin-api/ plugin manifest, permissions, contribution types
fm-plugin-runtime/ restricted Lua sandbox and plugin lifecycle
fm-transport-dto/ OpenAPI-serialisable DTOs shared by server and client
fm-vfs/ VFS provider trait and capability flags
fm-vfs-local/ local filesystem provider
fm-vfs-sftp/ SFTP provider (fm-ssh session layer)
fm-connections/ remote connection profiles
fm-credentials/ credential store abstraction
fm-credentials-macos/ macOS Keychain backend
fm-credentials-windows/ Windows Credential Manager backend
fm-events/ typed event bus and replay buffer
fm-search/ recursive filesystem search
fm-settings/ versioned JSON settings with migrations
fm-ssh/ SSH session and host-key verification
fm-metadata/ file metadata helpers
fm-archive/ archive VFS provider (zip, tar, …)
fm-test-support/ shared test fixtures and helpers
frontend/
src/ Mithril/TypeScript sources
openapi/ checked-in OpenAPI document (do not hand-edit)
docs/
architecture/ Architecture notes and file-format contracts
decisions/ Architecture Decision Records (ADRs 0001–0011)
plugin-api/ Plugin API reference (README.md)
plugins/ Bundled sample plugins (Lua)
TASKS/ Per-task implementation files (task tracker)
See AGENTS.md for repository conventions, and run pnpm run <script> at the repo
root (dev, test, lint, build, ...) — see the root package.json for the full list.
| Command | What it does |
|---|---|
pnpm dev |
Start the Vite dev server with the mock client (default). No Rust process needed. |
pnpm dev:mock |
Same as pnpm dev — mock runtime explicitly selected (VITE_RUNTIME=mock). |
pnpm dev:http |
Start Vite against the Axum backend (VITE_RUNTIME=http). Requires Terminal 1 below. |
pnpm dev:tauri |
Launch the Tauri desktop app in dev mode (VITE_RUNTIME=tauri). |
pnpm test |
Run Rust tests + frontend tests + script tests. |
pnpm test:rust |
cargo test --workspace |
pnpm test:frontend |
Vitest (frontend unit tests) |
pnpm lint |
Rust + Biome linting/formatting checks |
pnpm api:export |
Export frontend/openapi/openapi.json from the running server |
pnpm api:generate |
Regenerate the Orval Fetch client under frontend/src/api/ |
pnpm api:check |
Export + generate and fail if either checked-in file would change |
pnpm build |
Production Rust + frontend build |
pnpm build:tauri |
Package the Tauri desktop app (.app/.dmg on macOS, .msi/.exe on Windows) |
# Terminal 1 — Axum backend (auto-rebuilds on file change)
FM_SERVER_PORT=8787 cargo watch -x "run -p fm-server"
# Terminal 2 — Vite dev server with /api proxy to localhost:8787
pnpm dev:httpThe Vite server starts at http://127.0.0.1:5180.
How the proxy works: Vite forwards every /api/* request to http://127.0.0.1:8787. For SSE
(GET /api/v1/events), the proxy configuration in frontend/config/api-proxy.ts disables
compression, removes timeouts, and adds cache-control: no-cache, no-transform and
x-accel-buffering: no so that events are flushed to the browser without buffering.
The VITE_RUNTIME environment variable selects the client adapter at build time:
VITE_RUNTIME |
Adapter | Backend needed |
|---|---|---|
mock (default) |
In-process mock — fixtures up to 1 M entries | None |
http |
HTTP + SSE against Axum | fm-server on port 8787 |
tauri |
Tauri IPC commands + Tauri channel events | Embedded in the Tauri shell |
When fm-server is running, open http://127.0.0.1:8787/api/v1/docs to browse the interactive
OpenAPI documentation. The raw OpenAPI JSON is at http://127.0.0.1:8787/api/v1/openapi.json.
- AGENTS.md — coding-agent rules and repository conventions
- docs/decisions/ — Architecture Decision Records (ADR 0001–0011)
- docs/plugin-api/README.md — Plugin API reference
- TASKS/README.md — Implementation task index and milestone status
- ROADMAP.md — What is done, mocked, and not yet implemented
For deterministic frontend development without Axum or Tauri, run pnpm dev:mock. The mock
adapter provides nested and special-case directory fixtures, configurable loading/failure states,
scriptable backend events, and lazily generated directories of up to 1,000,000 entries.
The custom Mithril directory table uses fixed-height virtual rows from --fm-row-height, so large
and lazy mock directories mount only the visible window plus overscan. It exposes semantic grid
rows and cells, cursor/selection rendering hooks, explicit loading/empty/error states, and a
reproducible million-entry rendering check via pnpm --dir frontend benchmark:directory-table.
The presentation-only pane composes that table with a compact single-tab strip, clickable
filesystem breadcrumbs, Ctrl/Cmd+L path editing, inline navigation errors, and entry, selection,
size, and sort status counters.
Name, extension, size, and modified headers sort the loaded page in either direction, using stable
natural name ordering and raw metadata values; large sorts yield cooperatively to keep the UI
responsive. Folder grouping comes from the persisted tab view rather than the table component.
The cursor also drives a cancellable lazy metadata summary, while typed size/date presentation
settings keep table and summary formatting consistent.
Per-pane selection is keyed by stable entry IDs and remains independent of the keyboard cursor.
Arrow, page, edge, range, toggle, select-all, pane-switching, open and parent bindings are handled
through the action-registry keybinding dispatcher, with settings overrides, host-platform modifiers
and type-to-select. Numpad *, +, and - invert selection or select/deselect visible files by a
prompted glob mask; top-row Shift+8, Shift+=, and - provide keypad-free equivalents. Bindings
intentionally use character keys because browser keyboard events do not reliably distinguish the
numpad characters across layouts. While a prefix is
active it appears behind a divider at the right of the pane footer, highlights the first matching
in-word occurrence in every matching name, and constrains keyboard cursor movement to those
matches. Backspace edits the prefix, Escape clears it and the selection, and an unmatched prefix
briefly flashes red but remains editable. Non-root directories prepend a synthetic .. row that
navigates to the parent without entering the selectable file set.
Ctrl/Cmd+P opens a custom, keyboard-first command palette over the already-loaded action registry.
It fuzzy-filters action titles, ids and categories, ranks matches and recently used commands, shows
shortcuts and availability reasons, prompts for schema-defined parameters, and returns focus to its
previous target when closed. Enabled plugin actions use that same registry, so they automatically
appear in the palette and context menus. Plugins currently run in a restricted Lua sandbox with
resource limits and per-plugin bounded diagnostics; Lua failures create non-blocking warnings and
are auto-disabled after repeated failures. See docs/plugin-api/README.md.
The bundled File Age sample contributes a host-rendered sample.fileAge column, with compact age
display and raw modification-time sorting.
The main window loads its authoritative workspace projection through the shared client and renders
the recursive pane layout with a draggable, minimum-width splitter. Pane clicks and Tab traversal
move visible focus through semantic workspace commands; divider changes are sent as debounced
UpdateLayout commands. The event-driven operation centre shows queued, running, paused, completed,
and failed jobs with progress, transfer rate, current entry, lifecycle controls, retained results,
and expandable failure details. Completed and failed jobs remain visible until dismissed.
Application-wide settings are stored as versioned JSON in the platform configuration directory
and are shared by the Axum GET/PUT /api/v1/settings endpoints and equivalent Tauri commands.
Writes are atomic, older schemas migrate forward, and corrupt files are backed up before defaults
are loaded with a warning. Frontend bootstrap applies the stored theme, font/row dimensions, and
date/size formats; live pane layouts, tabs, and per-tab views remain workspace-owned state.
Development builds include Mithril Inspector. Open the docked inspector with the M toggle at the
bottom of the page, or press Alt+Shift+M to select a rendered element. Use it to trace elements to
their source components, inspect the component tree, and view component attrs and local state. The
inspector and its editor endpoint are excluded from production builds.
Backend-to-frontend updates use one typed event contract for both browser SSE and Tauri channels.
The frontend event-stream abstraction exposes connection status and listener registration while
ignoring unknown future event types for forward compatibility.
Shared frontend data lives in a readonly, explicit Meiosis-style state tree. Typed actions enqueue
immutable Mergerino patches through one animation-frame batch, while targeted subscriptions let
directory and operation views redraw only when their selected slice changes.
Workspace state is a normalized, directory-free projection; directory sessions and transient
cursor, selection, dialog and drag state live in separate slices. All browser, Tauri and mock
workspace mutations use the same semantic FileManagerClient command surface, with stale revisions
reloaded and only safely idempotent commands retried.
The Rust event bus assigns monotonic event IDs, filters each subscription by session and workspace,
retains bounded replay history for reconnects, and reports explicit gaps when a client must
resynchronise.
Browser mode exposes that bus as one multiplexed GET /api/v1/events SSE connection. Named events
carry the shared typed envelope and numeric replay ID, while observable named keep-alive events let
the frontend detect stale connections. Reconnects resume through Last-Event-ID or the browser-safe
lastEventId query parameter; expired IDs produce a resynchronise event that refetches affected
pane snapshots. Desktop mode forwards the same serialized envelope bytes over one ordered Tauri
channel; channel setup is connecting, an installed channel remains open until explicit shutdown,
and Tauri does not expose SSE-style reconnecting. Directory deltas and operation progress share
the frontend's animation-frame batching policy with SSE. One-off notifications remain on the same
channel to preserve total event ordering and byte parity. Closing a window or disconnecting the
client cancels its Rust subscription task. Connection state is shown textually in the application
header. The Vite /api development proxy forwards the stream without compression or buffering.
Until task 0064 introduces production
sessions, REST and SSE share one explicit loopback-only development session; this is not a
production authentication mechanism.
Filesystem access is isolated behind the fm-vfs provider contract. Providers advertise explicit
capabilities, expose cancellable asynchronous operations and streaming reads/writes, and are
resolved from provider-neutral locations through a typed registry.
Cloud-synchronized folders discovered from macOS conventions and Windows OneDrive environment
variables appear in the favourites menu under CLOUD; they remain ordinary local provider
locations and require no vendor credentials. Mounted network volumes discovered from macOS volume
metadata or Windows mapped drives appear separately under NETWORK. They also use the existing
local provider, preserve optional protocol/server/share and read-only metadata, and require no
embedded SMB client or vendor credentials.
Application-managed remote connections (SSH today; FTP/FTPS and native cloud/SMB in later tasks)
are kept separate from both, in fm-connections and fm-credentials, and appear in the favourites
menu under SERVERS with a ●/○ status dot. A connection profile never stores a password,
passphrase or token directly - only an opaque reference into a CredentialStore, backed by the
macOS Keychain or Windows Credential Manager (an in-memory store is used on other hosts and in
tests only).
SSH/SFTP is implemented by fm-ssh (session/authentication/host-key verification, reusable later
by an SSH terminal) and fm-vfs-sftp (the FileSystemProvider, registered under the sftp
scheme). A saved connection's connect/test now perform a real SSH handshake through a
registered dialer; browsing an sftp://<connection-id>/path location pools and transparently
reconnects sessions per connection. SSH host keys are never auto-accepted, first use or on change:
an unverified or changed key surfaces as a distinct connection status
(hostKeyUnverified/hostKeyMismatch), and POST /api/v1/connections/{id}/hostKey/probe/accept
(and the equivalent Tauri commands) let a caller inspect the presented fingerprint and explicitly
persist it before a later connect can succeed - accepted fingerprints are stored in a JSON
known-hosts file beside the connection profiles. Clicking a connected server under SERVERS opens
its root in the active pane; local ↔ SFTP and same-connection SFTP ↔ SFTP copies/moves stream
through the same operation engine as local files, including server-native rename when source and
destination share a connection. FTP/FTPS and native cloud/SMB providers remain unimplemented; their
connect/test still validate configuration and credential only, without a live handshake.
Mutating filesystem work is represented by typed jobs in fm-operations. Its bounded scheduler
runs a planning phase before execution, publishes lifecycle and coalesced progress events through
the shared event bus, calculates a smoothed transfer rate, and cooperatively cancels at safe points
with partial-destination cleanup delegated to each operation implementation. Running work can be
paused without losing its planned totals or held scheduler locks, then resumed at the next item or
streaming chunk boundary. Cancellation is surfaced immediately as Cancelling, also interrupts
planning and conflict waits, and finishes as Cancelled with an explicit partial-progress summary.
Queued jobs expose their FIFO position in the operation centre. Terminal snapshots are retained in
an atomic JSON history beside settings (up to 100 entries and 30 days); an operation found in
flight after restart is retained as interrupted with its last known progress and is never resumed.
Shared preflight
checks reject same/nested destinations, case-only renames on insensitive filesystems, traversal
cycles, and file/directory replacement mismatches. Create-directory jobs now execute through the
provider, validate cross-platform-safe names, and create intermediate directories only when the
semantic request explicitly opts in. F7 opens the Materialized new-folder dialog; completion is
reflected through a directory delta that selects and scrolls the new entry into view. Remaining
mutation kinds land incrementally in tasks 0039–0044. Rename jobs use the provider's metadata
operation without copy/delete fallback, reject occupied destinations, and safely handle case-only
changes on insensitive filesystems. F2 opens an inline table editor with basename selection,
client-side validation, Esc cancellation, and Enter commit; stable entry IDs retain cursor and
selection when the directory delta arrives.
Single-file copy jobs stream through provider readers and writers into a private temporary file,
then publish atomically with collision-safe ask, overwrite, and rename-new behavior. F5 copies one
selected file to the other pane; byte/item totals, cancellation cleanup, timestamps, and supported
permissions are handled by the backend operation engine. The precise metadata contract is recorded
in docs/architecture/file-copy-metadata.md.
Ctrl/Cmd+C, Ctrl/Cmd+X and Ctrl/Cmd+V retain an in-application clipboard of provider-neutral
locations across panes and tabs. Paste validates the visible destination before it queues a copy or
move operation; cut rows remain dimmed until that move is accepted. System clipboard integration is
kept behind the platform-adapter capability boundary. The command palette and selection context menu
also copy selected filenames, full paths, or paths relative to the active directory as newline-
separated plain text using the host clipboard.
Selected rows can also be dragged between panes or onto loaded tabs. Directory rows resolve as the
destination themselves, invalid/read-only/subtree targets are rejected before drop, and accepted
drops queue the same conflict-safe copy/move operations as paste (move by default; Option on macOS
or Control elsewhere copies). macOS and Windows desktop builds also exchange file-reference drags
with Finder and Explorer; incoming native drops copy through the same conflict-safe operation
engine. Browser and unsupported desktop builds keep this behavior disabled through
nativeDragOut.
The shared application service now exposes semantic operation start/list/get/cancel/pause/resume
and conflict-resolution methods through matching Axum REST endpoints and Tauri commands. REST
starts accept Idempotency-Key so retries return the original job rather than queueing duplicates;
the generated HTTP client and the Tauri and mock adapters expose the same transport-neutral client
surface.
Local paths are represented as validated, percent-encoded file: locations rather than raw path
strings. Conversion preserves POSIX, Windows drive, UNC, long-path and Unicode forms; lexical
normalization is constrained to a configured root. See
docs/architecture/locations.md for the stable URI syntax.
The local provider lists directories in bounded, cancellable pages and fetches detailed metadata separately. Listings identify dotfiles, Windows hidden attributes, symbolic links and reparse points without following links; Finder alias detection remains a later macOS enhancement.
The application layer owns authoritative per-pane directory snapshots, including monotonic revisions and cancellation of superseded requests. Thin Axum and Tauri adapters expose the same list, refresh, navigation and metadata operations; listing options include server-side hidden-file filtering, folder grouping and sorting.
Each frontend pane now loads its active tab's real directory through that shared client surface. Directory navigation, parent traversal, backend-resolved per-tab history, retryable in-pane errors, and continuation-token paging are coordinated outside the view components. Superseded requests are aborted and responses are correlated by request ID before they may replace the visible snapshot.
.github/workflows/ci.yml runs on every push to main and every pull request:
- rust (matrix: ubuntu-latest, macos-latest, windows-latest):
cargo fmt --all --check,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspace. Cargo registry/target caching viaSwatinem/rust-cache. - frontend (ubuntu-latest): Biome format check,
tsc --noEmit, Vitest, production build. pnpm store caching viaactions/setup-node's built-in pnpm cache. - audit (ubuntu-latest, advisory-only — never blocks the workflow):
cargo auditandpnpm audit, reporting findings without failing the run.
Pull-request builds never perform code signing or notarization; that is reserved for protected release workflows (tracked separately).
Desktop product identity has one source: [package.metadata.desktop] in
apps/fm-desktop/src-tauri/Cargo.toml. The desktop crate inherits its version from
[workspace.package] in the root Cargo.toml; pnpm build:tauri resolves both through
cargo metadata and supplies them to Tauri. On macOS it produces a .app and .dmg; on Windows
it produces .msi and NSIS -setup.exe installers; on Linux it produces .deb and .AppImage
packages. Do not duplicate the version or product identity in tauri.conf.json. The base config
contains only a schema-required bootstrap copy of the identifier; the packaging contract test
requires it to match the Cargo-owned value.
To prepare a release:
- Update
[workspace.package].versioninCargo.tomland refreshCargo.lockwithcargo check -p fm-desktop. - Add the user-facing release notes to the GitHub release/tag description or the commits that GitHub's generated release notes will collect. Note platform limitations and manual checks.
- Run
pnpm lint,pnpm test, andpnpm build:taurion a supported desktop host. - Commit the version change, then push an annotated
v<version>tag (for examplev0.2.0). The workflow rejects tags that do not exactly match the Cargo version.
The tag-only .github/workflows/release-desktop.yml workflow uses the protected
desktop-release GitHub environment. No Apple or Windows signing certificates are required: the
macOS and Windows artefacts are deliberately published unsigned, and the macOS build is not
notarized. Configure only:
- the
HOMEBREW_TAP_REPOSITORYenvironment variable as theowner/homebrew-taprepository that will hold the cask, andHOMEBREW_TAP_TOKENas a fine-grained token allowed to write to it; CHOCOLATEY_API_KEYas the API key for theprocyonpackage on the Chocolatey Community Repository.
Pull-request CI does not reference those secrets. The workflow publishes generated release notes,
unsigned macOS and Windows installers, and Linux packages. It then calculates checksums from those
exact release assets, updates Casks/procyon.rb in the configured Homebrew tap, and generates and
pushes the Chocolatey package. The unsigned macOS release is a universal binary for Apple Silicon
and Intel Macs. Installing it through Homebrew does not bypass Gatekeeper: users must explicitly
approve the app in macOS Privacy & Security or remove the quarantine attribute only if they trust
the downloaded release. Windows users should expect a Microsoft Defender SmartScreen warning and
must choose to run the installer only after verifying that it came from the official release.
After the first packages have been published, users can install Procyon with:
brew tap erikvullings/tap
brew install --cask procyonor, from an elevated Windows terminal:
choco install procyonNew Chocolatey package versions may remain unavailable until Community Repository moderation has completed.
CI performs an unsigned packaging smoke test on disposable macOS and Windows runners: it copies or installs an artefact, launches the packaged executable, verifies that it remains running, and then cleans up. Before promoting a release, also perform this manual smoke on each supported platform:
- macOS: download the
.dmgon a different Mac, mount it, drag Procyon to Applications, confirm the expected Gatekeeper warning for the unsigned macOS app, explicitly approve it in Privacy & Security, browse a directory, and quit normally. - Windows: download both installers on a clean Windows VM, confirm the expected SmartScreen warning for the unsigned publisher, install one format only after verifying its source, launch Procyon, browse a directory, quit, uninstall, and repeat with the other installer format.
- Linux: install the
.debon Ubuntu 22.04 or run the.AppImageafter marking it executable; launch Procyon, browse a directory, quit, and remove the installed package or downloaded image.
Auto-update is not included in the first-release packaging design; releases are downloaded and installed manually.