A Keyboard section in the Settings window edits the shared keymap: rows by group with a search field, a chip per key (x removes), + to record, reset per row and Reset all. Recording takes the keydown before every other key in the window (Cmd+W records instead of closing), refuses reserved combos without preventDefault (Cmd+Q still quits), requires a chord or F-key for editor commands, and offers Replace when another command in an overlapping scope owns the key. Rows flag conflicts and plain character keys that would stop starting a type-to-filter query. Fixed keys are listed read-only, in the platform's spelling. Remaps are ordinary settings (only rows that differ from the defaults), so every window, the menu hints and the tooltips follow live — and the macOS menu bar: its accelerators are built from the keymap (only the combos Electrobun's parser expresses faithfully) and rebuilt on a remap. A main-window menu action fired from the editor or Settings window brings the main window forward first. Also: the "Enter / double-click on a file" setting under File list (do nothing / open local files in their default app), arrow keys in the Settings sidebar with focus on open and a hand-walked Tab, translations for all seven languages (the spec now checks every language labels every command), and a README shortcut table generated from the keymap. Co-Authored-By: Claude <noreply@anthropic.com>
383 lines
17 KiB
Markdown
383 lines
17 KiB
Markdown
# miniSCP
|
||
|
||
A cross-platform, dual-panel SFTP/local file manager — a small WinSCP-style
|
||
commander built on **Bun**, **Electrobun**, and **Angular**.
|
||
|
||
Each of the two panels independently shows either the local filesystem or a
|
||
remote server over SSH/SFTP, so you can move files **local↔remote**,
|
||
**remote↔remote**, or **local↔local** through one symmetric engine.
|
||
|
||
> **Name & icon are placeholders.** See [Rebranding](#rebranding) — it's a
|
||
> one-file change.
|
||
|
||
## Developer docs
|
||
|
||
Dense reference material lives in [docs/](docs/): [architecture](docs/architecture.md)
|
||
(processes, windows, RPC, every module/service), [build & release](docs/build-and-release.md)
|
||
(Build-n versioning, per-target release pipeline, CI), [platform notes](docs/platform-notes.md)
|
||
(macOS/Windows/Linux matrix, runtime deps + smoke tests), and
|
||
[gotchas](docs/gotchas.md) (known landmines).
|
||
|
||
## Features
|
||
|
||
- Dual-panel commander UI; either panel can be Local or any saved connection.
|
||
- **Session tabs**: each tab is an independent left/right workspace; remote
|
||
sessions stay connected and transfers keep running in background tabs.
|
||
- Per-panel action toolbar with a direction-explicit send button, plus a
|
||
VSCode-style status bar showing live transfer progress (click to expand the
|
||
transfer queue with per-file speed and ETA).
|
||
- **Overwrite confirmation** on name collisions, with a source-vs-destination
|
||
comparison (size, modified date, which is newer) and batch replace-all /
|
||
skip-all.
|
||
- **Settings** (gear, top-right): light / dark / system theme and speed units
|
||
(MB/s or Mbps), persisted across restarts.
|
||
- On macOS the tab bar is fused with the window titlebar; other platforms keep
|
||
a standard titlebar.
|
||
- **Menu-bar (tray) icon**: template icon that adapts to light/dark; click it
|
||
to bring the app to the front.
|
||
- **Edit files** (F4 / ⌘E / toolbar / File menu): a built-in editor works on local
|
||
and remote files directly; or pick an external app (Settings → Editor →
|
||
Custom → Choose…). External edits of remote files download to a temp folder,
|
||
are watched, and every save auto-uploads (visible in the transfer list) until
|
||
the session disconnects. Leftover temp files from a crash are offered for
|
||
cleanup at the next start.
|
||
- Browse, sort (name/size/modified, folders first), full keyboard control
|
||
with remappable shortcuts (see below). Enter / double-click on a local file
|
||
can open it in its default app (Settings → File list; off by default).
|
||
- Create folder / file, rename, delete (recursive), change permissions (chmod).
|
||
- Upload, download, and remote→remote send — files **and** whole folders —
|
||
with a transfer queue, per-job progress, and cancel.
|
||
- Connection manager: saved connections organised into folders, password or
|
||
private-key auth (with passphrase).
|
||
|
||
## Stack
|
||
|
||
| Layer | Choice |
|
||
| ---------- | ------------------------------------------------------------- |
|
||
| Runtime | Bun (main process, all SSH/SFTP + file IO) |
|
||
| Desktop | Electrobun 2.0 via Hutch (native webview, typed RPC bridge) |
|
||
| Frontend | Angular 22 (standalone, signals, zoneless) in `ui/` |
|
||
| Styling | Tailwind CSS v4, Oxide-derived tokens (no component library) |
|
||
| Icons | @ng-icons/tdesign-icons |
|
||
| SSH/SFTP | `ssh2` (pure-JS under Bun) |
|
||
|
||
The repo is a Bun workspace: the Electrobun app at the root, the Angular app in
|
||
`ui/`. Shared types and the RPC contract live in `src/shared/` and are imported
|
||
by both sides.
|
||
|
||
```
|
||
src/shared/ types.ts, rpc.ts (wire contract), branding.ts
|
||
src/bun/ Electrobun main process
|
||
fs/ FileSystemProvider + LocalProvider + SftpProvider + registry
|
||
transfers/ engine.ts (queue/stream copy) + walk.ts (recursive helpers)
|
||
store/ connections.json persistence
|
||
ui/src/app/ Angular components + signal services
|
||
```
|
||
|
||
## Prerequisites
|
||
|
||
- [Bun](https://bun.sh) ≥ 1.3 (`bun --version`).
|
||
- macOS: **Xcode Command Line Tools** (for the app build).
|
||
- Linux: **AppIndicator** — the app links it for the tray and will not start
|
||
without it (`ERR_DLOPEN_FAILED`). `libayatana-appindicator` (Arch),
|
||
`libayatana-appindicator3-1` (Debian/Ubuntu),
|
||
`libayatana-appindicator-gtk3` (Fedora), plus system `webkit2gtk`.
|
||
Details and diagnostics in [platform notes](docs/platform-notes.md).
|
||
- No Node install required — a preload shim (`scripts/node-version-shim.ts`)
|
||
satisfies the Angular CLI's Node version check while running under Bun.
|
||
- Network on the first `bun install`: Electrobun 2.x's npm package is a
|
||
bootstrap that downloads its build tool (Hutch), the Electrobun core and the
|
||
pinned Bun runtime into `~/.hutch` (~200 MB), and projects the SDK into the
|
||
gitignored `.hutch/devkit/`.
|
||
|
||
## Setup
|
||
|
||
```bash
|
||
bun install # installs root + ui workspace deps
|
||
```
|
||
|
||
## Develop
|
||
|
||
```bash
|
||
bun run dev
|
||
```
|
||
|
||
This starts the Angular dev server (hot reload for UI code) and launches the
|
||
Electrobun app pointed at it. Edits under `ui/` reload instantly; edits to the
|
||
Bun main process (`src/bun/**`, RPC schema) trigger an app relaunch.
|
||
|
||
## Releases
|
||
|
||
Versioning is Sublime-style: **Build n**, where n is the commit count
|
||
(`git rev-list --count HEAD`), regenerated into the gitignored
|
||
`src/shared/version.gen.ts` by every build script (and `postinstall`).
|
||
|
||
One artifact per invocation, on the matching host — electrobun cannot
|
||
cross-compile (Hutch builds for its own OS and arch):
|
||
|
||
```sh
|
||
bun run release -- --target mac-arm64 # on an Apple Silicon Mac
|
||
bun run release -- --target win-x64 # on a Windows 11 x64 machine
|
||
bun run release -- --target linux-x64 # on a Linux x64 machine
|
||
```
|
||
|
||
Artifacts land in `release/<target>/miniSCP-Build<n>-<target>.*` — a `.dmg`
|
||
on the Mac; on Windows a `-Setup.zip` installer **and** a `-portable.zip`
|
||
(unzip anywhere and run); on Linux Electrobun's `-Setup.tar.gz` installer.
|
||
There is no Intel Mac build: Electrobun 2.x publishes no macOS x64 core.
|
||
CI: `.gitea/workflows/release.yml` runs the
|
||
same script on a macos + windows runner matrix once act_runners are
|
||
registered; trigger by pushing a `build-<n>` tag.
|
||
|
||
## Build a packaged app
|
||
|
||
```bash
|
||
bun run build # ng build (ui/dist) + electrobun build -> build/*/miniSCP*.app
|
||
```
|
||
|
||
The Angular bundle is copied into the app under `views://app`. The macOS icon is
|
||
compiled from `assets/icon.iconset` into `AppIcon.icns` automatically.
|
||
|
||
For a distributable (optimized, with auto-update channel) build, use
|
||
`bunx electrobun build --env=stable` (see the
|
||
[Electrobun docs](https://electrobun.dev/electrobun/) for signing,
|
||
notarization, and release hosting).
|
||
|
||
## Rebranding
|
||
|
||
Everything user-facing reads from **[`src/shared/branding.ts`](src/shared/branding.ts)**:
|
||
|
||
```ts
|
||
export const BRANDING = {
|
||
appName: "miniSCP", // window title, bundle name, app-data folder
|
||
identifier: "es.teknes.miniscp",
|
||
legacyNames: [], // previous names; their data dirs migrate on boot
|
||
version: "0.1.0",
|
||
icon: "assets/icon.iconset",
|
||
} as const;
|
||
```
|
||
|
||
To change the icon: edit `assets/logo.svg` (app icon) and
|
||
`assets/tray/tray-glyph.svg` (menu-bar glyph), then run
|
||
`bun scripts/gen-icons.ts` — it regenerates the macOS iconset, `icon.png`, the
|
||
tray template image, and the tray progress frames from the SVGs
|
||
(`@resvg/resvg-js`, dev-only dependency).
|
||
|
||
> Renaming `appName` changes the app-data directory. Add the old name to
|
||
> `legacyNames` and the app moves the previous folder (connections, settings,
|
||
> edit temp files) into place on the next boot.
|
||
|
||
## Where connections are stored
|
||
|
||
Saved connections live in a plain JSON file in the OS app-data directory
|
||
(macOS: `~/Library/Application Support/miniSCP/connections.json`), written with
|
||
`0600` permissions.
|
||
|
||
> ⚠️ **Passwords are stored in plaintext** in this file (a deliberate v1
|
||
> simplicity choice). Anyone who can read the file can read the passwords. If
|
||
> that isn't acceptable for your use, use key-based auth or don't save the
|
||
> password (connect ad-hoc). OS-keychain storage is a natural future addition.
|
||
|
||
## Keyboard shortcuts
|
||
|
||
The whole app works from the keyboard, and **every command can be remapped** in
|
||
**Settings → Keyboard**: press **+** on a row and type the keys, see conflicts,
|
||
reset one row or all. Remaps apply live in every window, the right-click menus'
|
||
hints and the toolbar tooltips follow, and so does the macOS menu bar. The
|
||
defaults:
|
||
|
||
| Command | macOS | Windows / Linux |
|
||
| --- | --- | --- |
|
||
| **File list** | | |
|
||
| Move up | `↑` | `↑` |
|
||
| Move down | `↓` | `↓` |
|
||
| Page up | `PgUp` | `PgUp` |
|
||
| Page down | `PgDn` | `PgDn` |
|
||
| First item | `Home` | `Home` |
|
||
| Last item | `End` | `End` |
|
||
| Select / deselect | `Space` | `Space` `Ins` |
|
||
| Select all | `⌘A` | `Ctrl+A` |
|
||
| Open | `↩` `⌘↓` | `Enter` |
|
||
| Parent folder | `⌫` `⌘↑` | `Backspace` `Alt+↑` |
|
||
| Switch panel | `⇥` `⇧⇥` | `Tab` `Shift+Tab` |
|
||
| Show the item's menu | `⇧F10` `⌃↩` | `Shift+F10` `Menu` |
|
||
| **Files** | | |
|
||
| Send to the other panel | `F5` | `F5` |
|
||
| Edit | `⌘E` `F4` | `F4` `Ctrl+E` |
|
||
| Rename | `F2` | `F2` |
|
||
| New folder | `⇧⌘N` `F7` | `F7` `Ctrl+Shift+N` |
|
||
| New file | `⌘N` `⇧F4` | `Shift+F4` `Ctrl+N` |
|
||
| Permissions | `⌘I` | `Ctrl+I` |
|
||
| Delete | `F8` `⌦` | `F8` `Del` |
|
||
| Move to Trash | `⌘⌫` | `Ctrl+Del` |
|
||
| Copy path | `⌥⌘C` | `Ctrl+Shift+C` |
|
||
| Reveal in file manager | `⌥⌘R` | `Alt+Shift+R` |
|
||
| Open in default app | `⌘O` | `Ctrl+O` |
|
||
| Refresh | `⌘R` | `Ctrl+R` |
|
||
| Go to path | `⌘L` | `Ctrl+L` |
|
||
| Choose what the panel shows | `⌘K` | `Ctrl+K` |
|
||
| **Sorting** | | |
|
||
| Sort by name | `⌥1` | `Alt+1` |
|
||
| Sort by size | `⌥2` | `Alt+2` |
|
||
| Sort by modified | `⌥3` | `Alt+3` |
|
||
| Sort by kind | `⌥4` | `Alt+4` |
|
||
| Sort by rights | `⌥5` | `Alt+5` |
|
||
| Sort by owner | `⌥6` | `Alt+6` |
|
||
| Sort by group | `⌥7` | `Alt+7` |
|
||
| **Tabs** | | |
|
||
| New tab | `⌘T` | `Ctrl+T` |
|
||
| Close tab | `⌘W` | `Ctrl+W` |
|
||
| Close other tabs | `⌥⌘W` | `Ctrl+Shift+W` |
|
||
| Close tabs to the right | — | — |
|
||
| Next tab | `⌃⇥` `⇧⌘]` | `Ctrl+Tab` `Ctrl+PgDn` |
|
||
| Previous tab | `⌃⇧⇥` `⇧⌘[` | `Ctrl+Shift+Tab` `Ctrl+PgUp` |
|
||
| Go to tab 1 | `⌘1` | `Ctrl+1` |
|
||
| Go to tab 2 | `⌘2` | `Ctrl+2` |
|
||
| Go to tab 3 | `⌘3` | `Ctrl+3` |
|
||
| Go to tab 4 | `⌘4` | `Ctrl+4` |
|
||
| Go to tab 5 | `⌘5` | `Ctrl+5` |
|
||
| Go to tab 6 | `⌘6` | `Ctrl+6` |
|
||
| Go to tab 7 | `⌘7` | `Ctrl+7` |
|
||
| Go to tab 8 | `⌘8` | `Ctrl+8` |
|
||
| Go to last tab | `⌘9` | `Ctrl+9` |
|
||
| Move tab left | `⌃⇧PgUp` | `Ctrl+Shift+PgUp` |
|
||
| Move tab right | `⌃⇧PgDn` | `Ctrl+Shift+PgDn` |
|
||
| Disconnect tab | `⌘D` | `Ctrl+D` |
|
||
| **Window** | | |
|
||
| Settings | `⌘,` | `Ctrl+,` |
|
||
| Connection manager | `⇧⌘K` | `Ctrl+Shift+K` |
|
||
| Show / hide transfers | `⌘J` | `Ctrl+J` |
|
||
| Install update | — | — |
|
||
| Bigger interface | `⌘=` `⇧⌘=` `⌘Num +` | `Ctrl+=` `Ctrl+Shift+=` `Ctrl+Num +` |
|
||
| Smaller interface | `⌘-` | `Ctrl+-` |
|
||
| Interface at 100% | `⌘0` | `Ctrl+0` |
|
||
| Close window | `⌘W` | `Ctrl+W` |
|
||
| **Editor** | | |
|
||
| Save | `⌘S` | `Ctrl+S` |
|
||
| Find | `⌘F` | `Ctrl+F` |
|
||
| Find next | `⌘G` | `F3` |
|
||
| Find previous | `⇧⌘G` | `Shift+F3` |
|
||
| Wrap lines | `⌥Z` | `Alt+Z` |
|
||
| **Connection manager** | | |
|
||
| New connection | `⌘N` | `Ctrl+N` |
|
||
| New stack | `⇧⌘N` | `Ctrl+Shift+N` |
|
||
| Save connection | `⌘S` | `Ctrl+S` |
|
||
| Connect / open stack | `⌘↩` | `Ctrl+Enter` |
|
||
| Delete connection or stack | `⌘⌫` | `Del` |
|
||
| Rename stack | `F2` | `F2` |
|
||
| Import from WinSCP | `⌘O` | `Ctrl+O` |
|
||
|
||
**Fixed keys** (not remappable): Esc closes menus, dialogs and the find bar —
|
||
in the file list it clears the filter, then cancels a connect, then clears the
|
||
selection. Enter / Esc confirm / cancel dialogs and Tab walks their controls
|
||
(on macOS too, without the system's Keyboard navigation setting). Typing in the
|
||
file list filters it (Backspace trims); Shift + any movement key selects the
|
||
rows the cursor passes. ↑ ↓ Home End Enter work in every menu.
|
||
|
||
(On Macs, function keys may need `fn` — every F-key command also has a ⌘ key
|
||
or a toolbar button.)
|
||
|
||
## Same-file protection
|
||
|
||
Copying a file or folder onto itself would truncate it to zero bytes (the
|
||
destination is opened for writing before the source is read). miniSCP blocks
|
||
this at several layers:
|
||
|
||
- **Same session / same host**: every connection carries a filesystem identity
|
||
(`local`, or `resolvedIP:port` — the hostname is DNS-resolved, so
|
||
`myserver.lan` and its IP compare equal even across two panels/tabs holding
|
||
separate SSH sessions to the same server). Equal identity + equal or nested
|
||
paths ⇒ the transfer is refused before anything is opened.
|
||
- **Hidden aliases** (two *different* addresses that reach the same machine —
|
||
NAT, split DNS): when identities differ but source and destination paths
|
||
collide anyway, a probe file is written through one connection and looked up
|
||
through the other; if it's visible on both, they're the same machine and the
|
||
copy is refused. (SFTP offers no device/inode identity — the mechanism
|
||
`cp`/`rsync` use locally — so probing is the only deterministic test.)
|
||
- The overwrite dialog never offers to "Replace" a file with itself — such
|
||
items are auto-skipped with a notice.
|
||
|
||
**Known residual limitation**: the same filesystem mounted at *different
|
||
paths* on two connections (bind mounts, NFS exported twice, shared container
|
||
volumes) cannot be detected — the paths differ, so no guard or probe triggers.
|
||
Be careful when two panels reach the same data through different mount points.
|
||
|
||
## Portability notes
|
||
|
||
Platform seams are isolated behind `process.platform` / `PLATFORM` branches
|
||
marked with `PORTABILITY:` comments: editor launch (`open` / `cmd /c start` /
|
||
`xdg-open`), app pickers, owner/group lookups, tray images, window chrome,
|
||
shell-integration wording, drive listing, and the connections-file ACL. The
|
||
application menu ([src/bun/menu.ts](src/bun/menu.ts)) is macOS-only for now;
|
||
the in-app UI covers every menu action.
|
||
|
||
Right-click menus are **HTML, on every platform**
|
||
([ui/src/app/core/context-menu.service.ts](ui/src/app/core/context-menu.service.ts)) rather than native —
|
||
electrobun's native context menu is a documented no-op on Linux, and one menu
|
||
implementation beats two that drift. Only reveal / open-with-default / copy-path
|
||
cross to the backend, through the `shellItemAction` RPC.
|
||
|
||
## Windows (beta)
|
||
|
||
Windows 11+ x64 is supported as a **beta** target (win-arm64 deferred). All
|
||
electrobun APIs the app uses are implemented on Windows; known issues are
|
||
upstream:
|
||
|
||
- **HiDPI blur** ([electrobun#324](https://github.com/blackboardsh/electrobun/issues/324)):
|
||
WebView2 rendered at 1x on scaled displays on Electrobun 1.18; 2.x ships
|
||
per-monitor DPI setup — to re-check on the first 2.x Windows build.
|
||
- **Notifications** use legacy balloon style, not Windows toast.
|
||
- **Unsigned binaries**: both the Setup.exe installer and the portable exe
|
||
trigger SmartScreen/Mark-of-the-Web warnings on first run. The installer
|
||
unpacks to `%LOCALAPPDATA%` and, since Electrobun 2.x, ships an uninstaller.
|
||
- `connections.json` is protected with an `icacls` current-user ACL instead of
|
||
POSIX 0600 (which is meaningless on Windows).
|
||
|
||
First-Windows-boot smoke test: drives dropdown lists C:\ etc.; navigate to a
|
||
drive root ('..' disappears); local↔remote transfers; external editor launch
|
||
(`cmd /c start`) and the .exe picker; Show in Explorer / Recycle Bin; tray icon
|
||
renders colored; Permissions absent on local panels; Ctrl+labels in tooltips;
|
||
portable zip runs from any folder.
|
||
|
||
## Linux (beta)
|
||
|
||
Linux x64 is supported as a **beta** target and ships Electrobun's own
|
||
installer (`-Setup.tar.gz`).
|
||
One hard requirement: the AppIndicator library listed under
|
||
[Prerequisites](#prerequisites) — electrobun links it for the tray, so without
|
||
it the app aborts at startup with `ERR_DLOPEN_FAILED` rather than merely losing
|
||
its tray icon.
|
||
|
||
- **No native menus at all** upstream (both the app menubar and context menus
|
||
are no-ops). The menubar was already macOS-only and the context menus are now
|
||
HTML, so nothing is missing in practice.
|
||
- **Tray visibility** depends on the desktop: KDE, XFCE, MATE and Cinnamon show
|
||
it; GNOME 3.26+ needs the AppIndicator extension. The app works either way.
|
||
- **Unsigned**, and no AppImage or `.deb` — unpack the `-Setup.tar.gz` and run
|
||
its `installer`.
|
||
- **NVIDIA GPUs** need `WEBKIT_DISABLE_DMABUF_RENDERER=1` or the window renders
|
||
black. The dev scripts set it automatically when an NVIDIA driver is
|
||
detected; an installed build needs it set by hand (e.g. in its desktop
|
||
entry) — see [gotchas](docs/gotchas.md#linux).
|
||
|
||
Full dependency table, diagnostics and the first-boot smoke test:
|
||
[platform notes](docs/platform-notes.md).
|
||
|
||
## Known issues
|
||
|
||
- **Rare native crash under load.** A long-running session (multiple remote
|
||
connections, extended uptime) could hit a Bun panic — a segfault inside Bun
|
||
1.3.13's FFI thread-safe callbacks executing Electrobun's native→worker
|
||
bridge (`FFI_Callback_threadsafe_call` → `web_worker.spin` in the decoded
|
||
crash report). This is an upstream Bun × Electrobun bug, not app code; a
|
||
restart recovers cleanly and in-flight work isn't corrupted. Electrobun 2.0.2
|
||
pins Bun 1.4.0, which may have fixed it — not yet confirmed under load. Bun's
|
||
own crash reporter link is printed to the console on panic.
|
||
|
||
## Not in v1
|
||
|
||
Drag-and-drop, file preview/edit, transfer resume, symlink following
|
||
(symlinks are listed but skipped by transfers), OS keychain, tab persistence
|
||
across restarts, Linux AppImage/.deb packages (the release is Electrobun's
|
||
Setup installer).
|