Compare commits

..
4 Commits
Author SHA1 Message Date
JokinandClaude Fable 5 dc5cb1f707 Developer documentation: architecture, build/release, platforms, gotchas
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:25:38 +02:00
JokinandClaude Fable 5 2585dd128b Multi-arch release script + Gitea workflow; retire legacy BunSCP name
bun run release -- --target mac-arm64|mac-x64|win-x64: gen version/icons,
build UI, electrobun build --env=stable, collect artifacts as
release/<target>/miniSCP-Build<n>-<target>.*. mac-x64 builds on the
arm64 Mac by running electrobun's x64 CLI under Rosetta 2 (verified:
x86_64 launcher in the produced app). Windows also zips a portable
build next to the Setup installer. Tag-triggered Gitea Actions matrix
included, inert until runners exist. legacyNames emptied — the BunSCP
migration has served its purpose.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:25:10 +02:00
JokinandClaude Fable 5 c7271fb7cf Windows compatibility pass (beta target)
External editor via cmd/start + exe picker; Permissions hidden for
Windows-local (chmod only flips read-only there); Explorer/Recycle Bin
wording; Ctrl vs Cmd shortcut labels; separator-aware path joins +
drive-root detection; drives list in the panel source picker; icon.ico
+ colored tray icon; icacls ACL on connections.json; README Windows
(beta) section with upstream known issues and a smoke-test checklist.
Runtime paths pending first boot on a real Windows machine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:02:42 +02:00
JokinandClaude Fable 5 d80d5c23dc Sublime-style Build-n versioning + About box
Build n = commit count, baked at build time into a gitignored
version.gen.ts (postinstall + all build scripts regenerate it). About
lives in the macOS app menu and as a footer row in the options popover
(the cross-platform path), showing build, commit, date and engines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:55:39 +02:00
34 changed files with 1006 additions and 66 deletions
+46
View File
@@ -0,0 +1,46 @@
# Release matrix — INERT until act_runner instances with these labels are
# registered on git.teknes.es. Each OS builds its own artifacts (electrobun
# cannot cross-compile: packaging needs codesign/hdiutil on macOS and
# rcedit/PowerShell on Windows).
#
# Runner prerequisites:
# macos — arm64 Mac: bun, Xcode CLT, Rosetta 2 (softwareupdate
# --install-rosetta), network to github.com (electrobun downloads
# its per-arch core binaries on first build)
# windows — Windows 11 x64: bun, network to github.com; WebView2 runtime is
# preinstalled on Win 11
#
# Trigger: push a tag like build-62 (git tag build-$(git rev-list --count HEAD)).
name: release
on:
push:
tags:
- "build-*"
jobs:
mac:
runs-on: macos
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history — the build number is the commit count
- run: bun install --frozen-lockfile
- run: bun scripts/release.ts --target mac-arm64
- run: bun scripts/release.ts --target mac-x64
- uses: actions/upload-artifact@v3
with:
name: mac
path: release/**
windows:
runs-on: windows
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: bun install --frozen-lockfile
- run: bun scripts/release.ts --target win-x64
- uses: actions/upload-artifact@v3
with:
name: windows
path: release/**
+2
View File
@@ -6,3 +6,5 @@ artifacts/
.DS_Store
ui/dist/
ui/.angular/
src/shared/version.gen.ts
release/
+57 -9
View File
@@ -10,6 +10,13 @@ remote server over SSH/SFTP, so you can move files **local↔remote**,
> **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 matrix + smoke tests), and [gotchas](docs/gotchas.md) (known landmines).
## Features
- Dual-panel commander UI; either panel can be Local or any saved connection.
@@ -87,6 +94,29 @@ 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 between OSes:
```sh
bun run release -- --target mac-arm64 # on the Mac (native)
bun run release -- --target mac-x64 # on the same arm64 Mac, via Rosetta 2
bun run release -- --target win-x64 # on a Windows 11 x64 machine
```
Artifacts land in `release/<target>/miniSCP-Build<n>-<target>.*` — a `.dmg`
per mac arch; on Windows a `-Setup.zip` installer **and** a `-portable.zip`
(unzip anywhere and run). The mac-x64 build fetches electrobun's x64 CLI once
into `~/.cache/miniscp/` and runs it under Rosetta so the whole pipeline
believes it is on an Intel Mac. 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
@@ -109,7 +139,7 @@ Everything user-facing reads from **[`src/shared/branding.ts`](src/shared/brandi
export const BRANDING = {
appName: "miniSCP", // window title, bundle name, app-data folder
identifier: "es.teknes.miniscp",
legacyNames: ["BunSCP"], // previous names; their data dirs migrate on boot
legacyNames: [], // previous names; their data dirs migrate on boot
version: "0.1.0",
icon: "assets/icon.iconset",
} as const;
@@ -173,15 +203,33 @@ Be careful when two panels reach the same data through different mount points.
## Portability notes
The codebase is platform-neutral except for two seams, both macOS-only today:
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.
- **External editor launch** (`launchEditor` in [src/bun/edits.ts](src/bun/edits.ts)):
uses macOS `open [-a app] file`. A Windows port should use
`cmd /c start "" [app] file`; Linux `xdg-open file` (or invoke the configured
editor binary directly). The `.app` picker in the `pickEditorApp` handler
needs the equivalent executable picker per platform.
- **Application menu** ([src/bun/menu.ts](src/bun/menu.ts)): electrobun menus
are macOS-only; the in-app UI covers every menu action.
## 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 renders at 1x on scaled displays until upstream adds DPI awareness.
- **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. No Start Menu
entry or uninstaller (installer unpacks to `%LOCALAPPDATA%`).
- `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.
## Known issues
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

+12 -3
View File
@@ -13,6 +13,7 @@
"@resvg/resvg-js": "^2.6.2",
"@types/bun": "latest",
"@types/ssh2": "^1.15.5",
"png-to-ico": "^3.0.2",
"typescript": "~5.9.3",
},
},
@@ -930,9 +931,9 @@
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"png-to-ico": ["png-to-ico@2.1.8", "", { "dependencies": { "@types/node": "^17.0.36", "minimist": "^1.2.6", "pngjs": "^6.0.0" }, "bin": { "png-to-ico": "bin/cli.js" } }, "sha512-Nf+IIn/cZ/DIZVdGveJp86NG5uNib1ZXMiDd/8x32HCTeKSvgpyg6D/6tUBn1QO/zybzoMK0/mc3QRgAyXdv9w=="],
"png-to-ico": ["png-to-ico@3.0.2", "", { "dependencies": { "@types/node": "^25.5.0", "minimist": "^1.2.8", "pngjs": "^7.0.0" }, "bin": { "png-to-ico": "bin/cli.js" } }, "sha512-36vvp3/YF7LPYkUEOj08/WgB1wI63qW391YfOhOWckgOtGkarr9+EhPBimcCmRP7fJaG4EaXQhTTaQ8qqdj8aA=="],
"pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
"postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="],
@@ -1144,6 +1145,8 @@
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"electrobun/png-to-ico": ["png-to-ico@2.1.8", "", { "dependencies": { "@types/node": "^17.0.36", "minimist": "^1.2.6", "pngjs": "^6.0.0" }, "bin": { "png-to-ico": "bin/cli.js" } }, "sha512-Nf+IIn/cZ/DIZVdGveJp86NG5uNib1ZXMiDd/8x32HCTeKSvgpyg6D/6tUBn1QO/zybzoMK0/mc3QRgAyXdv9w=="],
"hosted-git-info/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
@@ -1166,7 +1169,7 @@
"path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
"png-to-ico/@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="],
"png-to-ico/@types/node": ["@types/node@25.9.5", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg=="],
"proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
@@ -1190,12 +1193,18 @@
"@npmcli/promise-spawn/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
"electrobun/png-to-ico/@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="],
"electrobun/png-to-ico/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
"png-to-ico/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"sass/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
+212
View File
@@ -0,0 +1,212 @@
# miniSCP architecture
Dense reference for development and debugging. Companion files:
[build-and-release.md](build-and-release.md), [platform-notes.md](platform-notes.md),
[gotchas.md](gotchas.md).
## Process model
- **Bun main process** (`src/bun/`): electrobun runs it inside a WebWorker of
its native launcher. Owns every privileged capability: filesystem, SSH,
window management, native menus/dialogs/tray, the connection/settings stores.
- **Webviews** (`ui/` Angular): one per window, system WebKit on macOS /
WebView2 on Windows. Zero privileged access — everything goes through RPC.
- **RPC transport**: one WebSocket server in the bun process, multiplexed by
`webviewId` (each webview auto-connects using natively injected globals).
Requests are typed by the `AppRPC` schema in `src/shared/rpc.ts`.
### The one-schema rule
`AppRPC` is a single superset schema shared by all window kinds. This works
because electrobun allows **partial handler sets** (a side only implements
what it serves) and **silently drops messages nobody listens to**. Every
response crosses the bridge as `Result<T>` (`{ok:true,…}|{ok:false,error}`) —
exceptions never cross; `wrap()` in `src/bun/rpc-handlers.ts` enforces it.
### Per-window RPC instances (critical)
Each `BrowserWindow` MUST receive its own `defineRPC(...)` instance: binding a
BrowserView calls `rpc.setTransport(...)`, so a shared instance would have its
transport stolen by the most recent window. `createAppRPC` (main),
`createEditorRPC` (per editor window, hooks close over that window's record),
`createManagerRPC` (the single manager window). `rpc.send.*` on an instance
targets exactly its own webview.
### The `events` late-binding pattern
`createAppRPC` needs to trigger things that need `rpc.send` (which doesn't
exist until after construction). `AppDeps.events` is a mutable object of
no-op functions, reassigned in `src/bun/index.ts` after everything exists:
`sessionClosed`, `connectionsChanged`, `openEditor`, `settingsChanged`,
`openConnectionManager`, `showAbout`.
## Window kinds
`src/bun/windows.ts` — `buildPreload(mode, ctx?)` injects `__APP_PLATFORM`,
`__APP_MODE` (`main|editor|manager`), `__APP_CTX` before any page script.
`ui/src/main.ts` picks the root component from `APP_MODE`; all three roots use
selector `app-root` (index.html hosts only that tag). `openSecondaryWindow` is
the ONLY place that branches platform for secondary chrome: macOS
`hiddenInset` + `trafficLightOffset {x:12,y:13}` (the UI renders an
`h-10 pl-24` drag-region header via `IS_MAC_CHROME`); other platforms keep the
native titlebar and a compact `h-9` non-drag header.
`WindowRegistry<Rec>` (same file): keyed window map with dedupe/activate,
cascade placement off an anchor frame, focus tracking. electrobun's emitter
has **no `off()`** — `track()` closures capture only `(this, key)` so closed
records can be garbage collected. A future "window follows window" grouping
belongs here (`anchor.on('move')` + `setPosition`).
### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/editor-window/`)
One window per file, key `sessionId+path` (sessionId, not fsKey: saves route
through a concrete session, and `local` is one shared session anyway).
Re-opening focuses the existing window. The webview fetches its own content
(`readFile`) — `openEditor` just creates the window with ctx in the preload.
**Close safety** (electrobun window `close` is NOT cancellable — verified, no
`allow` response like `before-quit` has):
1. ⌘W (routed via the app menu → `editorCloseRequest`) / Close button →
styled in-window Save/Don't Save/Cancel; the confirm path calls
`editorClose` which sets `suppressPrompt` before `win.close()`.
2. Traffic-light close → the window is already gone, but the editor mirrored
its draft to bun (`editorState`, 400 ms debounce + flush on blur/dirty-flip)
→ native Save/Discard box from the held draft. A failed save (dead session)
parks the draft via `EditManager.rescueDraft` into the edits-leftovers dir,
offered back at next boot. Known limit: the last ≤400 ms of keystrokes can
be lost on an instant type-then-close.
3. ⌘Q → the app-level `before-quit` guard (cancellable) counts dirty editors
plus running transfers.
`editorSave` is a dedicated request (not generic `writeFile`) so one handler
atomically writes + cleans the bun record + retitles + pushes `pathRefresh`
to the main window (panels showing that directory refresh). Editor upgrades:
scroll-synced line gutter (hidden when wrap is on — wrapped line numbers would
need per-edit height measurement), Alt/⌥Z wrap toggle, custom find bar
(matches computed in TS, `setSelectionRange` + scrollTop math — deliberately
not `findInPage`, which is a shim on WebView2).
### Connection-manager window (`src/bun/manager-window.ts`)
Single instance, centered over main, **modal to main only** (editors stay
usable). No native modality exists, so three layers approximate it: bun
bounces main's `focus` event back to the manager; the main webview renders a
click-swallowing overlay (`managerState` message); main drops its keyboard
shortcuts and bun drops main-targeted menu actions while open. Connect /
open-stack execute in the MAIN window via the `connect:<id>` /
`open-stack:<id>` menuAction channel (the manager closes right after — a
failed connect surfaces in main). Store edits push `connectionsChanged` so
main's `ConnectionsService` reloads; a reload-retry in `App.findConnection`
covers the save→connect race. `setAlwaysOnTop` is deliberately NOT used (it
would float over editor windows).
## Bun modules
- `fs/provider.ts` — `FileSystemProvider` interface: uniform local/SFTP ops +
path helpers + streams + **`fsKey`** (see self-copy below).
- `fs/local.ts` — node:fs, `lstat`-based, `fsKey="local"`. Owner/group via
cached `id -nu` / `dscacheutil|getent` spawns (win32 → undefined).
`listLocalDrives()` for the Windows drive picker (`fsutil`).
- `fs/sftp.ts` — ssh2 under Bun (pure JS; native accelerators are build
`external`s). POSIX paths always. `fsKey = resolvedIP:port` via `dns.lookup`
at connect. Owner/group parsed from the ls-style readdir `longname`.
Keepalive 15 s; `closedByUs` flag so `dispose()` doesn't fire the onClose
callback. mtime seconds→ms; mode masked `& 0o7777`.
- `fs/registry.ts` — sessionId → provider map; `"local"` is built-in;
`add()` mints `sftp-<n>`; `drop()` (session died) vs `remove()` (clean).
- `transfers/engine.ts` — FIFO job queue. `Semaphore` with live resize
(`wake()` reserves slots synchronously — an async waiter increment was a
race). Per-job worker pool (`MAX_WORKERS_PER_JOB=8`) draws from the shared
slot budget, so in-folder parallelism and cross-job fairness share one knob
(`setConcurrency` from settings; `single` mode = 1). `activeStreams` set
enables hard cancel. `emit` throttled 100 ms. `failSession` fails that
session's jobs. Probe files: `.{appname}-probe-*`.
- `transfers/walk.ts` — recursive tree walk (segment arrays, skips symlinks)
+ `removeRecursive`.
- **Self-copy protection** (data-loss class): copying a file onto itself
truncates it (`createWriteStream` O_TRUNCs before the read). Guards compare
**fsKey, never sessionId** — two sessions to one server are distinct
sessions on one filesystem. When fsKeys differ but paths collide,
`probeSameFilesystem` writes a token via dst and reads it via src to unmask
hidden aliases (SFTP has no dev/inode). Fails open (permissions produce
their own error). Residual risk (documented in README): same filesystem
mounted at different paths.
- `edits.ts` — `readFileText`/`writeFileText` (2 MB cap) for the built-in
editor; `EditManager` for the external-editor flow: temp-download under
`appDataDir()/edits/<id>/` + `meta.json`, directory watch (survives atomic
renames), 400 ms debounce → engine upload. Watchers die with their session;
leftover dirs are offered for cleanup at boot. `launchEditor` is the
platform seam (`open` / `cmd /c start` / `xdg-open`).
- `store/paths.ts` — per-OS `appDataDir()` + `migrateLegacyDataDir()` (renames
a `BRANDING.legacyNames` dir into place on boot; list currently empty).
- `store/connections.ts` — plaintext-passwords JSON (deliberate v1 choice),
atomic tmp+rename, 0600 + win32 `icacls` ACL. `store/settings.ts` — defaults
spread-merge on load, so NEW SETTINGS KEYS need no backend change.
- `context-menu.ts` — native row/empty-space menus + the column picker.
Local shell actions (reveal/open/copy-path) run bun-side via Utils; others
forward through `menuAction` and the UI applies them to the row it
remembered (`FileActionsService.menuTarget`). Labels are per-platform.
- `menu.ts` — macOS-only app menu, rebuilt on every connections change (the
Connect submenu mirrors the store). ⌘W is intercepted by the menu, so bun
routes `close-tab` to the focused editor window first.
- `tray.ts` — static icon, click = activate main. macOS template image;
Windows colored icon. No `setImage` calls ever (they lose the template
treatment) and no menu (clicks arrive directly).
- `index.ts` — composition root: wiring order matters (events assigned after
rpc; manager/editors constructed after win; quit guard; focus tracking for
notification suppression across all windows).
## UI services (`ui/src/app/`)
- `rpc.service.ts` — wraps `Electroview.defineRPC`. **Push messages fan out
through plain callback registries, never signals**: a signal consumed from
an effect that also writes signals re-triggers itself (this froze the whole
app once), and signal coalescing drops progress snapshots. All requests are
gated on the socket-open promise — calls fired during boot would otherwise
be silently lost. `request` and `send` are ready-gated Proxies.
- `panels.service.ts` — `PanelState` (per panel): sessionId, fsKey, cwd,
entries, sort, cursor, selection, type-to-filter. `sorted` computed:
filter → dirs-first sort (name/size/mtime numeric, kind/rights/owner/group
via display-string compare) → synthetic `'..'` row unshifted unless
`isRootPath(cwd)`. `touched` marks user navigation (connect flows only
auto-load `localDir` into an untouched local panel). `connectToken`
supersedes stale connects. `Workspace` = tab = left/right pair + activeSide.
Remote goes RIGHT on connect/open-stack (commander convention); per-panel
dropdown connects target their own panel.
- `transfers.service.ts` — jobMap from `transferUpdate` callbacks; EMA speed
(`SPEED_SMOOTHING=0.7`); `copyToOtherPanel` guards same-fs+same-dir before
enqueue and runs the collision flow (same-path auto-skip, type-mismatch
skip, overwrite dialog with all/none batch modes).
- `settings.service.ts` — one signal per setting; **imperative apply pattern**
(`applyTheme/DarkTone/Accent/TextSize` set `data-*` attributes on
`<html>`); `applyAll` also serves the `settingsChanged` broadcast so editor
and manager windows restyle live (they render no settings UI, so no
persist echo). New setting = five touches here + types.ts (backend merges
automatically).
- `focus.service.ts` — dependency-free `tick`/`bump()`; dialogs bump it, the
main window's panels listen. Exists so dialogs don't inject PanelsService
(whose constructor spawns a workspace — fatal in secondary windows).
- `connections.service.ts` — store mirror + ssh-config hosts; reloads on the
`connectionsChanged` push.
- `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always
against an explicit panel; `menuTarget` remembers the right-clicked row.
- `file-icons.ts` — extension → category map driving glyphs, colors
(fixed `--icon-*` tokens, never accent-tinted) and the Kind column.
- `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`,
`EDITOR_CONTEXT`, `IS_MAC_CHROME`, `IS_WINDOWS`, `MOD_LABEL`/`ALT_LABEL`)
+ `blockNativeContextMenu` (kills WebKit's Reload/Inspect menu outside
text fields).
## Theming
`ui/src/styles.css`: shadcn-style OKLCH tokens; dark values are duplicated in
`:root[data-theme='dark']` AND a `prefers-color-scheme` media block (system
mode) — every dark token change goes in BOTH. Accent tones: one
`--tone-h/--tone-c` line per tone + three formula blocks derive `--primary`,
`--ring`, `--cursor-tint`; graphite = attribute removed = pristine zinc.
`@theme inline` maps tokens to Tailwind utilities (`bg-primary`,
`text-icon-folder`, `bg-cursor-tint`). Selected rows are `bg-primary/15`
(follow the accent); the cursor row uses `--cursor-tint` so graphite stays
pixel-identical to the old neutral hover. Text-size levels set `--row-h`,
`--list-font` etc. per `data-text` attribute, consumed via `h-(--row-h)` /
`text-(length:--list-font)` arbitrary utilities.
+84
View File
@@ -0,0 +1,84 @@
# Build & release
## Versioning — "Build n"
Sublime-style: the version IS the commit count. `scripts/gen-version.ts` runs
`git rev-list --count HEAD` + `git rev-parse --short HEAD` and writes the
**gitignored** `src/shared/version.gen.ts` (`BUILD = {number, commit, date,
electrobun}`). It is regenerated by `postinstall` and at the start of every
`dev`/`build`/`start`/`release` script; committing it would bump the count it
records, hence gitignored. Fresh checkouts fail `tsc` until `bun install` has
run once (postinstall creates the file). `BRANDING.version` stays
semver-shaped (`0.1.<n>`) for electrobun's config/updater; `BRANDING.buildLabel`
("Build n") is what humans see (About box, options popover).
## Dev loop
- `bun run dev` — gen-version → scripts/dev.ts: starts `ng serve` (HMR) and
`electrobun dev --watch`, with `DEV_SERVER_URL` pointing the webviews at the
dev server (only honored on the dev channel and only if the server answers).
- `bun run build` — gen-version → Angular build → `electrobun build`
(**dev channel** — fine for boot checks, NOT a release).
- `scripts/node-version-shim.ts` (preloaded via `bunfig.toml`, root AND ui/)
fakes a Node version the Angular CLI accepts under Bun. Both preloads are
load-bearing.
## Release builds
`bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64]` (default: host).
Steps: gen-version → gen-icons → Angular build → `electrobun build
--env=stable` → copy/rename artifacts into
`release/<target>/miniSCP-Build<n>-<target>.*`.
Facts the script encodes:
- **`--env=stable` is mandatory for releases** — the CLI defaults to the dev
channel (different app name `miniSCP-dev`, no distributable artifacts).
- **No OS cross-compile.** The CLI builds for its host (its `targets` config
field is unread in 1.18.1); packaging needs host tools (codesign/hdiutil on
macOS, rcedit/PowerShell on Windows). Hence one script run per artifact, on
the matching machine; `win-x64` on a non-Windows host errors out.
- **mac-x64 from the arm64 Mac (Rosetta)**: the script downloads electrobun's
x64 CLI binary once (`electrobun-cli-darwin-x64.tar.gz` from the GitHub
release matching the installed version, cached at
`~/.cache/miniscp/electrobun-cli-x64-<ver>/`) and runs it directly —
Rosetta translates it, the CLI sees `process.arch === "x64"`, downloads the
`dist-macos-x64` core into the electrobun package dir on first use, and
packages a genuine x64 app. Note: Bun's `fetch`→`Bun.write` streaming hung
on the GitHub redirect chain; the script uses `curl -fsSL` deliberately.
- **Core binary downloads**: the installed npm package ships mac-arm64
binaries only; other `dist-<os>-<arch>` cores are fetched from
`github.com/blackboardsh/electrobun/releases` at build time — network to
GitHub is a build-time requirement on every fresh machine/runner.
- **Artifact shapes** (stable channel, in `artifacts/`): macOS →
`stable-macos-<arch>-miniSCP.dmg` + `.app.tar.zst` + `update.json`
(updater feed); Windows → `stable-win-x64-miniSCP-Setup.zip`
(self-extracting installer → `%LOCALAPPDATA%`) plus the script's own
**portable zip** made from the assembled app folder under
`build/stable-win-x64/` (layout to confirm on the first real Windows build —
the step warns instead of failing).
- The script filters artifacts by app name, so stale artifacts from older
brandings don't get collected.
## CI (Gitea Actions)
`.gitea/workflows/release.yml` — tag-triggered (`build-*`), macos + windows
runner matrix, each running the same release script; inert until act_runners
with those labels are registered. Needs `fetch-depth: 0` (the build number is
the commit count). Runner prereqs are commented in the workflow file.
## Icons
`scripts/gen-icons.ts` (resvg, dev-time): renders `assets/logo.svg` into the
macOS iconset + `icon.png`, a multi-size `assets/icon.ico` (Windows installer/
taskbar, wired via `electrobun.config.ts win.icon`), the macOS tray template
PNG from `assets/tray/tray-glyph.svg`, and a small colored Windows tray PNG.
Rerun after touching either SVG; outputs are committed (build inputs).
## electrobun config notes
`electrobun.config.ts`: app identity comes from `BRANDING`; `build.bun.external`
must keep `["cpu-features", "*.node"]` (ssh2's optional native accelerators —
bundling them breaks the build; runtime falls back to pure JS);
`build.copy` ships the Angular dist and tray assets into `views://`;
`bundleCEF: false` everywhere (system WebKit / WebView2).
+97
View File
@@ -0,0 +1,97 @@
# Gotchas — landmines this codebase has already stepped on
Every one of these caused a real bug or debugging spiral. Read before
touching the adjacent code.
## Angular (v22, zoneless, signals, OnPush)
- **Never consume push streams through signal+effect.** An effect that reads
a signal and (directly or transitively) writes signals re-triggers itself —
this froze the entire app on the first transfer. Push messages
(transferUpdate etc.) go through plain callback registries in RpcService.
Signal coalescing would also silently drop intermediate progress snapshots.
- **No signal writes during template render** (NG0600). Anything that loads
panels from code paths reachable during render defers with
`queueMicrotask` (see `PanelsService.newWorkspace`).
- **`[value]` on `<select>` is applied before `@for` options render** and
silently selects nothing. Use per-option `[selected]` bindings (connection
manager stack picker).
- Template bindings with special characters: `[class.bg-primary/15]` works;
`[class.shadow-[...]]` (brackets) does NOT parse — use `[style.box-shadow]`.
Static class attributes may use arbitrary values freely.
- Functions called from templates ARE reactive: signals read inside them
register on the view's consumer (call-stack-based tracking). That's why
`iconClass(entry)` reacting to `settings.iconColors()` works.
- Dedicated tools > shell: IDE diagnostics between paired template/class
edits are often stale mid-sequence — trust `bun run build:ui` (full AOT
type-check), not the first red squiggle.
- Tailwind v4 canonical class names: `wrap-break-word` not `break-words`,
`accent-primary` not `accent-[var(--primary)]`, `z-60` not `z-[60]`.
Every color used in a template must resolve through `@theme inline`.
## Electrobun
- **Window `close` is not cancellable** (response type `{}`; only the
app-level `before-quit` supports `event.response = {allow:false}`). All
unsaved-state guards must either intercept app-controlled close paths
(⌘W, buttons) or survive the close (editor draft mirroring).
- **One rpc instance per window** — BrowserView binding steals the rpc's
transport; a shared instance silently sends to the wrong webview.
- **RPC before socket open is silently lost.** Everything gates on the
socket-open promise (RpcService `ready`).
- The emitter has no `off()`. Window event closures must not capture records
you want garbage collected.
- `electrobun build` defaults to the **dev** channel; release builds need
`--env=stable`. The stable app name differs from the dev one
(`miniSCP` vs `miniSCP-dev`), including the app-data dir implications when
testing.
- Tray: never call `setImage` after construction on macOS — the template
treatment is lost and the icon renders black on dark menu bars.
- `ApplicationMenu` is macOS-only in our usage; accelerators on Windows are
single-character only. ContextMenu exists on macOS+Windows, not Linux.
- Dev vs prod URL: `getMainViewUrl()` resolves the ng-serve dev server only
when `DEV_SERVER_URL` is set AND the updater channel is `dev` AND the
server answers a HEAD request; secondary windows must reuse the resolved
URL, never hardcode `views://`.
- Upstream Bun panic (`FFI_Callback_threadsafe_call` → `web_worker.spin`):
rare segfault under long sessions; upstream electrobun×Bun issue, restart
recovers, nothing app-side to fix.
## Domain logic
- **sessionId ≠ filesystem identity.** Two panels on the same server are two
SSH sessions. Every same-file/nested-path guard compares `fsKey`
(`resolvedIP:port` / `"local"`); comparing sessionId caused a
truncate-to-zero data-loss bug. When fsKeys differ but paths collide, the
probe-file check unmasks aliased hosts. Never "simplify" this back.
- `createWriteStream` truncates BEFORE the source is read — that's why
self-copy is destructive, and why guards must run before any stream opens.
- The synthetic `'..'` row: type `'dir'`, must be excluded from selection,
targets(), filtering match-count, kind/rights/owner display, and drag
sources. Any new row-level feature must decide what `'..'` does.
- `Semaphore.wake()` must reserve slots synchronously; waking waiters that
increment asynchronously loses budget under concurrent release.
- Settings: the backend spread-merges (`{...DEFAULT_SETTINGS, ...data}`) so
new keys auto-default, but the UI SettingsService reads field-by-field —
a new setting silently stays at default until wired in all five spots.
- The editor draft mirror sends full content per tick (2 MB cap upstream);
don't shrink the 400 ms debounce.
- `panel.touched` gates localDir auto-navigation; `connectToken` guards
against a slow connect resolving into a panel the user already reused.
## Build / tooling
- Angular CLI rejects Bun's Node-compat version — `scripts/node-version-shim.ts`
is preloaded via `bunfig.toml` at the root AND in `ui/`. Removing either
breaks `ng`.
- ssh2's optional native deps must stay `external: ["cpu-features", "*.node"]`
in `electrobun.config.ts` — bundling them breaks the build; ssh2 falls back
to pure JS at runtime.
- `bun run --cwd ui build` (flag order matters; `bun --cwd ui run build` does
not do what you want).
- `version.gen.ts` is gitignored and regenerated by postinstall + every build
script; a fresh checkout fails `tsc` until `bun install` (or any script)
has run once. Never commit it — it would bump the commit count it records.
- Icon pipeline is `scripts/gen-icons.ts` (resvg) — regenerate after touching
`assets/logo.svg` or `assets/tray/tray-glyph.svg`; the .ico and tray PNGs
are build inputs, not build outputs.
+71
View File
@@ -0,0 +1,71 @@
# Platform notes — macOS / Windows behavior matrix
macOS is the primary target; Windows 11+ x64 is a **beta** target (win-arm64
deferred; Linux untargeted — electrobun lacks menus/context menus there).
Every platform seam carries a `PORTABILITY:` comment at the branch site.
## Seam inventory (where platform branches live)
| Concern | File | mac | win | other |
|---|---|---|---|---|
| Window chrome | `src/bun/windows.ts`, `index.ts` | hiddenInset + traffic-light offset, UI drag header | native titlebar, compact header | same as win |
| External editor launch | `src/bun/edits.ts launchEditor` | `open [-a app]` | `cmd /c start "" [app] file` | app directly / `xdg-open` |
| Editor app picker | `rpc-handlers.ts pickEditorApp` | /Applications, `.app` | %ProgramFiles%, `.exe` | home, any |
| Owner/group names | `fs/local.ts resolveId` | `id -nu` + `dscacheutil` | undefined → "—" | `id -nu` + `getent` |
| Drive roots | `fs/local.ts listLocalDrives` | empty | `fsutil fsinfo drives` | empty |
| chmod/Permissions | context-menu.ts, file-panel, file-actions | full | hidden for LOCAL panels (only flips read-only); SFTP unaffected | full |
| Shell wording | `context-menu.ts` | Reveal in Finder / Move to Trash | Show in Explorer / Move to Recycle Bin | Show in File Manager |
| Shortcut labels | `ui platform.ts MOD_LABEL/ALT_LABEL` | ⌘ / ⌥ | Ctrl+ / Alt+ | Ctrl+ / Alt+ |
| Tray | `src/bun/tray.ts` | 22 pt template PNG | 16 px colored PNG | colored |
| App menu | `src/bun/menu.ts` | full menu incl. About | none (early return) — About lives in the options popover | none |
| connections.json protection | `store/connections.ts` | chmod 0600 | `icacls` current-user ACL (0600 is meaningless) | 0600 |
| App data dir | `store/paths.ts` | ~/Library/Application Support | %APPDATA% | $XDG_CONFIG_HOME/.config |
| ssh-agent | `fs/sftp.ts` | $SSH_AUTH_SOCK | `\\.\pipe\openssh-ssh-agent` fallback | $SSH_AUTH_SOCK |
| Paths in UI | `ui format.ts joinPath/isRootPath` | "/" | separator detected from the dir string; drive-root regex | "/" |
Remote (SFTP) paths are ALWAYS POSIX regardless of host or server OS
(`path.posix` throughout `fs/sftp.ts`).
## Windows known issues (upstream electrobun, documented not fixed)
- **HiDPI blur** — WebView2 renders at 1x on scaled displays
(blackboardsh/electrobun#324, open). Most user-visible defect; wait for
upstream DPI awareness.
- **Notifications** are legacy balloon style (`Shell_NotifyIcon`), not
Windows toasts.
- **Unsigned binaries** — Setup.exe and the portable exe both trigger
SmartScreen / Mark-of-the-Web on first run. No Authenticode support in
electrobun 1.18.1 (signtool post-hook or electrobun-builder are options
later). Installer unpacks to `%LOCALAPPDATA%`, desktop shortcut only — no
Start Menu entry or uninstaller.
- `hiddenInset` degrades to `hidden` on Windows — irrelevant here because
non-mac windows use the native titlebar.
- `findInPage` is a `window.find()` shim on WebView2 — irrelevant here, the
editor find is custom.
## First-Windows-boot smoke test
Code paths written blind (no Windows box was available); verify in order:
1. Build: `bun scripts/release.ts --target win-x64` on the Windows machine —
core binaries download from GitHub releases on first run; Setup.zip AND
the portable zip appear under `release/win-x64/` (the portable step warns
instead of failing if the assembled-app folder layout differs — fix the
`appDir` path in release.ts if so).
2. App boots, tray icon renders colored, window has a native titlebar.
3. Drives dropdown in each panel lists `C:\` (+ others); navigating to a
drive root hides the `'..'` row; the path bar shows consistent `\` paths.
4. local↔remote transfers both directions; self-copy guard still blocks
same-path copies.
5. External editor: default app opens via `cmd /c start`; the Choose… picker
starts in Program Files and accepts an .exe; remote temp-edit → save →
auto-upload works (directory watcher on NTFS).
6. Context menus: Show in Explorer, Move to Recycle Bin (item restorable from
the bin), Copy Path; Permissions ABSENT on local rows, present on remote.
7. Ctrl+T/W/F/S and Ctrl+digit tab jumps; tooltips read "Ctrl+…".
8. connections.json ACL: `icacls connections.json` shows only the current
user after saving a connection.
9. Editor/manager windows open, modal bounce works, About shows from the
options popover.
10. Known-issue confirmation: HiDPI blur on a scaled display (expected until
upstream fixes it).
+2 -1
View File
@@ -27,7 +27,8 @@ export default {
bundleCEF: false,
},
win: {
bundleCEF: false,
bundleCEF: false, // system WebView2 keeps the bundle small
icon: "assets/icon.ico",
},
},
} satisfies ElectrobunConfig;
+12 -5
View File
@@ -3,13 +3,17 @@
"version": "0.1.0",
"private": true,
"type": "module",
"workspaces": ["ui"],
"workspaces": [
"ui"
],
"scripts": {
"dev": "bun scripts/dev.ts",
"dev": "bun scripts/gen-version.ts && bun scripts/dev.ts",
"dev:ui": "bun run --cwd ui start",
"build:ui": "bun run --cwd ui build",
"build": "bun run build:ui && bunx electrobun build",
"start": "bun run build:ui && bunx electrobun dev"
"build": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun build",
"start": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun dev",
"postinstall": "bun scripts/gen-version.ts",
"release": "bun scripts/release.ts"
},
"dependencies": {
"electrobun": "1.18.1",
@@ -20,7 +24,10 @@
"@resvg/resvg-js": "^2.6.2",
"@types/bun": "latest",
"@types/ssh2": "^1.15.5",
"png-to-ico": "^3.0.2",
"typescript": "~5.9.3"
},
"trustedDependencies": ["@parcel/watcher"]
"trustedDependencies": [
"@parcel/watcher"
]
}
+10 -3
View File
@@ -1,10 +1,12 @@
// Regenerate every raster icon asset from the SVG sources (dev-time only).
// bun scripts/gen-icons.ts
// Produces: assets/icon.png (1024), assets/icon.iconset/* (macOS app icon),
// assets/tray/tray-44-template.png.
// assets/icon.ico (Windows), assets/tray/tray-44-template.png (macOS tray),
// assets/tray/tray-32-color.png (Windows tray).
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { Resvg } from "@resvg/resvg-js";
import pngToIco from "png-to-ico";
const root = path.join(import.meta.dir, "..");
const assets = path.join(root, "assets");
@@ -27,8 +29,13 @@ for (const size of [16, 32, 128, 256, 512]) {
await writePng(path.join(assets, "icon.iconset", `icon_${size}x${size}@2x.png`), logo, size * 2);
}
// ---- tray base (22pt status item @2x) ----
// ---- Windows app icon (.ico: 16/32/48/256, used by installer + taskbar) ----
const icoPngs = [16, 32, 48, 256].map((size) => Buffer.from(renderPng(logo, size)));
await Bun.write(path.join(assets, "icon.ico"), await pngToIco(icoPngs));
// ---- tray: macOS 22pt template @2x; Windows small colored icon ----
const trayGlyph = await Bun.file(path.join(assets, "tray", "tray-glyph.svg")).text();
await writePng(path.join(assets, "tray", "tray-44-template.png"), trayGlyph, 44);
await writePng(path.join(assets, "tray", "tray-32-color.png"), logo, 32);
console.log("icons regenerated: app iconset + tray template");
console.log("icons regenerated: app iconset + icon.ico + tray images");
+46
View File
@@ -0,0 +1,46 @@
// Writes src/shared/version.gen.ts (gitignored) with the "Build n" version:
// n = commit count since the repo root, Sublime Text style. Regenerated by
// postinstall and by every dev/build/release script — never committed, since
// committing it would bump the very count it records.
import path from "node:path";
function git(args: string[]): string | null {
try {
const proc = Bun.spawnSync(["git", ...args], {
cwd: import.meta.dir,
stdout: "pipe",
stderr: "ignore",
});
if (!proc.success) return null;
return proc.stdout.toString().trim() || null;
} catch {
return null;
}
}
const number = Number(git(["rev-list", "--count", "HEAD"]) ?? 0);
const commit = git(["rev-parse", "--short", "HEAD"]) ?? "dev";
const date = new Date().toISOString().slice(0, 10);
let electrobun = "unknown";
try {
const pkg = await Bun.file(
path.join(import.meta.dir, "..", "node_modules", "electrobun", "package.json"),
).json();
electrobun = pkg.version;
} catch {
// not installed yet (postinstall ordering) — fine
}
const out = `// GENERATED by scripts/gen-version.ts — do not edit, do not commit.
export const BUILD = {
number: ${number},
commit: ${JSON.stringify(commit)},
date: ${JSON.stringify(date)},
electrobun: ${JSON.stringify(electrobun)},
} as const;
`;
const target = path.join(import.meta.dir, "..", "src", "shared", "version.gen.ts");
await Bun.write(target, out);
console.log(`[version] Build ${number} (${commit}) -> ${path.relative(process.cwd(), target)}`);
+128
View File
@@ -0,0 +1,128 @@
// Build one release artifact for one target, on the matching host:
// bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64]
// Default target = the host. electrobun cannot cross-compile OSes (its
// packaging needs host tools: codesign/hdiutil on mac, rcedit/PowerShell on
// Windows), so win-x64 must run on a Windows machine. mac-x64 IS buildable on
// an arm64 Mac: we fetch electrobun's x64 CLI binary and run it under
// Rosetta 2 — it sees an x64 host, pulls the x64 core, and packages mac-x64.
// Artifacts land in release/<target>/miniSCP-Build<N>-<target>.<ext>.
import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
import os from "node:os";
import path from "node:path";
const ROOT = path.join(import.meta.dir, "..");
const TARGETS = ["mac-arm64", "mac-x64", "win-x64"] as const;
type Target = (typeof TARGETS)[number];
function hostTarget(): Target {
if (process.platform === "win32") return "win-x64";
return process.arch === "arm64" ? "mac-arm64" : "mac-x64";
}
function fail(message: string): never {
console.error(`\n[release] ERROR: ${message}`);
process.exit(1);
}
function run(cmd: string[], opts: { cwd?: string } = {}): void {
console.log(`[release] $ ${cmd.join(" ")}`);
const proc = Bun.spawnSync(cmd, {
cwd: opts.cwd ?? ROOT,
stdout: "inherit",
stderr: "inherit",
});
if (!proc.success) fail(`command failed: ${cmd.join(" ")}`);
}
// ---- target selection + host guards ----
const argIndex = process.argv.indexOf("--target");
const requested = (argIndex >= 0 ? process.argv[argIndex + 1] : hostTarget()) as Target;
if (!TARGETS.includes(requested)) fail(`unknown target "${requested}" (${TARGETS.join(", ")})`);
if (requested === "win-x64" && process.platform !== "win32") {
fail("win-x64 must be built on a Windows machine/runner — electrobun cannot cross-compile.");
}
if (requested.startsWith("mac") && process.platform !== "darwin") {
fail(`${requested} must be built on macOS.`);
}
const viaRosetta = requested === "mac-x64" && process.arch === "arm64";
// ---- prepare: version, icons, UI bundle ----
run(["bun", "scripts/gen-version.ts"]);
run(["bun", "scripts/gen-icons.ts"]);
run(["bun", "run", "--cwd", "ui", "build"]);
const { BUILD } = await import("../src/shared/version.gen");
const { BRANDING } = await import("../src/shared/branding");
const buildName = `Build${BUILD.number}`;
// ---- electrobun build (native, or x64 CLI under Rosetta) ----
async function x64Cli(): Promise<string> {
const pkg = await Bun.file(path.join(ROOT, "node_modules", "electrobun", "package.json")).json();
const dir = path.join(os.homedir(), ".cache", "miniscp", `electrobun-cli-x64-${pkg.version}`);
const bin = path.join(dir, "electrobun");
if (existsSync(bin)) return bin;
console.log(`[release] downloading electrobun x64 CLI ${pkg.version} (one-time)…`);
mkdirSync(dir, { recursive: true });
const url = `https://github.com/blackboardsh/electrobun/releases/download/v${pkg.version}/electrobun-cli-darwin-x64.tar.gz`;
const tarball = path.join(dir, "cli.tar.gz");
run(["curl", "-fsSL", "-o", tarball, url]);
run(["tar", "-xzf", tarball, "-C", dir]);
if (!existsSync(bin)) fail(`x64 CLI tarball did not contain "electrobun" (${dir})`);
run(["chmod", "+x", bin]);
return bin;
}
if (viaRosetta) {
// x64 Mach-O runs under Rosetta transparently; it sees process.arch === x64
const rosetta = Bun.spawnSync(["arch", "-x86_64", "true"]);
if (!rosetta.success) {
fail("Rosetta 2 is not installed — run: softwareupdate --install-rosetta --agree-to-license");
}
run([await x64Cli(), "build", "--env=stable"]);
} else {
run(["bunx", "electrobun", "build", "--env=stable"]);
}
// ---- collect artifacts ----
const [osName, archName] = requested === "win-x64" ? ["win", "x64"] : ["macos", requested.slice(4)];
const prefix = `stable-${osName}-${archName}-`;
const artifactsDir = path.join(ROOT, "artifacts");
const outDir = path.join(ROOT, "release", requested);
mkdirSync(outDir, { recursive: true });
const produced = readdirSync(artifactsDir).filter(
(f) => f.startsWith(prefix) && f.includes(BRANDING.appName),
);
if (!produced.length) fail(`no ${prefix}* artifacts found in artifacts/ — did the build run?`);
const out: string[] = [];
for (const file of produced) {
const ext = file.endsWith(".dmg") ? ".dmg" : file.endsWith("-Setup.zip") ? "-Setup.zip" : null;
if (!ext) continue; // skip the raw .app.tar.zst + update.json
const name = `${BRANDING.appName}-${buildName}-${requested}${ext}`;
cpSync(path.join(artifactsDir, file), path.join(outDir, name));
out.push(name);
}
// Windows: also produce a portable zip from the assembled app folder (the
// Setup.exe is an installer; portable = unzip anywhere and run). Best-effort:
// the folder layout is confirmed by the first real Windows build.
if (requested === "win-x64") {
const appDir = path.join(ROOT, "build", `stable-${osName}-${archName}`, BRANDING.appName);
if (existsSync(appDir)) {
const portable = path.join(outDir, `${BRANDING.appName}-${buildName}-win-x64-portable.zip`);
run(["powershell", "-Command", `Compress-Archive -Force -Path '${appDir}\\*' -DestinationPath '${portable}'`]);
out.push(path.basename(portable));
} else {
console.warn(`[release] WARN: portable skipped — app folder not found at ${appDir}`);
}
}
if (!out.length) fail("artifacts were found but none matched a known package extension.");
console.log(`\n[release] ${BRANDING.appName} ${BRANDING.buildLabel} (${BUILD.commit}) — ${requested}`);
for (const name of out) console.log(`[release] release/${requested}/${name}`);
+20 -5
View File
@@ -11,6 +11,16 @@ const COLUMNS: { key: ColumnKey; label: string }[] = [
{ key: "group", label: "Group" },
];
// PORTABILITY: shell-integration wording per OS
const IS_WIN = process.platform === "win32";
const REVEAL_LABEL =
process.platform === "darwin"
? "Reveal in Finder"
: IS_WIN
? "Show in Explorer"
: "Show in File Manager";
const TRASH_LABEL = IS_WIN ? "Move to Recycle Bin" : "Move to Trash";
export interface RowMenuContext {
path: string;
name: string;
@@ -84,7 +94,7 @@ export class PanelContextMenu {
items.push({ label: "Refresh", action: "ctx-refresh" });
if (ctx.isLocal) {
items.push({ type: "separator" });
items.push({ label: "Reveal in Finder", action: "ctx-reveal" });
items.push({ label: REVEAL_LABEL, action: "ctx-reveal" });
}
return items;
}
@@ -99,18 +109,23 @@ export class PanelContextMenu {
}
items.push({ label: "Copy to Other Panel", action: "ctx-copy" });
items.push({ label: "Rename", action: "ctx-rename" });
items.push({ label: "Permissions", action: "ctx-chmod" });
// chmod on Windows local files only flips the read-only bit — hide the lie
if (!(ctx.isLocal && IS_WIN)) {
items.push({ label: "Permissions", action: "ctx-chmod" });
}
items.push({ type: "separator" });
items.push({ label: "Copy Path", action: "ctx-copy-path" });
if (ctx.isLocal) {
items.push({ label: "Reveal in Finder", action: "ctx-reveal" });
items.push({ label: REVEAL_LABEL, action: "ctx-reveal" });
}
items.push({ type: "separator" });
if (ctx.isLocal) {
items.push({
label: "Move to Trash",
label: TRASH_LABEL,
action: "ctx-trash",
tooltip: "Finder's Restore is not available for items trashed from here",
...(process.platform === "darwin"
? { tooltip: "Finder's Restore is not available for items trashed from here" }
: {}),
});
}
items.push({ label: "Delete", action: "ctx-delete" });
+17 -6
View File
@@ -48,14 +48,25 @@ export function writeFileText(
* Launch a file in an external editor.
*
* PORTABILITY: this is the only platform-specific piece of the edit feature.
* macOS delivers the file to any GUI app uniformly via `open [-a app]`.
* A Windows port should use `cmd /c start "" [app] file` and a Linux port
* `xdg-open file` (or `app file` when an editor is configured) — swap the
* spawn below and the .app picker in pickEditorApp; everything else
* (temp download, directory watch, auto-upload) is platform-neutral.
* macOS delivers the file to any GUI app via `open [-a app]`; Windows via
* `cmd /c start` (empty "" arg = window title slot); Linux via the app
* directly or `xdg-open`. Everything else (temp download, directory watch,
* auto-upload) is platform-neutral. Windows path pending first smoke test.
*/
function launchEditor(filePath: string, editorApp: string): void {
const args = editorApp ? ["open", "-a", editorApp, filePath] : ["open", filePath];
let args: string[];
switch (process.platform) {
case "darwin":
args = editorApp ? ["open", "-a", editorApp, filePath] : ["open", filePath];
break;
case "win32":
args = editorApp
? ["cmd", "/c", "start", "", editorApp, filePath]
: ["cmd", "/c", "start", "", filePath];
break;
default:
args = editorApp ? [editorApp, filePath] : ["xdg-open", filePath];
}
Bun.spawn(args, { stdout: "ignore", stderr: "ignore" });
}
+16
View File
@@ -47,6 +47,22 @@ function resolveId(kind: "user" | "group", id: number): string | undefined {
return name;
}
/**
* Drive roots for the Windows panel source picker ("C:\", "D:\", …).
* PORTABILITY: win32-only concept; empty elsewhere ("/" is always reachable).
*/
export function listLocalDrives(): string[] {
if (process.platform !== "win32") return [];
try {
const proc = Bun.spawnSync(["fsutil", "fsinfo", "drives"], { stdout: "pipe", stderr: "ignore" });
if (!proc.success) return [];
// output: "Drives: C:\ D:\ ..."
return [...proc.stdout.toString().matchAll(/[A-Za-z]:\\/g)].map((m) => m[0]);
} catch {
return [];
}
}
function toEntry(name: string, full: string, st: Stats): FileEntry {
return {
name,
+19
View File
@@ -51,6 +51,7 @@ const events = {
openEditor: (_ctx: EditorOpenContext) => {},
settingsChanged: (_settings: AppSettings) => {},
openConnectionManager: () => {},
showAbout: () => {},
};
const rpc = createAppRPC({ registry, engine, store, settings, edits, contextMenu, events });
const url = await getMainViewUrl();
@@ -104,6 +105,20 @@ events.settingsChanged = (s) => {
manager.broadcastSettings(s);
};
events.openConnectionManager = () => manager.open();
function showAbout(): void {
const { build } = BRANDING;
void Utils.showMessageBox({
type: "info",
title: BRANDING.appName,
message: BRANDING.appName,
detail: `${BRANDING.buildLabel} (${build.commit}) · ${build.date}\n\nElectrobun ${build.electrobun} · Bun ${Bun.version}`,
buttons: ["OK"],
defaultId: 0,
cancelId: 0,
});
}
events.showAbout = showAbout;
// clean editors close with the main window; dirty ones get to ask first,
// and the modal manager has nothing to guard
win.on("close", () => {
@@ -204,6 +219,10 @@ ApplicationMenu.on("application-menu-clicked", (event) => {
manager.open();
return;
}
if (action === "about") {
showAbout();
return;
}
// the manager is modal to the main window — don't drive main behind it
if (manager.isOpen()) return;
rpc.send.menuAction({ action });
+3
View File
@@ -1,4 +1,5 @@
import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/bun";
import { BRANDING } from "../shared/branding";
import type { SavedConnection } from "../shared/types";
import type { ConnectionStore } from "./store/connections";
@@ -32,6 +33,8 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
ApplicationMenu.setApplicationMenu([
{
submenu: [
{ label: `About ${BRANDING.appName}`, action: "about" },
{ type: "separator" },
{ role: "hide" },
{ role: "hideOthers" },
{ role: "showAll" },
+22 -3
View File
@@ -3,6 +3,7 @@ import type { AppRPC, EditorOpenContext } from "../shared/rpc";
import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types";
import type { PanelContextMenu } from "./context-menu";
import { readFileText, writeFileText, type EditManager } from "./edits";
import { listLocalDrives } from "./fs/local";
import type { SessionRegistry } from "./fs/registry";
import { SftpProvider } from "./fs/sftp";
import type { ConnectionStore } from "./store/connections";
@@ -30,6 +31,7 @@ export interface AppDeps {
openEditor: (ctx: EditorOpenContext) => void;
settingsChanged: (settings: AppSettings) => void;
openConnectionManager: () => void;
showAbout: () => void;
};
}
@@ -100,8 +102,9 @@ function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void)
const pickKeyFileHandler = () =>
wrap(async () => {
const { homedir } = await import("node:os");
const { join } = await import("node:path");
const picked = await Utils.openFileDialog({
startingFolder: `${homedir()}/.ssh`,
startingFolder: join(homedir(), ".ssh"),
allowedFileTypes: "*",
canChooseFiles: true,
canChooseDirectory: false,
@@ -240,9 +243,17 @@ export function createAppRPC({
pickEditorApp: () =>
wrap(async () => {
// PORTABILITY: apps live in different places per OS
const { homedir } = await import("node:os");
const [startingFolder, allowedFileTypes] =
process.platform === "darwin"
? ["/Applications", "app"]
: process.platform === "win32"
? [process.env.ProgramFiles ?? "C:\\Program Files", "exe"]
: [homedir(), "*"];
const picked = await Utils.openFileDialog({
startingFolder: "/Applications",
allowedFileTypes: "app",
startingFolder,
allowedFileTypes,
canChooseFiles: true,
canChooseDirectory: false,
allowsMultipleSelection: false,
@@ -294,6 +305,14 @@ export function createAppRPC({
events.openConnectionManager();
return {};
}),
showAbout: () =>
wrap(async () => {
events.showAbout();
return {};
}),
listDrives: () => wrap(async () => ({ drives: listLocalDrives() })),
},
},
});
+19
View File
@@ -1,4 +1,5 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { ConnectionsFile, SavedConnection, Stack } from "../../shared/types";
import { appDataDir } from "./paths";
@@ -68,5 +69,23 @@ export class ConnectionStore {
await fs.writeFile(tmp, JSON.stringify(this.data, null, "\t"), { mode: 0o600 });
await fs.rename(tmp, this.file);
await fs.chmod(this.file, 0o600);
this.restrictOnWindows();
}
/**
* PORTABILITY: 0o600 only toggles the read-only bit on Windows, so the
* plaintext passwords in this file would be world-readable there. Replace
* inheritance with a current-user-only ACL, best effort.
*/
private restrictOnWindows(): void {
if (process.platform !== "win32") return;
try {
Bun.spawn(
["icacls", this.file, "/inheritance:r", "/grant:r", `${os.userInfo().username}:F`],
{ stdout: "ignore", stderr: "ignore" },
);
} catch {
// ACL tooling unavailable — file still works, just unprotected
}
}
}
+13 -8
View File
@@ -1,21 +1,26 @@
import { Tray, type BrowserWindow } from "electrobun/bun";
/**
* Menu-bar icon: the app glyph as a macOS template image (adapts to
* light/dark). Clicking it brings the app window to the front. No menu is ever
* set, so clicks arrive directly; no setImage swaps, so the template
* treatment from construction always applies.
* Menu-bar / notification-area icon. Clicking it brings the app window to the
* front. No menu is ever set, so clicks arrive directly; no setImage swaps, so
* the image treatment from construction always applies.
*
* PORTABILITY: macOS uses a monochrome template image (adapts to light/dark
* menu bars); Windows ignores `template` and wants a small colored icon.
*/
export class AppTray {
private tray: Tray;
constructor(win: BrowserWindow) {
const isMac = process.platform === "darwin";
this.tray = new Tray({
title: "",
image: "views://assets/tray/tray-44-template.png",
template: true,
width: 22,
height: 22,
image: isMac
? "views://assets/tray/tray-44-template.png"
: "views://assets/tray/tray-32-color.png",
template: isMac,
width: isMac ? 22 : 16,
height: isMac ? 22 : 16,
});
this.tray.on("tray-clicked", () => win.activate());
}
+9 -3
View File
@@ -1,12 +1,18 @@
// The single place to rebrand the app. Everything (window title, bundle name,
// app-data directory, UI header, icon) reads from here.
import { BUILD } from "./version.gen";
export const BRANDING = {
appName: "miniSCP",
identifier: "es.teknes.miniscp",
// Previous app names: their app-data folders are migrated on boot
// Previous app names whose app-data folders migrate on boot after a rename
// (see migrateLegacyDataDir in src/bun/store/paths.ts).
legacyNames: ["BunSCP"],
version: "0.1.0",
legacyNames: [] as string[],
// Sublime-style versioning: n = commit count (scripts/gen-version.ts).
// `version` stays semver-shaped for electrobun's config/updater.
version: `0.1.${BUILD.number}`,
buildLabel: `Build ${BUILD.number}`,
build: BUILD,
// macOS build converts this .iconset folder into AppIcon.icns.
// Regenerate from assets/icon.png with scripts/gen-iconset.sh.
icon: "assets/icon.iconset",
+4
View File
@@ -137,6 +137,10 @@ export type AppRPC = {
moveToTrash: { params: { path: string }; response: Result };
/** Native column-picker menu (right-click on the list headers). */
showColumnsMenu: { params: { visible: ColumnKey[] }; response: Result };
/** Native About box (name, Build n, commit, engine versions). */
showAbout: { params: Record<string, never>; response: Result };
/** Windows drive roots for the local panel ("C:\", …); empty elsewhere. */
listDrives: { params: Record<string, never>; response: Result<{ drives: string[] }> };
// editor windows
/** Main window only: open (or focus) a native editor window. */
openEditor: { params: EditorOpenContext; response: Result };
+3 -3
View File
@@ -28,7 +28,7 @@
<div class="flex-1"></div>
<button
(click)="toggleWrap()"
title="Wrap long lines — hides line numbers (⌥Z)"
[title]="'Wrap long lines — hides line numbers (' + altLabel + 'Z)'"
class="rounded-md px-2 py-1 text-[11px] electrobun-webkit-app-region-no-drag hover:bg-accent"
[class.bg-accent]="wrap()"
[class.text-foreground]="wrap()"
@@ -38,7 +38,7 @@
</button>
<button
(click)="openFind()"
title="Find (⌘F)"
[title]="'Find (' + modLabel + 'F)'"
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaSearch" size="0.85rem" />
@@ -142,7 +142,7 @@
</div>
<footer class="flex shrink-0 items-center gap-2 border-t border-border px-4 py-2">
<span class="text-[11px] text-muted-foreground">⌘S save · ⌘F find · ⌘W close</span>
<span class="text-[11px] text-muted-foreground">{{ modLabel }}S save · {{ modLabel }}F find · {{ modLabel }}W close</span>
<span class="text-[11px] text-muted-foreground">·</span>
<span class="text-[11px] tabular-nums text-muted-foreground">
{{ lineCount() }} {{ lineCount() === 1 ? 'line' : 'lines' }}
+3 -1
View File
@@ -10,7 +10,7 @@ import {
viewChild,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME } from '../platform';
import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME, MOD_LABEL } from '../platform';
import { RpcService } from '../rpc.service';
import { SettingsService } from '../settings.service';
import { ToastService } from '../toast.service';
@@ -40,6 +40,8 @@ const DRAFT_DEBOUNCE_MS = 400;
export class EditorWindow {
readonly ctx = EDITOR_CONTEXT;
readonly isMacChrome = IS_MAC_CHROME;
readonly modLabel = MOD_LABEL;
readonly altLabel = ALT_LABEL;
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
+3
View File
@@ -3,6 +3,7 @@ import type { FileEntry } from '@shared/types';
import { DialogsService } from './dialogs.service';
import { joinPath } from './format';
import { type PanelState, type Side } from './panels.service';
import { IS_WINDOWS } from './platform';
import { RpcService } from './rpc.service';
import { SettingsService } from './settings.service';
import { ToastService } from './toast.service';
@@ -157,6 +158,8 @@ export class FileActionsService {
}
chmod(panel: PanelState): void {
// Windows local chmod only flips the read-only bit — don't pretend
if (IS_WINDOWS && panel.sessionId() === 'local') return;
const targets = this.targets(panel);
if (!targets.length) return;
this.dialogs.chmod.set({
+20 -7
View File
@@ -63,13 +63,15 @@
>
<ng-icon name="mynaFilePlus" size="var(--action-icon)" />
</button>
<button
(click)="run('chmod')"
title="Permissions"
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaLock" size="var(--action-icon)" />
</button>
@if (chmodAvailable()) {
<button
(click)="run('chmod')"
title="Permissions"
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaLock" size="var(--action-icon)" />
</button>
}
<button
(click)="run('delete')"
title="Delete (F8 / Del)"
@@ -95,6 +97,17 @@
}
</button>
<!-- Windows drive roots (empty list elsewhere) -->
@for (drive of drives(); track drive) {
<button
(click)="pickDrive(drive)"
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-6 text-left text-(length:--path-font) hover:bg-accent"
>
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 font-mono">{{ drive }}</span>
</button>
}
@if (connections.grouped().loose.length || connections.grouped().stacks.length) {
<div class="my-1 border-t border-border"></div>
}
+16
View File
@@ -16,6 +16,7 @@ import { FileActionsService } from '../file-actions.service';
import { fileGlyph, fileIconClass, fileKind } from '../file-icons';
import { formatDate, formatMode, formatSize } from '../format';
import { PanelsService, type Side, type SortKey } from '../panels.service';
import { IS_WINDOWS } from '../platform';
import { RpcService } from '../rpc.service';
import { SettingsService } from '../settings.service';
import { TransfersService } from '../transfers.service';
@@ -56,6 +57,10 @@ export class FilePanel {
readonly pathDraft = signal('');
readonly menuOpen = signal(false);
/** Windows drive roots for the source picker; always empty elsewhere. */
readonly drives = signal<string[]>([]);
/** chmod on Windows local files only flips the read-only bit — hide it. */
readonly chmodAvailable = computed(() => !(IS_WINDOWS && !this.isRemote()));
private readonly listEl = viewChild.required<ElementRef<HTMLDivElement>>('list');
private lastClickedIndex = 0;
@@ -79,7 +84,18 @@ export class FilePanel {
void this.rpc.request.showColumnsMenu({ visible: this.settings.columns() });
}
pickDrive(drive: string): void {
this.menuOpen.set(false);
this.activate();
void this.panel().load(drive);
}
constructor() {
if (IS_WINDOWS && this.rpc.available) {
void this.rpc.request.listDrives({}).then((res) => {
if (res.ok) this.drives.set(res.drives);
});
}
// keep the editable path bar in sync with the actual cwd
effect(() => this.pathDraft.set(this.panel().cwd()));
// focus follows the active side (Tab switches panels, dialogs refocus on close)
+11 -2
View File
@@ -42,9 +42,18 @@ export function formatEta(seconds: number): string {
return h ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` : mmss;
}
/** POSIX-style join for UI-composed paths (both panels use "/" on macOS + remote). */
/**
* Separator-aware join for UI-composed paths: remote and POSIX-local dirs use
* "/", a Windows-local dir ("C:\Users\x") keeps "\" so paths stay consistent.
*/
export function joinPath(dir: string, name: string): string {
return dir.endsWith('/') ? dir + name : `${dir}/${name}`;
const sep = dir.includes('\\') && !dir.includes('/') ? '\\' : '/';
return dir.endsWith(sep) ? dir + name : `${dir}${sep}${name}`;
}
/** "/" (POSIX) or a drive root like "C:\" — nothing above to navigate to. */
export function isRootPath(p: string): boolean {
return p === '/' || /^[A-Za-z]:[\\/]?$/.test(p);
}
/** 0o755 -> "rwxr-xr-x" */
+4 -4
View File
@@ -2,7 +2,7 @@ import { Injectable, computed, inject, signal, type WritableSignal } from '@angu
import type { ColumnKey, ConnectionConfig, FileEntry, SavedConnection } from '@shared/types';
import { fileKind } from './file-icons';
import { FocusService } from './focus.service';
import { formatMode } from './format';
import { formatMode, isRootPath, joinPath } from './format';
import { RpcService } from './rpc.service';
import { ToastService } from './toast.service';
@@ -73,8 +73,8 @@ export class PanelState {
return cmp * dir;
});
const cwd = this.cwd();
if (cwd && cwd !== '/') {
list.unshift({ name: '..', path: `${cwd}/..`, type: 'dir', size: 0, mtime: 0, mode: 0 });
if (cwd && !isRootPath(cwd)) {
list.unshift({ name: '..', path: joinPath(cwd, '..'), type: 'dir', size: 0, mtime: 0, mode: 0 });
}
return list;
});
@@ -144,7 +144,7 @@ export class PanelState {
up(): void {
if (!this.cwd()) return;
void this.load(`${this.cwd()}/..`);
void this.load(joinPath(this.cwd(), '..'));
}
/** Update the filter and park the cursor on the first real match. */
+6
View File
@@ -18,6 +18,12 @@ export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?
/** True when the window uses macOS hiddenInset chrome (header fused with titlebar). */
export const IS_MAC_CHROME = PLATFORM === 'darwin';
export const IS_WINDOWS = PLATFORM === 'win32';
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. */
export const MOD_LABEL = IS_MAC_CHROME ? '⌘' : 'Ctrl+';
export const ALT_LABEL = IS_MAC_CHROME ? '⌥' : 'Alt+';
export const APP_MODE: 'main' | 'editor' | 'manager' =
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
+19 -3
View File
@@ -1,8 +1,9 @@
import { Component, ElementRef, effect, inject, signal, viewChild } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { BRANDING } from '@shared/branding';
import type { AccentTone } from '@shared/types';
import { PanelsService } from '../panels.service';
import { IS_MAC_CHROME } from '../platform';
import { IS_MAC_CHROME, MOD_LABEL } from '../platform';
import { RpcService } from '../rpc.service';
import { SettingsService } from '../settings.service';
@@ -77,7 +78,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
@if (panels.workspaces().length > 1) {
<button
(click)="close($event, ws.id)"
title="Close tab (⌘W)"
[title]="'Close tab (' + modLabel + 'W)'"
class="mr-1 grid size-4 shrink-0 place-items-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-border hover:text-foreground"
>
<ng-icon name="mynaX" size="0.7rem" />
@@ -93,7 +94,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
</div>
<button
(click)="panels.newWorkspace()"
title="New tab (⌘T)"
[title]="'New tab (' + modLabel + 'T)'"
class="ml-0.5 grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaPlus" size=".8rem" />
@@ -339,6 +340,14 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
</button>
</div>
</div>
<!-- about footer -->
<button
(click)="showAbout()"
class="mt-3 w-full border-t border-border pt-2 text-center text-[11px] text-muted-foreground hover:text-foreground"
>
{{ appName }} · {{ buildLabel }}
</button>
</div>
}
</div>
@@ -350,6 +359,9 @@ export class TabBar {
private readonly rpc = inject(RpcService);
readonly settingsOpen = signal(false);
readonly isMacChrome = IS_MAC_CHROME;
readonly modLabel = MOD_LABEL;
readonly appName = BRANDING.appName;
readonly buildLabel = BRANDING.buildLabel;
readonly themes = THEMES;
readonly darkTones = DARK_TONES;
readonly accents = ACCENTS;
@@ -392,6 +404,10 @@ export class TabBar {
void this.rpc.request.openConnectionManager({});
}
showAbout(): void {
void this.rpc.request.showAbout({});
}
async pickEditorApp(): Promise<void> {
const res = await this.rpc.request.pickEditorApp({});
if (res.ok && res.path) this.settings.setEditorApp(res.path);