Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5be41db032 | ||
|
|
5d23809d48 | ||
|
|
3f7145d69a | ||
|
|
fa2cec7136 | ||
|
|
e28314054f | ||
|
|
8f8f230711 | ||
|
|
d361297690 | ||
|
|
0eb5608873 | ||
|
|
9bd237d89c | ||
|
|
bfc2147c77 | ||
|
|
318760efa6 | ||
|
|
141d675bf7 | ||
|
|
f3bec7aeaf | ||
|
|
99bfd3abc2 | ||
|
|
a83264c77d | ||
|
|
e3a5750b01 | ||
|
|
c3d97ce524 | ||
|
|
f7f02083fe | ||
|
|
2bac7678ca | ||
|
|
6c05e39ce1 | ||
|
|
0a0c708140 | ||
|
|
4605a481f1 | ||
|
|
5ed029a2b8 | ||
|
|
c4364c1051 | ||
|
|
89af52e1c0 | ||
|
|
9c54a441a9 | ||
|
|
f101257f20 | ||
|
|
9f2a076e08 | ||
|
|
c8ccc6b918 | ||
|
|
5bbfc2f475 | ||
|
|
eafe426c8e | ||
|
|
bb97e5418f | ||
|
|
b35c30d5ab | ||
|
|
39e019af9f | ||
|
|
17bb1a14fb | ||
|
|
ab0bb4ef6f | ||
|
|
06a14fb6a1 | ||
|
|
7921b2986b | ||
|
|
aaa12c3e23 | ||
|
|
01739e4cf4 | ||
|
|
7b15e0982e | ||
|
|
0ae2a62229 | ||
|
|
1f773261a8 | ||
|
|
fe75904872 | ||
|
|
2294453c93 | ||
|
|
2db367318d | ||
|
|
66076cf1d7 | ||
|
|
4102020f5c | ||
|
|
11c3d419c7 | ||
|
|
fe028a9b01 | ||
|
|
72643051f9 | ||
|
|
6bb4c3cd7d | ||
|
|
30a432cd37 | ||
|
|
f7ebed5478 | ||
|
|
07825c1bd1 | ||
|
|
fc03fa6216 | ||
|
|
e4ba846719 | ||
|
|
697f7f22c8 | ||
|
|
f87077b2b7 | ||
|
|
b5b1320776 | ||
|
|
3d8c410a82 | ||
|
|
b2cf000293 | ||
|
|
f86b10335e | ||
|
|
6ecb1b8339 | ||
|
|
a40433b99e | ||
|
|
43f1168d6a | ||
|
|
a0f56822cd | ||
|
|
d8cfc54049 | ||
|
|
ee5a9e13e6 | ||
|
|
260e2d64b2 |
@@ -1,14 +1,16 @@
|
|||||||
# Release matrix — INERT until act_runner instances with these labels are
|
# Release matrix — INERT until act_runner instances with these labels are
|
||||||
# registered on git.teknes.es. Each OS builds its own artifacts (electrobun
|
# registered on git.teknes.es. Each OS builds its own artifacts (electrobun
|
||||||
# cannot cross-compile: packaging needs codesign/hdiutil on macOS and
|
# cannot cross-compile: packaging needs codesign/hdiutil on macOS and
|
||||||
# rcedit/PowerShell on Windows).
|
# rcedit/PowerShell on Windows). No mac-x64: Electrobun 2.x has no macOS x64
|
||||||
|
# core.
|
||||||
#
|
#
|
||||||
# Runner prerequisites:
|
# Runner prerequisites:
|
||||||
# macos — arm64 Mac: bun, Xcode CLT, Rosetta 2 (softwareupdate
|
# macos — Apple Silicon Mac: bun, Xcode CLT, network to github.com and the
|
||||||
# --install-rosetta), network to github.com (electrobun downloads
|
# Electrobun/Hutch release hosts (`bun install` runs `electrobun
|
||||||
# its per-arch core binaries on first build)
|
# prepare`; the first build per Electrobun version fills ~/.hutch
|
||||||
# windows — Windows 11 x64: bun, network to github.com; WebView2 runtime is
|
# with Hutch, the core, Cottontail and the pinned Bun, ~200 MB)
|
||||||
# preinstalled on Win 11
|
# windows — Windows 11 x64: bun, the same network access; WebView2 runtime
|
||||||
|
# is preinstalled on Win 11
|
||||||
#
|
#
|
||||||
# Trigger: push a tag like build-62 (git tag build-$(git rev-list --count HEAD)).
|
# Trigger: push a tag like build-62 (git tag build-$(git rev-list --count HEAD)).
|
||||||
name: release
|
name: release
|
||||||
@@ -26,7 +28,6 @@ jobs:
|
|||||||
fetch-depth: 0 # full history — the build number is the commit count
|
fetch-depth: 0 # full history — the build number is the commit count
|
||||||
- run: bun install --frozen-lockfile
|
- run: bun install --frozen-lockfile
|
||||||
- run: bun scripts/release.ts --target mac-arm64
|
- run: bun scripts/release.ts --target mac-arm64
|
||||||
- run: bun scripts/release.ts --target mac-x64
|
|
||||||
- uses: actions/upload-artifact@v3
|
- uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: mac
|
name: mac
|
||||||
|
|||||||
@@ -8,3 +8,6 @@ ui/dist/
|
|||||||
ui/.angular/
|
ui/.angular/
|
||||||
src/shared/version.gen.ts
|
src/shared/version.gen.ts
|
||||||
release/
|
release/
|
||||||
|
# Hutch-generated (electrobun 2.x): the projected SDK devkit and build scratch
|
||||||
|
.hutch/
|
||||||
|
.cottontail-tmp/
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Working agreements for miniSCP
|
||||||
|
|
||||||
|
## Orientation
|
||||||
|
|
||||||
|
`docs/` is the source of truth — read the relevant file before touching adjacent code:
|
||||||
|
[architecture.md](docs/architecture.md) (process/window model, bun modules, UI services, theming),
|
||||||
|
[gotchas.md](docs/gotchas.md) (landmines already hit — read before changing nearby code),
|
||||||
|
[build-and-release.md](docs/build-and-release.md), [platform-notes.md](docs/platform-notes.md).
|
||||||
|
|
||||||
|
## Planning
|
||||||
|
|
||||||
|
In plan mode, use `AskUserQuestion` to resolve **every** ambiguity before finalizing a
|
||||||
|
plan. Never guess at intent or silently pick behavior the user didn't specify — keep asking
|
||||||
|
until the approach is unambiguous, then exit planning.
|
||||||
|
|
||||||
|
## After an approved plan is executed
|
||||||
|
|
||||||
|
1. **Verify first** — backend: `bun run ./node_modules/typescript/bin/tsc --noEmit -p tsconfig.json`;
|
||||||
|
UI: `bun run build:ui`; plus any smoke test that fits the change. (`bun run build:ui` is the
|
||||||
|
full AOT type-check for Angular — trust it over IDE squiggles.)
|
||||||
|
2. **Then commit + push automatically, no need to ask** — a descriptive message ending with:
|
||||||
|
|
||||||
|
```
|
||||||
|
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||||
|
```
|
||||||
|
|
||||||
|
Push to the current branch (this repo works directly on `main`).
|
||||||
|
3. If verification fails, fix it or report back — do **not** commit broken work.
|
||||||
|
|
||||||
|
## Keep the live documentation current
|
||||||
|
|
||||||
|
`docs/` is living, not a one-time write. When a change alters the architecture/process model,
|
||||||
|
a module's or service's responsibility, the RPC surface, theming, or introduces a new landmine,
|
||||||
|
update the matching doc **in the same change, before the auto-commit**:
|
||||||
|
|
||||||
|
- `docs/architecture.md` — structure, bun modules, UI services, theming.
|
||||||
|
- `docs/gotchas.md` — anything that caused a real bug or debugging spiral.
|
||||||
|
- `docs/build-and-release.md` / `docs/platform-notes.md` — when build or platform behavior changes.
|
||||||
|
|
||||||
|
Match the existing dense, bulleted house style.
|
||||||
@@ -15,14 +15,15 @@ remote server over SSH/SFTP, so you can move files **local↔remote**,
|
|||||||
Dense reference material lives in [docs/](docs/): [architecture](docs/architecture.md)
|
Dense reference material lives in [docs/](docs/): [architecture](docs/architecture.md)
|
||||||
(processes, windows, RPC, every module/service), [build & release](docs/build-and-release.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)
|
(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).
|
(macOS/Windows/Linux matrix, runtime deps + smoke tests), and
|
||||||
|
[gotchas](docs/gotchas.md) (known landmines).
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Dual-panel commander UI; either panel can be Local or any saved connection.
|
- Dual-panel commander UI; either panel can be Local or any saved connection.
|
||||||
- **Session tabs**: each tab is an independent left/right workspace; remote
|
- **Session tabs**: each tab is an independent left/right workspace; remote
|
||||||
sessions stay connected and transfers keep running in background tabs.
|
sessions stay connected and transfers keep running in background tabs.
|
||||||
- Per-panel action toolbar with a direction-explicit copy button, plus a
|
- Per-panel action toolbar with a direction-explicit send button, plus a
|
||||||
VSCode-style status bar showing live transfer progress (click to expand the
|
VSCode-style status bar showing live transfer progress (click to expand the
|
||||||
transfer queue with per-file speed and ETA).
|
transfer queue with per-file speed and ETA).
|
||||||
- **Overwrite confirmation** on name collisions, with a source-vs-destination
|
- **Overwrite confirmation** on name collisions, with a source-vs-destination
|
||||||
@@ -34,15 +35,17 @@ Dense reference material lives in [docs/](docs/): [architecture](docs/architectu
|
|||||||
a standard titlebar.
|
a standard titlebar.
|
||||||
- **Menu-bar (tray) icon**: template icon that adapts to light/dark; click it
|
- **Menu-bar (tray) icon**: template icon that adapts to light/dark; click it
|
||||||
to bring the app to the front.
|
to bring the app to the front.
|
||||||
- **Edit files** (F4 / toolbar / File menu): a built-in editor works on local
|
- **Edit files** (F4 / ⌘E / toolbar / File menu): a built-in editor works on local
|
||||||
and remote files directly; or pick an external app (Options → Editor →
|
and remote files directly; or pick an external app (Settings → Editor →
|
||||||
Custom → Choose…). External edits of remote files download to a temp folder,
|
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
|
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
|
the session disconnects. Leftover temp files from a crash are offered for
|
||||||
cleanup at the next start.
|
cleanup at the next start.
|
||||||
- Browse, sort (name/size/modified, folders first), keyboard navigation.
|
- 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).
|
- Create folder / file, rename, delete (recursive), change permissions (chmod).
|
||||||
- Upload, download, and remote→remote copy — files **and** whole folders —
|
- Upload, download, and remote→remote send — files **and** whole folders —
|
||||||
with a transfer queue, per-job progress, and cancel.
|
with a transfer queue, per-job progress, and cancel.
|
||||||
- Connection manager: saved connections organised into folders, password or
|
- Connection manager: saved connections organised into folders, password or
|
||||||
private-key auth (with passphrase).
|
private-key auth (with passphrase).
|
||||||
@@ -52,10 +55,10 @@ Dense reference material lives in [docs/](docs/): [architecture](docs/architectu
|
|||||||
| Layer | Choice |
|
| Layer | Choice |
|
||||||
| ---------- | ------------------------------------------------------------- |
|
| ---------- | ------------------------------------------------------------- |
|
||||||
| Runtime | Bun (main process, all SSH/SFTP + file IO) |
|
| Runtime | Bun (main process, all SSH/SFTP + file IO) |
|
||||||
| Desktop | Electrobun 1.18 (native webview, typed RPC bridge) |
|
| Desktop | Electrobun 2.0 via Hutch (native webview, typed RPC bridge) |
|
||||||
| Frontend | Angular 22 (standalone, signals, zoneless) in `ui/` |
|
| Frontend | Angular 22 (standalone, signals, zoneless) in `ui/` |
|
||||||
| Styling | Tailwind CSS v4, shadcn-style tokens (no component library) |
|
| Styling | Tailwind CSS v4, Oxide-derived tokens (no component library) |
|
||||||
| Icons | @ng-icons/mynaui |
|
| Icons | TDesign shapes redrawn on 14/16px pixel grids (`@ng-icons`) |
|
||||||
| SSH/SFTP | `ssh2` (pure-JS under Bun) |
|
| SSH/SFTP | `ssh2` (pure-JS under Bun) |
|
||||||
|
|
||||||
The repo is a Bun workspace: the Electrobun app at the root, the Angular app in
|
The repo is a Bun workspace: the Electrobun app at the root, the Angular app in
|
||||||
@@ -75,8 +78,17 @@ ui/src/app/ Angular components + signal services
|
|||||||
|
|
||||||
- [Bun](https://bun.sh) ≥ 1.3 (`bun --version`).
|
- [Bun](https://bun.sh) ≥ 1.3 (`bun --version`).
|
||||||
- macOS: **Xcode Command Line Tools** (for the app build).
|
- 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`)
|
- No Node install required — a preload shim (`scripts/node-version-shim.ts`)
|
||||||
satisfies the Angular CLI's Node version check while running under Bun.
|
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
|
## Setup
|
||||||
|
|
||||||
@@ -101,19 +113,19 @@ Versioning is Sublime-style: **Build n**, where n is the commit count
|
|||||||
`src/shared/version.gen.ts` by every build script (and `postinstall`).
|
`src/shared/version.gen.ts` by every build script (and `postinstall`).
|
||||||
|
|
||||||
One artifact per invocation, on the matching host — electrobun cannot
|
One artifact per invocation, on the matching host — electrobun cannot
|
||||||
cross-compile between OSes:
|
cross-compile (Hutch builds for its own OS and arch):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
bun run release -- --target mac-arm64 # on the Mac (native)
|
bun run release -- --target mac-arm64 # on an Apple Silicon Mac
|
||||||
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
|
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`
|
Artifacts land in `release/<target>/miniSCP-Build<n>-<target>.*` — a `.dmg`
|
||||||
per mac arch; on Windows a `-Setup.zip` installer **and** a `-portable.zip`
|
on the Mac; on Windows a `-Setup.zip` installer **and** a `-portable.zip`
|
||||||
(unzip anywhere and run). The mac-x64 build fetches electrobun's x64 CLI once
|
(unzip anywhere and run); on Linux Electrobun's `-Setup.tar.gz` installer.
|
||||||
into `~/.cache/miniscp/` and runs it under Rosetta so the whole pipeline
|
There is no Intel Mac build: Electrobun 2.x publishes no macOS x64 core.
|
||||||
believes it is on an Intel Mac. CI: `.gitea/workflows/release.yml` runs the
|
CI: `.gitea/workflows/release.yml` runs the
|
||||||
same script on a macos + windows runner matrix once act_runners are
|
same script on a macos + windows runner matrix once act_runners are
|
||||||
registered; trigger by pushing a `build-<n>` tag.
|
registered; trigger by pushing a `build-<n>` tag.
|
||||||
|
|
||||||
@@ -128,7 +140,7 @@ compiled from `assets/icon.iconset` into `AppIcon.icns` automatically.
|
|||||||
|
|
||||||
For a distributable (optimized, with auto-update channel) build, use
|
For a distributable (optimized, with auto-update channel) build, use
|
||||||
`bunx electrobun build --env=stable` (see the
|
`bunx electrobun build --env=stable` (see the
|
||||||
[Electrobun docs](https://framework.blackboard.sh/electrobun/) for signing,
|
[Electrobun docs](https://electrobun.dev/electrobun/) for signing,
|
||||||
notarization, and release hosting).
|
notarization, and release hosting).
|
||||||
|
|
||||||
## Rebranding
|
## Rebranding
|
||||||
@@ -168,13 +180,102 @@ Saved connections live in a plain JSON file in the OS app-data directory
|
|||||||
|
|
||||||
## Keyboard shortcuts
|
## Keyboard shortcuts
|
||||||
|
|
||||||
`Tab` switch panel · `↑`/`↓`/`PgUp`/`PgDn`/`Home`/`End` move cursor ·
|
The whole app works from the keyboard, and **every command can be remapped** in
|
||||||
`Space` select · `Enter` open folder · `Backspace` parent ·
|
**Settings → Keyboard**: press **+** on a row and type the keys, see conflicts,
|
||||||
`Ctrl/Cmd+A` select all · `F5` copy to other panel · `F2` rename ·
|
reset one row or all. Remaps apply live in every window, the right-click menus'
|
||||||
`F7` new folder · `F8`/`Delete` delete · `Cmd/Ctrl+T` new tab ·
|
hints and the toolbar tooltips follow, and so does the macOS menu bar. The
|
||||||
`Cmd/Ctrl+W` close tab · `Ctrl+Tab` next tab.
|
defaults:
|
||||||
|
|
||||||
(On Macs, function keys may need `fn` — every action also has a toolbar button.)
|
| 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
|
## Same-file protection
|
||||||
|
|
||||||
@@ -210,6 +311,12 @@ 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;
|
application menu ([src/bun/menu.ts](src/bun/menu.ts)) is macOS-only for now;
|
||||||
the in-app UI covers every menu action.
|
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 (beta)
|
||||||
|
|
||||||
Windows 11+ x64 is supported as a **beta** target (win-arm64 deferred). All
|
Windows 11+ x64 is supported as a **beta** target (win-arm64 deferred). All
|
||||||
@@ -217,11 +324,12 @@ electrobun APIs the app uses are implemented on Windows; known issues are
|
|||||||
upstream:
|
upstream:
|
||||||
|
|
||||||
- **HiDPI blur** ([electrobun#324](https://github.com/blackboardsh/electrobun/issues/324)):
|
- **HiDPI blur** ([electrobun#324](https://github.com/blackboardsh/electrobun/issues/324)):
|
||||||
WebView2 renders at 1x on scaled displays until upstream adds DPI awareness.
|
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.
|
- **Notifications** use legacy balloon style, not Windows toast.
|
||||||
- **Unsigned binaries**: both the Setup.exe installer and the portable exe
|
- **Unsigned binaries**: both the Setup.exe installer and the portable exe
|
||||||
trigger SmartScreen/Mark-of-the-Web warnings on first run. No Start Menu
|
trigger SmartScreen/Mark-of-the-Web warnings on first run. The installer
|
||||||
entry or uninstaller (installer unpacks to `%LOCALAPPDATA%`).
|
unpacks to `%LOCALAPPDATA%` and, since Electrobun 2.x, ships an uninstaller.
|
||||||
- `connections.json` is protected with an `icacls` current-user ACL instead of
|
- `connections.json` is protected with an `icacls` current-user ACL instead of
|
||||||
POSIX 0600 (which is meaningless on Windows).
|
POSIX 0600 (which is meaningless on Windows).
|
||||||
|
|
||||||
@@ -231,20 +339,44 @@ drive root ('..' disappears); local↔remote transfers; external editor launch
|
|||||||
renders colored; Permissions absent on local panels; Ctrl+labels in tooltips;
|
renders colored; Permissions absent on local panels; Ctrl+labels in tooltips;
|
||||||
portable zip runs from any folder.
|
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
|
## Known issues
|
||||||
|
|
||||||
- **Rare native crash under load.** A long-running session (multiple remote
|
- **Rare native crash under load.** A long-running session (multiple remote
|
||||||
connections, extended uptime) can hit a Bun panic — a segfault inside Bun
|
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
|
1.3.13's FFI thread-safe callbacks executing Electrobun's native→worker
|
||||||
bridge (`FFI_Callback_threadsafe_call` → `web_worker.spin` in the decoded
|
bridge (`FFI_Callback_threadsafe_call` → `web_worker.spin` in the decoded
|
||||||
crash report). This is an upstream Bun × Electrobun bug, not app code; a
|
crash report). This is an upstream Bun × Electrobun bug, not app code; a
|
||||||
restart recovers cleanly and in-flight work isn't corrupted. Expected to
|
restart recovers cleanly and in-flight work isn't corrupted. Electrobun 2.0.2
|
||||||
resolve when Electrobun bumps its pinned Bun (currently 1.3.13; 1.3.14+
|
pins Bun 1.4.0, which may have fixed it — not yet confirmed under load. Bun's
|
||||||
available). Bun's own crash reporter link is printed to the console on panic.
|
own crash reporter link is printed to the console on panic.
|
||||||
|
|
||||||
## Not in v1
|
## Not in v1
|
||||||
|
|
||||||
Drag-and-drop, file preview/edit, transfer resume, symlink following
|
Drag-and-drop, file preview/edit, transfer resume, symlink following
|
||||||
(symlinks are listed but skipped by transfers), OS keychain, tab persistence
|
(symlinks are listed but skipped by transfers), OS keychain, tab persistence
|
||||||
across restarts, Windows/Linux packaging (the code is portable; only macOS
|
across restarts, Linux AppImage/.deb packages (the release is Electrobun's
|
||||||
packaging is wired up).
|
Setup installer).
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 619 B After Width: | Height: | Size: 371 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 702 B |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 702 B |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Bunny icon">
|
||||||
|
<!--
|
||||||
|
Front-facing bunny app icon.
|
||||||
|
• Absolute background: TRANSPARENT (nothing drawn outside the rounded square).
|
||||||
|
• ICON BACKGROUND = #0d0d0d (the rounded square)
|
||||||
|
• BUNNY (fur) = #f7f7f5 (soft white; use #ffffff for pure white)
|
||||||
|
Recolor the fur by editing the one fill on the <g>.
|
||||||
|
• Parts: ears = ellipses (rotate = tilt) | head+body = circle |
|
||||||
|
feet = two rounded ellipses (rx = width, ry = height; rotate = splay).
|
||||||
|
Only change from the previous version: the feet are now chubby, gently
|
||||||
|
splayed paws instead of pointy ovals.
|
||||||
|
-->
|
||||||
|
<rect x="24" y="24" width="464" height="464" rx="108" fill="#0d0d0d"/>
|
||||||
|
<g fill="#f7f7f5">
|
||||||
|
<ellipse cx="205" cy="185" rx="34" ry="105" transform="rotate(-9 205 185)"/> <!-- left ear -->
|
||||||
|
<ellipse cx="307" cy="185" rx="34" ry="105" transform="rotate(9 307 185)"/> <!-- right ear -->
|
||||||
|
<circle cx="256" cy="305" r="98"/> <!-- head + body -->
|
||||||
|
<ellipse cx="203" cy="400" rx="32" ry="24" transform="rotate(-12 203 400)"/> <!-- left foot (cute) -->
|
||||||
|
<ellipse cx="309" cy="400" rx="32" ry="24" transform="rotate(12 309 400)"/> <!-- right foot (cute) -->
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1,29 +1,94 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
<!-- BunSCP master logo. Glyph: the two diagonal arrows of mynaui
|
<svg
|
||||||
arrow-diagonal-one with the mini-lock of mynaui lock-square centered
|
viewBox="0 0 512 512"
|
||||||
between them; the squircle itself plays the role of lock-square's
|
role="img"
|
||||||
outer square. Regenerate all raster assets with scripts/gen-icons.ts. -->
|
aria-label="Bunny icon"
|
||||||
<defs>
|
version="1.1"
|
||||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
id="svg4"
|
||||||
<stop offset="0" stop-color="#818cf8"/>
|
sodipodi:docname="logo.svg"
|
||||||
<stop offset="0.5" stop-color="#4f46e5"/>
|
inkscape:version="1.4.4 (dcaf3e7, 2026-05-05)"
|
||||||
<stop offset="1" stop-color="#1e1b4b"/>
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
</linearGradient>
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
</defs>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
<rect x="100" y="100" width="824" height="824" rx="186" fill="url(#bg)"/>
|
<defs
|
||||||
|
id="defs4" />
|
||||||
<g transform="translate(232 232) scale(23.333)"
|
<sodipodi:namedview
|
||||||
fill="none" stroke="#fafafa" stroke-width="1.5"
|
id="namedview4"
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
pagecolor="#ffffff"
|
||||||
<!-- arrow to top-left -->
|
bordercolor="#000000"
|
||||||
<path d="M8.2 8.2 4.6 4.6m0 0v4.2m0-4.2h4.2"/>
|
borderopacity="0.25"
|
||||||
<!-- arrow to bottom-right -->
|
inkscape:showpageshadow="2"
|
||||||
<path d="m15.8 15.8 3.6 3.6m0 0v-4.2m0 4.2h-4.2"/>
|
inkscape:pageopacity="0.0"
|
||||||
<!-- lock: shackle -->
|
inkscape:pagecheckerboard="0"
|
||||||
<path d="M13.3 11.2V9.9c0-1.95-2.6-1.95-2.6 0v1.3"/>
|
inkscape:deskcolor="#d1d1d1"
|
||||||
<!-- lock: body (amber accent) -->
|
inkscape:zoom="1.71875"
|
||||||
<rect x="9.4" y="11.2" width="5.2" height="4.6" rx="1.1"
|
inkscape:cx="256.29091"
|
||||||
fill="#fbbf24" stroke="#fafafa"/>
|
inkscape:cy="256.29091"
|
||||||
|
inkscape:window-width="2560"
|
||||||
|
inkscape:window-height="1382"
|
||||||
|
inkscape:window-x="2560"
|
||||||
|
inkscape:window-y="242"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg4" />
|
||||||
|
<!--
|
||||||
|
Front-facing bunny app icon.
|
||||||
|
• Absolute background: TRANSPARENT (nothing drawn outside the rounded square).
|
||||||
|
• ICON BACKGROUND = #0d0d0d (the rounded square)
|
||||||
|
• BUNNY (fur) = #f7f7f5 (soft white; use #ffffff for pure white)
|
||||||
|
Recolor the fur by editing the one fill on the <g>.
|
||||||
|
• Parts: ears = ellipses (rotate = tilt) | head+body = circle |
|
||||||
|
feet = two rounded ellipses (rx = width, ry = height; rotate = splay).
|
||||||
|
Only change from the previous version: the feet are now chubby, gently
|
||||||
|
splayed paws instead of pointy ovals.
|
||||||
|
-->
|
||||||
|
<g
|
||||||
|
fill="#f7f7f5"
|
||||||
|
id="g4"
|
||||||
|
style="fill:#0e0e0c;fill-opacity:1"
|
||||||
|
transform="matrix(1.3118908,0,0,1.3118908,-79.844041,-78.839213)">
|
||||||
|
<ellipse
|
||||||
|
cx="205"
|
||||||
|
cy="185"
|
||||||
|
rx="34"
|
||||||
|
ry="105"
|
||||||
|
transform="rotate(-9,205,185)"
|
||||||
|
id="ellipse1"
|
||||||
|
style="fill:#0e0e0c;fill-opacity:1" />
|
||||||
|
<!-- left ear -->
|
||||||
|
<ellipse
|
||||||
|
cx="307"
|
||||||
|
cy="185"
|
||||||
|
rx="34"
|
||||||
|
ry="105"
|
||||||
|
transform="rotate(9,307,185)"
|
||||||
|
id="ellipse2"
|
||||||
|
style="fill:#0e0e0c;fill-opacity:1" />
|
||||||
|
<!-- right ear -->
|
||||||
|
<circle
|
||||||
|
cx="256"
|
||||||
|
cy="305"
|
||||||
|
r="98"
|
||||||
|
id="circle2"
|
||||||
|
style="fill:#0e0e0c;fill-opacity:1" />
|
||||||
|
<!-- head + body -->
|
||||||
|
<ellipse
|
||||||
|
cx="203"
|
||||||
|
cy="400"
|
||||||
|
rx="32"
|
||||||
|
ry="24"
|
||||||
|
transform="rotate(-12,203,400)"
|
||||||
|
id="ellipse3"
|
||||||
|
style="fill:#0e0e0c;fill-opacity:1" />
|
||||||
|
<!-- left foot (cute) -->
|
||||||
|
<ellipse
|
||||||
|
cx="309"
|
||||||
|
cy="400"
|
||||||
|
rx="32"
|
||||||
|
ry="24"
|
||||||
|
transform="rotate(12,309,400)"
|
||||||
|
id="ellipse4"
|
||||||
|
style="fill:#0e0e0c;fill-opacity:1" />
|
||||||
|
<!-- right foot (cute) -->
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 702 B |
|
Before Width: | Height: | Size: 621 B After Width: | Height: | Size: 855 B |
@@ -1,12 +1,94 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
<!-- BunSCP tray glyph: black + alpha only (macOS template image).
|
<svg
|
||||||
Same geometry as logo.svg, slightly bolder for menu-bar sizes. -->
|
viewBox="0 0 512 512"
|
||||||
<g fill="none" stroke="#000" stroke-width="1.5"
|
role="img"
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
aria-label="Bunny icon"
|
||||||
<path d="M8.2 8.2 4.6 4.6m0 0v4.2m0-4.2h4.2"/>
|
version="1.1"
|
||||||
<path d="m15.8 15.8 3.6 3.6m0 0v-4.2m0 4.2h-4.2"/>
|
id="svg4"
|
||||||
<!-- lock: outlined so the shackle + body silhouette stays readable small -->
|
sodipodi:docname="tray-glyph.svg"
|
||||||
<path d="M13.55 11V9.7c0-2.1-3.1-2.1-3.1 0V11"/>
|
inkscape:version="1.4.4 (dcaf3e7, 2026-05-05)"
|
||||||
<rect x="9.1" y="11" width="5.8" height="5" rx="1.2"/>
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs4" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview4"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="1.71875"
|
||||||
|
inkscape:cx="256.29091"
|
||||||
|
inkscape:cy="256.29091"
|
||||||
|
inkscape:window-width="2560"
|
||||||
|
inkscape:window-height="1382"
|
||||||
|
inkscape:window-x="2560"
|
||||||
|
inkscape:window-y="242"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg4" />
|
||||||
|
<!--
|
||||||
|
Front-facing bunny app icon.
|
||||||
|
• Absolute background: TRANSPARENT (nothing drawn outside the rounded square).
|
||||||
|
• ICON BACKGROUND = #0d0d0d (the rounded square)
|
||||||
|
• BUNNY (fur) = #f7f7f5 (soft white; use #ffffff for pure white)
|
||||||
|
Recolor the fur by editing the one fill on the <g>.
|
||||||
|
• Parts: ears = ellipses (rotate = tilt) | head+body = circle |
|
||||||
|
feet = two rounded ellipses (rx = width, ry = height; rotate = splay).
|
||||||
|
Only change from the previous version: the feet are now chubby, gently
|
||||||
|
splayed paws instead of pointy ovals.
|
||||||
|
-->
|
||||||
|
<g
|
||||||
|
fill="#f7f7f5"
|
||||||
|
id="g4"
|
||||||
|
style="fill:#ecece8;fill-opacity:1"
|
||||||
|
transform="matrix(1.3118908,0,0,1.3118908,-79.844041,-78.839213)">
|
||||||
|
<ellipse
|
||||||
|
cx="205"
|
||||||
|
cy="185"
|
||||||
|
rx="34"
|
||||||
|
ry="105"
|
||||||
|
transform="rotate(-9,205,185)"
|
||||||
|
id="ellipse1"
|
||||||
|
style="fill:#ecece8;fill-opacity:1" />
|
||||||
|
<!-- left ear -->
|
||||||
|
<ellipse
|
||||||
|
cx="307"
|
||||||
|
cy="185"
|
||||||
|
rx="34"
|
||||||
|
ry="105"
|
||||||
|
transform="rotate(9,307,185)"
|
||||||
|
id="ellipse2"
|
||||||
|
style="fill:#ecece8;fill-opacity:1" />
|
||||||
|
<!-- right ear -->
|
||||||
|
<circle
|
||||||
|
cx="256"
|
||||||
|
cy="305"
|
||||||
|
r="98"
|
||||||
|
id="circle2"
|
||||||
|
style="fill:#ecece8;fill-opacity:1" />
|
||||||
|
<!-- head + body -->
|
||||||
|
<ellipse
|
||||||
|
cx="203"
|
||||||
|
cy="400"
|
||||||
|
rx="32"
|
||||||
|
ry="24"
|
||||||
|
transform="rotate(-12,203,400)"
|
||||||
|
id="ellipse3"
|
||||||
|
style="fill:#ecece8;fill-opacity:1" />
|
||||||
|
<!-- left foot (cute) -->
|
||||||
|
<ellipse
|
||||||
|
cx="309"
|
||||||
|
cy="400"
|
||||||
|
rx="32"
|
||||||
|
ry="24"
|
||||||
|
transform="rotate(12,309,400)"
|
||||||
|
id="ellipse4"
|
||||||
|
style="fill:#ecece8;fill-opacity:1" />
|
||||||
|
<!-- right foot (cute) -->
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 621 B After Width: | Height: | Size: 2.7 KiB |
@@ -5,7 +5,6 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "bunscp",
|
"name": "bunscp",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electrobun": "1.18.1",
|
|
||||||
"ssh-config": "^5.2.0",
|
"ssh-config": "^5.2.0",
|
||||||
"ssh2": "^1.17.0",
|
"ssh2": "^1.17.0",
|
||||||
},
|
},
|
||||||
@@ -13,6 +12,7 @@
|
|||||||
"@resvg/resvg-js": "^2.6.2",
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/ssh2": "^1.15.5",
|
"@types/ssh2": "^1.15.5",
|
||||||
|
"electrobun": "2.0.2",
|
||||||
"png-to-ico": "^3.0.2",
|
"png-to-ico": "^3.0.2",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
},
|
},
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"@angular/core": "^22.0.0",
|
"@angular/core": "^22.0.0",
|
||||||
"@angular/platform-browser": "^22.0.0",
|
"@angular/platform-browser": "^22.0.0",
|
||||||
"@ng-icons/core": "^34.0.0",
|
"@ng-icons/core": "^34.0.0",
|
||||||
"@ng-icons/mynaui": "^34.0.0",
|
"@ng-icons/tdesign-icons": "^34.0.0",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
},
|
},
|
||||||
@@ -133,8 +133,6 @@
|
|||||||
|
|
||||||
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||||
|
|
||||||
"@babylonjs/core": ["@babylonjs/core@7.54.3", "", {}, "sha512-P5ncXVd8GEUJLhwloP9V0oVwQYIrvZztguVeLlvd5Rx+9aQnenKjpV8auJ6SRsUlAmNZU4pFTKzwF6o2EUfhAw=="],
|
|
||||||
|
|
||||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||||
|
|
||||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||||
@@ -253,8 +251,6 @@
|
|||||||
|
|
||||||
"@lmdb/lmdb-win32-x64": ["@lmdb/lmdb-win32-x64@3.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-JF1BmLCm9kGEVZgYmJq43zeQVdHVgAJnTi/NURWEsy6L1ZrrlSmdltS+D17QN4LODwf+1LMXAA9auIZVXtWwzw=="],
|
"@lmdb/lmdb-win32-x64": ["@lmdb/lmdb-win32-x64@3.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-JF1BmLCm9kGEVZgYmJq43zeQVdHVgAJnTi/NURWEsy6L1ZrrlSmdltS+D17QN4LODwf+1LMXAA9auIZVXtWwzw=="],
|
||||||
|
|
||||||
"@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@1.1.1", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ=="],
|
|
||||||
|
|
||||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||||
|
|
||||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
|
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
|
||||||
@@ -307,7 +303,7 @@
|
|||||||
|
|
||||||
"@ng-icons/core": ["@ng-icons/core@34.0.0", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular-devkit/schematics": ">=21.0.0", "@angular/common": ">=21.0.0", "@angular/core": ">=21.0.0", "@schematics/angular": ">=21.0.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-SrL8dBApSKzXhihEGmiJ/tAj+wSvt6XmZJOAzUTVcgkuV0YuL1WCHVfAwXgQHVG5Te27JkjyXd2n76pNFMuekw=="],
|
"@ng-icons/core": ["@ng-icons/core@34.0.0", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular-devkit/schematics": ">=21.0.0", "@angular/common": ">=21.0.0", "@angular/core": ">=21.0.0", "@schematics/angular": ">=21.0.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-SrL8dBApSKzXhihEGmiJ/tAj+wSvt6XmZJOAzUTVcgkuV0YuL1WCHVfAwXgQHVG5Te27JkjyXd2n76pNFMuekw=="],
|
||||||
|
|
||||||
"@ng-icons/mynaui": ["@ng-icons/mynaui@34.0.0", "", { "dependencies": { "tslib": "^2.3.0" } }, "sha512-Wstqby65RIOmlbBzrHfSEb9+HX87GbOHmIfSh3oEV8Wr6vulnOnQ11S8M3aYT5kuuIe9Bq00OzYwbHuqYBCNrw=="],
|
"@ng-icons/tdesign-icons": ["@ng-icons/tdesign-icons@34.0.0", "", { "dependencies": { "tslib": "^2.3.0" } }, "sha512-FIRho+rAX3ludOeIMP+/Tr0Xp3zi3j/JXT5YooGeoMo8AF3qk0gvG4Cu1kI+8V5r3SxoOia9ktPxt0ADKt3LXg=="],
|
||||||
|
|
||||||
"@npmcli/agent": ["@npmcli/agent@4.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^11.2.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg=="],
|
"@npmcli/agent": ["@npmcli/agent@4.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^11.2.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg=="],
|
||||||
|
|
||||||
@@ -475,8 +471,6 @@
|
|||||||
|
|
||||||
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.2", "@tailwindcss/oxide": "4.3.2", "postcss": "^8.5.15", "tailwindcss": "4.3.2" } }, "sha512-rjVWYCa7Ngbi5AarT6k8TkxUG3Wl1QKzHdIZVsjZSzf36Jmo2IKZt/NHRAwly8oDkbBOH0YTu+CHuf9jPxMc+g=="],
|
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.2", "@tailwindcss/oxide": "4.3.2", "postcss": "^8.5.15", "tailwindcss": "4.3.2" } }, "sha512-rjVWYCa7Ngbi5AarT6k8TkxUG3Wl1QKzHdIZVsjZSzf36Jmo2IKZt/NHRAwly8oDkbBOH0YTu+CHuf9jPxMc+g=="],
|
||||||
|
|
||||||
"@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="],
|
|
||||||
|
|
||||||
"@tufjs/canonical-json": ["@tufjs/canonical-json@2.0.0", "", {}, "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA=="],
|
"@tufjs/canonical-json": ["@tufjs/canonical-json@2.0.0", "", {}, "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA=="],
|
||||||
|
|
||||||
"@tufjs/models": ["@tufjs/models@4.1.0", "", { "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^10.1.1" } }, "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww=="],
|
"@tufjs/models": ["@tufjs/models@4.1.0", "", { "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^10.1.1" } }, "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww=="],
|
||||||
@@ -497,7 +491,7 @@
|
|||||||
|
|
||||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||||
|
|
||||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
"agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="],
|
||||||
|
|
||||||
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||||
|
|
||||||
@@ -513,14 +507,10 @@
|
|||||||
|
|
||||||
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
|
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
|
||||||
|
|
||||||
"ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="],
|
|
||||||
|
|
||||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||||
|
|
||||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.42", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q=="],
|
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.42", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q=="],
|
||||||
|
|
||||||
"basic-ftp": ["basic-ftp@5.3.1", "", {}, "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw=="],
|
|
||||||
|
|
||||||
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
|
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
|
||||||
|
|
||||||
"beasties": ["beasties@0.4.2", "", { "dependencies": { "css-select": "^6.0.0", "css-what": "^7.0.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "htmlparser2": "^10.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.49", "postcss-media-query-parser": "^0.2.3", "postcss-safe-parser": "^7.0.1" } }, "sha512-NvcGjG/7AVUAfRbvrJmHunDQS9uHnE6Q/7AkaPr8oKE8HjOlpjRG5075z/th2Tmlezk3VlaaS8+X9I1RwHJMQw=="],
|
"beasties": ["beasties@0.4.2", "", { "dependencies": { "css-select": "^6.0.0", "css-what": "^7.0.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "htmlparser2": "^10.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.49", "postcss-media-query-parser": "^0.2.3", "postcss-safe-parser": "^7.0.1" } }, "sha512-NvcGjG/7AVUAfRbvrJmHunDQS9uHnE6Q/7AkaPr8oKE8HjOlpjRG5075z/th2Tmlezk3VlaaS8+X9I1RwHJMQw=="],
|
||||||
@@ -583,18 +573,12 @@
|
|||||||
|
|
||||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
|
|
||||||
"cross-spawn-windows-exe": ["cross-spawn-windows-exe@1.2.0", "", { "dependencies": { "@malept/cross-spawn-promise": "^1.1.0", "is-wsl": "^2.2.0", "which": "^2.0.2" } }, "sha512-mkLtJJcYbDCxEG7Js6eUnUNndWjyUZwJ3H7bErmmtOYU/Zb99DyUkpamuIZE0b3bhmJyZ7D90uS6f+CGxRRjOw=="],
|
|
||||||
|
|
||||||
"css-select": ["css-select@6.0.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^7.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "nth-check": "^2.1.1" } }, "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw=="],
|
"css-select": ["css-select@6.0.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^7.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "nth-check": "^2.1.1" } }, "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw=="],
|
||||||
|
|
||||||
"css-what": ["css-what@7.0.0", "", {}, "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ=="],
|
"css-what": ["css-what@7.0.0", "", {}, "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ=="],
|
||||||
|
|
||||||
"data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
|
|
||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
"degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="],
|
|
||||||
|
|
||||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
@@ -611,7 +595,7 @@
|
|||||||
|
|
||||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||||
|
|
||||||
"electrobun": ["electrobun@1.18.1", "", { "dependencies": { "@babylonjs/core": "^7.45.0", "@types/bun": "^1.3.8", "png-to-ico": "^2.1.8", "proxy-agent": "^6.5.0", "rcedit": "^4.0.1", "three": "^0.165.0" }, "bin": { "electrobun": "bin/electrobun.cjs" } }, "sha512-tgZ+WKGskn/1/Y5i1mpVCCkgRa1O31Tz7MIArBTK44GfPzT43uOq9c/HvxdQGrLeZV8ZvjK1lQrwqSXCgh6tvA=="],
|
"electrobun": ["electrobun@2.0.2", "", { "bin": { "electrobun": "bin/electrobun.cjs" } }, "sha512-UDs2iTNs6qYCzdWiFch/0DtxrsijNDr1WFDQfRmSA3Iouv0iEzyCF5mh4uDxkwNnAbuwFyow36EiVTYP4DRzig=="],
|
||||||
|
|
||||||
"electron-to-chromium": ["electron-to-chromium@1.5.389", "", {}, "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg=="],
|
"electron-to-chromium": ["electron-to-chromium@1.5.389", "", {}, "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg=="],
|
||||||
|
|
||||||
@@ -639,14 +623,6 @@
|
|||||||
|
|
||||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||||
|
|
||||||
"escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="],
|
|
||||||
|
|
||||||
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
|
||||||
|
|
||||||
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
|
||||||
|
|
||||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
|
||||||
|
|
||||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||||
|
|
||||||
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
|
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
|
||||||
@@ -695,8 +671,6 @@
|
|||||||
|
|
||||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||||
|
|
||||||
"get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="],
|
|
||||||
|
|
||||||
"glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
|
"glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
|
||||||
|
|
||||||
"glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
|
"glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
|
||||||
@@ -737,8 +711,6 @@
|
|||||||
|
|
||||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||||
|
|
||||||
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
|
||||||
|
|
||||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||||
|
|
||||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
|
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
|
||||||
@@ -751,8 +723,6 @@
|
|||||||
|
|
||||||
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
|
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
|
||||||
|
|
||||||
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
|
|
||||||
|
|
||||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||||
@@ -807,7 +777,7 @@
|
|||||||
|
|
||||||
"log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="],
|
"log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="],
|
||||||
|
|
||||||
"lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
|
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
@@ -859,8 +829,6 @@
|
|||||||
|
|
||||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||||
|
|
||||||
"netmask": ["netmask@2.1.1", "", {}, "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA=="],
|
|
||||||
|
|
||||||
"node-addon-api": ["node-addon-api@6.1.0", "", {}, "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA=="],
|
"node-addon-api": ["node-addon-api@6.1.0", "", {}, "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA=="],
|
||||||
|
|
||||||
"node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="],
|
"node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="],
|
||||||
@@ -903,10 +871,6 @@
|
|||||||
|
|
||||||
"p-map": ["p-map@7.0.5", "", {}, "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA=="],
|
"p-map": ["p-map@7.0.5", "", {}, "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA=="],
|
||||||
|
|
||||||
"pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="],
|
|
||||||
|
|
||||||
"pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="],
|
|
||||||
|
|
||||||
"pacote": ["pacote@21.5.1", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/git": "^7.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "@npmcli/run-script": "^10.0.0", "cacache": "^20.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", "npm-package-arg": "^13.0.0", "npm-packlist": "^10.0.1", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "proc-log": "^6.0.0", "sigstore": "^4.0.0", "ssri": "^13.0.0", "tar": "^7.4.3" }, "bin": { "pacote": "bin/index.js" } }, "sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg=="],
|
"pacote": ["pacote@21.5.1", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/git": "^7.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "@npmcli/run-script": "^10.0.0", "cacache": "^20.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", "npm-package-arg": "^13.0.0", "npm-packlist": "^10.0.1", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "proc-log": "^6.0.0", "sigstore": "^4.0.0", "ssri": "^13.0.0", "tar": "^7.4.3" }, "bin": { "pacote": "bin/index.js" } }, "sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg=="],
|
||||||
|
|
||||||
"parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
|
"parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
|
||||||
@@ -945,18 +909,12 @@
|
|||||||
|
|
||||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||||
|
|
||||||
"proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="],
|
|
||||||
|
|
||||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
|
||||||
|
|
||||||
"qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="],
|
"qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="],
|
||||||
|
|
||||||
"range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="],
|
"range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="],
|
||||||
|
|
||||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||||
|
|
||||||
"rcedit": ["rcedit@4.0.1", "", { "dependencies": { "cross-spawn-windows-exe": "^1.1.0" } }, "sha512-bZdaQi34krFWhrDn+O53ccBDw0MkAT2Vhu75SqhtvhQu4OPyFM4RoVheyYiVQYdjhUi6EJMVWQ0tR6bCIYVkUg=="],
|
|
||||||
|
|
||||||
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||||
|
|
||||||
"reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
|
"reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
|
||||||
@@ -1041,8 +999,6 @@
|
|||||||
|
|
||||||
"tar": ["tar@7.5.19", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw=="],
|
"tar": ["tar@7.5.19", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw=="],
|
||||||
|
|
||||||
"three": ["three@0.165.0", "", {}, "sha512-cc96IlVYGydeceu0e5xq70H8/yoVT/tXBxV/W8A/U6uOq7DXc4/s1Mkmnu6SqoYGhSRWWYFOhVwvq6V0VtbplA=="],
|
|
||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||||
|
|
||||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||||
@@ -1105,10 +1061,10 @@
|
|||||||
|
|
||||||
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
|
||||||
|
|
||||||
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
|
"@npmcli/agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||||
|
|
||||||
"@npmcli/agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
"@npmcli/agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||||
|
|
||||||
"@npmcli/agent/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
|
"@npmcli/agent/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
|
||||||
@@ -1145,18 +1101,18 @@
|
|||||||
|
|
||||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
"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=="],
|
"hosted-git-info/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
|
||||||
|
|
||||||
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||||
|
|
||||||
"https-proxy-agent/agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="],
|
"http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||||
|
|
||||||
"log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
|
"log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
|
||||||
|
|
||||||
"log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
"log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
||||||
|
|
||||||
|
"lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
|
|
||||||
"minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
"minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||||
|
|
||||||
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||||
@@ -1165,16 +1121,14 @@
|
|||||||
|
|
||||||
"ora/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
|
"ora/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
|
||||||
|
|
||||||
"pac-proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
|
||||||
|
|
||||||
"path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
|
"path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
|
||||||
|
|
||||||
"png-to-ico/@types/node": ["@types/node@25.9.5", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg=="],
|
"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=="],
|
|
||||||
|
|
||||||
"sass/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
"sass/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||||
|
|
||||||
|
"socks-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||||
|
|
||||||
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||||
|
|
||||||
"ui/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
"ui/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||||
@@ -1187,16 +1141,10 @@
|
|||||||
|
|
||||||
"@angular/compiler-cli/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"@angular/compiler-cli/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
|
||||||
|
|
||||||
"@npmcli/git/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
|
"@npmcli/git/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
|
||||||
|
|
||||||
"@npmcli/promise-spawn/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
|
"@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-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||||
|
|
||||||
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||||
|
|||||||
@@ -6,14 +6,21 @@ Dense reference for development and debugging. Companion files:
|
|||||||
|
|
||||||
## Process model
|
## Process model
|
||||||
|
|
||||||
- **Bun main process** (`src/bun/`): electrobun runs it inside a WebWorker of
|
- **Bun main process** (`src/bun/`): the native launcher runs electrobun's
|
||||||
its native launcher. Owns every privileged capability: filesystem, SSH,
|
`main.js` on the Bun the 2.x devkit pins (1.4.0, shipped in the bundle —
|
||||||
window management, native menus/dialogs/tray, the connection/settings stores.
|
`build.mainProcess: "bun"`), which starts our bundle in a Worker. Imports the
|
||||||
|
SDK as `electrobun/main` (projected into `.hutch/devkit/`). 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 /
|
- **Webviews** (`ui/` Angular): one per window, system WebKit on macOS /
|
||||||
WebView2 on Windows. Zero privileged access — everything goes through RPC.
|
WebView2 on Windows / WebKitGTK on Linux. Zero privileged access — everything
|
||||||
|
goes through RPC.
|
||||||
- **RPC transport**: one WebSocket server in the bun process, multiplexed by
|
- **RPC transport**: one WebSocket server in the bun process, multiplexed by
|
||||||
`webviewId` (each webview auto-connects using natively injected globals).
|
`webviewId` (each webview auto-connects using natively injected globals;
|
||||||
Requests are typed by the `AppRPC` schema in `src/shared/rpc.ts`.
|
packets AES-GCM-encrypted per webview). 2.x Electroview queues sends while
|
||||||
|
its `hostSocket` connects and falls back to a native bridge when the socket
|
||||||
|
is unavailable. Requests are typed by the `AppRPC` schema in
|
||||||
|
`src/shared/rpc.ts`.
|
||||||
|
|
||||||
### The one-schema rule
|
### The one-schema rule
|
||||||
|
|
||||||
@@ -29,8 +36,8 @@ Each `BrowserWindow` MUST receive its own `defineRPC(...)` instance: binding a
|
|||||||
BrowserView calls `rpc.setTransport(...)`, so a shared instance would have its
|
BrowserView calls `rpc.setTransport(...)`, so a shared instance would have its
|
||||||
transport stolen by the most recent window. `createAppRPC` (main),
|
transport stolen by the most recent window. `createAppRPC` (main),
|
||||||
`createEditorRPC` (per editor window, hooks close over that window's record),
|
`createEditorRPC` (per editor window, hooks close over that window's record),
|
||||||
`createManagerRPC` (the single manager window). `rpc.send.*` on an instance
|
`createManagerRPC` (the single manager window), `createSettingsRPC` (the single
|
||||||
targets exactly its own webview.
|
Settings window). `rpc.send.*` on an instance targets exactly its own webview.
|
||||||
|
|
||||||
### The `events` late-binding pattern
|
### The `events` late-binding pattern
|
||||||
|
|
||||||
@@ -38,16 +45,20 @@ targets exactly its own webview.
|
|||||||
exist until after construction). `AppDeps.events` is a mutable object of
|
exist until after construction). `AppDeps.events` is a mutable object of
|
||||||
no-op functions, reassigned in `src/bun/index.ts` after everything exists:
|
no-op functions, reassigned in `src/bun/index.ts` after everything exists:
|
||||||
`sessionClosed`, `connectionsChanged`, `openEditor`, `settingsChanged`,
|
`sessionClosed`, `connectionsChanged`, `openEditor`, `settingsChanged`,
|
||||||
`openConnectionManager`, `showAbout`.
|
`openConnectionManager`, `openSettings`, `showAbout`, `applyUpdate` (the
|
||||||
|
update restart lives in index.ts beside the before-quit guard it must get past —
|
||||||
|
see build-and-release.md "Auto-updater").
|
||||||
|
|
||||||
## Window kinds
|
## Window kinds
|
||||||
|
|
||||||
`src/bun/windows.ts` — `buildPreload(mode, ctx?)` injects `__APP_PLATFORM`,
|
`src/bun/windows.ts` — `buildPreload(mode, settings, ctx?)` injects
|
||||||
`__APP_MODE` (`main|editor|manager`), `__APP_CTX` before any page script.
|
`__APP_PLATFORM`, `__APP_MODE` (`main|editor|manager|settings`), `__APP_CTX`
|
||||||
`ui/src/main.ts` picks the root component from `APP_MODE`; all three roots use
|
before any page script, plus the persisted theme attributes and interface
|
||||||
selector `app-root` (index.html hosts only that tag). `openSecondaryWindow` is
|
scale so the first paint is already right. `ui/src/main.ts` picks the root
|
||||||
the ONLY place that branches platform for secondary chrome: macOS
|
component from `APP_MODE`; all four roots use selector `app-root` (index.html
|
||||||
`hiddenInset` + `trafficLightOffset {x:12,y:13}` (the UI renders an
|
hosts only that tag). `chromeOptions()` is the ONLY place the native frame is
|
||||||
|
decided, spread by the main window and `openSecondaryWindow`: macOS `hiddenInset` +
|
||||||
|
`trafficLightOffset {x:12,y:4}` (2.x semantics — see gotchas; the UI renders an
|
||||||
`h-10 pl-24` drag-region header via `IS_MAC_CHROME`); other platforms keep the
|
`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.
|
native titlebar and a compact `h-9` non-drag header.
|
||||||
|
|
||||||
@@ -57,18 +68,22 @@ has **no `off()`** — `track()` closures capture only `(this, key)` so closed
|
|||||||
records can be garbage collected. A future "window follows window" grouping
|
records can be garbage collected. A future "window follows window" grouping
|
||||||
belongs here (`anchor.on('move')` + `setPosition`).
|
belongs here (`anchor.on('move')` + `setPosition`).
|
||||||
|
|
||||||
### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/editor-window/`)
|
### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/skins/shadlike/editor-window/`)
|
||||||
|
|
||||||
One window per file, key `sessionId+path` (sessionId, not fsKey: saves route
|
One window per file, key `sessionId+path` (sessionId, not fsKey: saves route
|
||||||
through a concrete session, and `local` is one shared session anyway).
|
through a concrete session, and `local` is one shared session anyway).
|
||||||
Re-opening focuses the existing window. The webview fetches its own content
|
Re-opening focuses the existing window. The webview fetches its own content
|
||||||
(`readFile`) — `openEditor` just creates the window with ctx in the preload.
|
(`readFile`) — `openEditor` just creates the window with ctx in the preload.
|
||||||
|
|
||||||
**Close safety** (electrobun window `close` is NOT cancellable — verified, no
|
**Close safety** (designed when electrobun window `close` was NOT cancellable —
|
||||||
`allow` response like `before-quit` has):
|
1.18.1 had no `allow` response like `before-quit`; 2.x adds a cancellable
|
||||||
1. ⌘W (routed via the app menu → `editorCloseRequest`) / Close button →
|
`will-close` for the titlebar close, not used yet — see gotchas):
|
||||||
styled in-window Save/Don't Save/Cancel; the confirm path calls
|
1. ⌘W (the page's own `close-window` key; a ⌘W it doesn't claim falls through
|
||||||
`editorClose` which sets `suppressPrompt` before `win.close()`.
|
to the app menu, which bun routes back as `editorCloseRequest`) / Close
|
||||||
|
button → styled in-window Save/Don't Save/Cancel, which takes focus (Save;
|
||||||
|
⌘S = Save, Esc = Cancel, Tab cycles) so keys can't edit the text behind it;
|
||||||
|
the confirm path calls `editorClose` which sets `suppressPrompt` before
|
||||||
|
`win.close()`.
|
||||||
2. Traffic-light close → the window is already gone, but the editor mirrored
|
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)
|
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)
|
→ native Save/Discard box from the held draft. A failed save (dead session)
|
||||||
@@ -84,12 +99,18 @@ to the main window (panels showing that directory refresh). Editor upgrades:
|
|||||||
scroll-synced line gutter (hidden when wrap is on — wrapped line numbers would
|
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
|
need per-edit height measurement), Alt/⌥Z wrap toggle, custom find bar
|
||||||
(matches computed in TS, `setSelectionRange` + scrollTop math — deliberately
|
(matches computed in TS, `setSelectionRange` + scrollTop math — deliberately
|
||||||
not `findInPage`, which is a shim on WebView2).
|
not `findInPage`, which is a shim on WebView2). Find next / previous (⌘G /
|
||||||
|
⇧⌘G, F3 / ⇧F3) step from the caret with the bar open or closed — the query
|
||||||
|
survives closing it. Focus after a state change goes through
|
||||||
|
`afterNextRender({ injector })`: under zoneless CD a microtask runs BEFORE the
|
||||||
|
render that creates the `@if` element.
|
||||||
|
|
||||||
### Connection-manager window (`src/bun/manager-window.ts`)
|
### Connection-manager window (`src/bun/manager-window.ts`)
|
||||||
|
|
||||||
Single instance, centered over main, **modal to main only** (editors stay
|
Single instance, centered over main, **modal to main only** (editors stay
|
||||||
usable). No native modality exists, so three layers approximate it: bun
|
usable). No native modality exists (re-checked on 2.0.2: no parent/owner/sheet
|
||||||
|
option or input lock in any SDK or the core's exports; `styleMask.DocModalWindow`
|
||||||
|
is only the cosmetic NSWindow bit), so three layers approximate it: bun
|
||||||
bounces main's `focus` event back to the manager; the main webview renders a
|
bounces main's `focus` event back to the manager; the main webview renders a
|
||||||
click-swallowing overlay (`managerState` message); main drops its keyboard
|
click-swallowing overlay (`managerState` message); main drops its keyboard
|
||||||
shortcuts and bun drops main-targeted menu actions while open. Connect /
|
shortcuts and bun drops main-targeted menu actions while open. Connect /
|
||||||
@@ -100,6 +121,52 @@ main's `ConnectionsService` reloads; a reload-retry in `App.findConnection`
|
|||||||
covers the save→connect race. `setAlwaysOnTop` is deliberately NOT used (it
|
covers the save→connect race. `setAlwaysOnTop` is deliberately NOT used (it
|
||||||
would float over editor windows).
|
would float over editor windows).
|
||||||
|
|
||||||
|
Keyboard (the `manager` keymap scope): ⌘N new connection (focus lands in
|
||||||
|
Name), ⇧⌘N new stack, ⌘S save, ⌘↩ connect — or, on a focused stack row, open
|
||||||
|
it in tabs — ⌘⌫ / Del delete the focused row (with a confirm), F2 rename the
|
||||||
|
focused stack, ⌘O import, ⌘W close. The list's rows are real buttons
|
||||||
|
(`data-row`; `data-stack` / `data-conn` say what they are): ↑ ↓ Home End move
|
||||||
|
FOCUS only — loading a connection would replace a half-edited form without
|
||||||
|
asking — Enter opens the focused connection in the form, ← / → collapse and
|
||||||
|
expand a stack (← on a connection climbs to its stack), and a stack's
|
||||||
|
hover-only actions also show while the keyboard is in its row. Enter in a form
|
||||||
|
field = Connect; Esc leaves a field first, then closes the window. Focus
|
||||||
|
returns where it was after a dialog (`focusin` memory + the FocusService
|
||||||
|
tick) and falls back to the edited connection's row whenever it drops to
|
||||||
|
`<body>`.
|
||||||
|
|
||||||
|
### Settings window (`src/bun/settings-window.ts` + `ui/src/app/skins/shadlike/settings-window/`)
|
||||||
|
|
||||||
|
Single instance, centered over main, **not modal**: every control is a plain
|
||||||
|
`SettingsService` setter, so each edit is an ordinary save that bun
|
||||||
|
rebroadcasts — the other windows follow live while it stays open. Opened by
|
||||||
|
the tab-bar gear and the open-settings key (⌘, / Ctrl+,, `openSettings` from
|
||||||
|
main) and, on macOS, the app menu's "Settings…". Its RPC serves only
|
||||||
|
`getSettings`/`saveSettings`/`pickEditorApp`/`showAbout`/`settingsClose`. Its
|
||||||
|
page claims its own close-window key (`settingsClose`); a ⌘W it doesn't claim
|
||||||
|
falls through to the app menu's Close Tab, and bun checks the Settings
|
||||||
|
window's focus FIRST and closes it (see gotchas). Sidebar sections (↑ / ↓ move
|
||||||
|
between them, focus starts on the current one, Tab is walked by `cycleFocus`):
|
||||||
|
Appearance, Transfers, Editor, File list (visible columns — the column-header
|
||||||
|
right-click menu toggles the same setting — and "Enter / double-click on a
|
||||||
|
file": nothing | open local files in their default app), Keyboard, About
|
||||||
|
(opens the native About box). The native title is `options.title`, retitled on
|
||||||
|
a locale switch.
|
||||||
|
|
||||||
|
**Keyboard** is the remap editor over the shared keymap: rows by group with a
|
||||||
|
search field (label or key text), a chip per combo (× removes), **+** records
|
||||||
|
(the next non-modifier keydown, taken before every other key in the window so
|
||||||
|
⌘W records instead of closing; Esc cancels; a click elsewhere or a blur ends
|
||||||
|
it), ↺ resets a row, Reset all (inline confirm). Reserved combos are refused
|
||||||
|
without `preventDefault` (⌘Q still quits); editor-scope combos must be chords
|
||||||
|
or F-keys (the editor's text area always has focus); a combo owned by an
|
||||||
|
overlapping-scope command offers **Replace** (moves it). Rows flag conflicts
|
||||||
|
and plain character keys on file-list rows (they no longer start a filter).
|
||||||
|
Fixed keys are listed read-only below. Every edit is `ShortcutsService.rebind`
|
||||||
|
→ `settings.keybindings` (only rows that differ from the defaults) → the
|
||||||
|
ordinary save + broadcast, so menus, tooltips and the macOS menu bar follow
|
||||||
|
live.
|
||||||
|
|
||||||
## Bun modules
|
## Bun modules
|
||||||
|
|
||||||
- `fs/provider.ts` — `FileSystemProvider` interface: uniform local/SFTP ops +
|
- `fs/provider.ts` — `FileSystemProvider` interface: uniform local/SFTP ops +
|
||||||
@@ -114,6 +181,12 @@ would float over editor windows).
|
|||||||
callback. mtime seconds→ms; mode masked `& 0o7777`.
|
callback. mtime seconds→ms; mode masked `& 0o7777`.
|
||||||
- `fs/registry.ts` — sessionId → provider map; `"local"` is built-in;
|
- `fs/registry.ts` — sessionId → provider map; `"local"` is built-in;
|
||||||
`add()` mints `sftp-<n>`; `drop()` (session died) vs `remove()` (clean).
|
`add()` mints `sftp-<n>`; `drop()` (session died) vs `remove()` (clean).
|
||||||
|
- `fs/watcher.ts` — `LocalDirWatcher`: `fs.watch` handles keyed by dir,
|
||||||
|
debounced ~150 ms (watch fires bursts). Drives local auto-refresh: the main
|
||||||
|
webview sends `setWatchedDirs` (the active tab's local panel cwds); a change
|
||||||
|
fires `onChange` → `rpc.send.pathRefresh({sessionId:"local", dir})`.
|
||||||
|
`onChange` is late-bound in index.ts (the `events` pattern). SFTP has no
|
||||||
|
watch, so remote panels are polled UI-side instead (see panels.service).
|
||||||
- `transfers/engine.ts` — FIFO job queue. `Semaphore` with live resize
|
- `transfers/engine.ts` — FIFO job queue. `Semaphore` with live resize
|
||||||
(`wake()` reserves slots synchronously — an async waiter increment was a
|
(`wake()` reserves slots synchronously — an async waiter increment was a
|
||||||
race). Per-job worker pool (`MAX_WORKERS_PER_JOB=8`) draws from the shared
|
race). Per-job worker pool (`MAX_WORKERS_PER_JOB=8`) draws from the shared
|
||||||
@@ -142,13 +215,25 @@ would float over editor windows).
|
|||||||
- `store/connections.ts` — plaintext-passwords JSON (deliberate v1 choice),
|
- `store/connections.ts` — plaintext-passwords JSON (deliberate v1 choice),
|
||||||
atomic tmp+rename, 0600 + win32 `icacls` ACL. `store/settings.ts` — defaults
|
atomic tmp+rename, 0600 + win32 `icacls` ACL. `store/settings.ts` — defaults
|
||||||
spread-merge on load, so NEW SETTINGS KEYS need no backend change.
|
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
|
- `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
|
Connect submenu mirrors the store), language switch and keyboard remap.
|
||||||
routes `close-tab` to the focused editor window first.
|
**Accelerators come from the shared keymap** (`effectiveKeymap` +
|
||||||
|
`toAccelerator`, the first combo the native parser can express faithfully:
|
||||||
|
⌘/⌃ chords only — a bare key means ⌘+key to it — no ⌫ / ⌦ / Shift+punctuation);
|
||||||
|
⌘Q stays fixed. The keybindings are read through a getter AFTER the
|
||||||
|
connections load, so a remap saved meanwhile isn't lost. The page claims the
|
||||||
|
keys it handles first, so the menu only runs what falls through; a
|
||||||
|
`close-tab` that still arrives while a Settings or editor window is focused
|
||||||
|
closes THAT window, and other main-targeted actions bring the main window
|
||||||
|
forward first. Labels go through
|
||||||
|
backend `i18n.t` and the menu is rebuilt on a locale change too.
|
||||||
|
- `i18n.ts` — backend translator (native menus + thrown error messages, which
|
||||||
|
no Angular service can reach). `setLocale(setting)` caches the resolved
|
||||||
|
locale (from `settings.locale`, `system` via `Intl…resolvedOptions().locale`);
|
||||||
|
`t(key, params)` shares the same `src/shared/i18n` dictionaries as the UI.
|
||||||
|
Wired in index.ts on boot and on `settingsChanged` (relocalizes + rebuilds
|
||||||
|
the app menu). The context menu no longer needs this — it is Angular and reads
|
||||||
|
`I18nService` directly.
|
||||||
- `tray.ts` — static icon, click = activate main. macOS template image;
|
- `tray.ts` — static icon, click = activate main. macOS template image;
|
||||||
Windows colored icon. No `setImage` calls ever (they lose the template
|
Windows colored icon. No `setImage` calls ever (they lose the template
|
||||||
treatment) and no menu (clicks arrive directly).
|
treatment) and no menu (clicks arrive directly).
|
||||||
@@ -156,14 +241,33 @@ would float over editor windows).
|
|||||||
rpc; manager/editors constructed after win; quit guard; focus tracking for
|
rpc; manager/editors constructed after win; quit guard; focus tracking for
|
||||||
notification suppression across all windows).
|
notification suppression across all windows).
|
||||||
|
|
||||||
## UI services (`ui/src/app/`)
|
## UI layout
|
||||||
|
|
||||||
|
```
|
||||||
|
ui/src/app/
|
||||||
|
core/ app.config.ts, icons.ts, platform.ts, format.ts, file-icons.ts,
|
||||||
|
metrics.ts and every *.service.ts — the non-visual layer
|
||||||
|
skins/shadlike/ the component tree: four window roots (SHAD_ROOTS barrel,
|
||||||
|
picked by main.ts), dialogs, context menu
|
||||||
|
```
|
||||||
|
|
||||||
|
Components reach `core/` through the **`@core/*`** tsconfig alias (alongside
|
||||||
|
`@shared/*` → `src/shared`), which is deliberate: components sit at varying
|
||||||
|
depths and an alias keeps their imports identical wherever a file moves. Do not
|
||||||
|
replace it with relative paths.
|
||||||
|
|
||||||
|
**Where a new rule goes**: behaviour → `core/`; markup and appearance → the
|
||||||
|
component; tokens → `ui/src/styles.css`.
|
||||||
|
|
||||||
|
## UI services (`ui/src/app/core/`)
|
||||||
|
|
||||||
- `rpc.service.ts` — wraps `Electroview.defineRPC`. **Push messages fan out
|
- `rpc.service.ts` — wraps `Electroview.defineRPC`. **Push messages fan out
|
||||||
through plain callback registries, never signals**: a signal consumed from
|
through plain callback registries, never signals**: a signal consumed from
|
||||||
an effect that also writes signals re-triggers itself (this froze the whole
|
an effect that also writes signals re-triggers itself (this froze the whole
|
||||||
app once), and signal coalescing drops progress snapshots. All requests are
|
app once), and signal coalescing drops progress snapshots. All requests are
|
||||||
gated on the socket-open promise — calls fired during boot would otherwise
|
gated on the socket-open promise (`electroview.hostSocket`) — on 1.18.1 calls
|
||||||
be silently lost. `request` and `send` are ready-gated Proxies.
|
fired during boot were silently lost; 2.x Electroview queues them itself, so
|
||||||
|
the gate is belt-and-braces now. `request` and `send` are ready-gated Proxies.
|
||||||
- `panels.service.ts` — `PanelState` (per panel): sessionId, fsKey, cwd,
|
- `panels.service.ts` — `PanelState` (per panel): sessionId, fsKey, cwd,
|
||||||
entries, sort, cursor, selection, type-to-filter. `sorted` computed:
|
entries, sort, cursor, selection, type-to-filter. `sorted` computed:
|
||||||
filter → dirs-first sort (name/size/mtime numeric, kind/rights/owner/group
|
filter → dirs-first sort (name/size/mtime numeric, kind/rights/owner/group
|
||||||
@@ -172,41 +276,256 @@ would float over editor windows).
|
|||||||
auto-load `localDir` into an untouched local panel). `connectToken`
|
auto-load `localDir` into an untouched local panel). `connectToken`
|
||||||
supersedes stale connects. `Workspace` = tab = left/right pair + activeSide.
|
supersedes stale connects. `Workspace` = tab = left/right pair + activeSide.
|
||||||
Remote goes RIGHT on connect/open-stack (commander convention); per-panel
|
Remote goes RIGHT on connect/open-stack (commander convention); per-panel
|
||||||
dropdown connects target their own panel.
|
dropdown connects target their own panel. **Auto-refresh** (always on):
|
||||||
|
`refresh({silent})` re-lists without the loading/busy flags and touches no
|
||||||
|
DOM (scroll/cursor/selection/filter preserved), snapshotting sessionId+cwd
|
||||||
|
to discard results if the user navigated mid-flight. Only the active tab is
|
||||||
|
live: its local panels are fs.watched (backend `setWatchedDirs` effect +
|
||||||
|
`pathRefresh` push → silent refresh), its remote panels polled every 3s
|
||||||
|
(`REMOTE_POLL_MS`); switching tabs silently refreshes the newly-active pair.
|
||||||
|
A future on/off setting is stubbed in commented breadcrumbs (types.ts,
|
||||||
|
settings.service, tab-bar).
|
||||||
- `transfers.service.ts` — jobMap from `transferUpdate` callbacks; EMA speed
|
- `transfers.service.ts` — jobMap from `transferUpdate` callbacks; EMA speed
|
||||||
(`SPEED_SMOOTHING=0.7`); `copyToOtherPanel` guards same-fs+same-dir before
|
(`SPEED_SMOOTHING=0.7`); `copyToOtherPanel` guards same-fs+same-dir before
|
||||||
enqueue and runs the collision flow (same-path auto-skip, type-mismatch
|
enqueue and runs the collision flow (same-path auto-skip, type-mismatch
|
||||||
skip, overwrite dialog with all/none batch modes).
|
skip, overwrite dialog with all/none batch modes).
|
||||||
- `settings.service.ts` — one signal per setting; **imperative apply pattern**
|
- `settings.service.ts` — one signal per setting; **imperative apply pattern**
|
||||||
(`applyTheme/DarkTone/Accent/TextSize` set `data-*` attributes on
|
(`applyTheme/DarkTone` set `data-*` attributes on `<html>`;
|
||||||
`<html>`); `applyAll` also serves the `settingsChanged` broadcast so editor
|
`applyUiScale` sets its inline `font-size`); `applyAll` also serves the
|
||||||
and manager windows restyle live (they render no settings UI, so no
|
`settingsChanged` broadcast, so every window follows a save made in any
|
||||||
persist echo). New setting = five touches here + types.ts (backend merges
|
other. Every window RPC serves `saveSettings` (editor and manager windows
|
||||||
automatically).
|
save too — the scale shortcuts — and the Settings window is nothing but
|
||||||
|
saves); bun's `settingsSaved()` in index.ts runs the
|
||||||
|
side effects once and rebroadcasts to every window **except the sender**, so
|
||||||
|
there is no echo to fight a held Cmd+. `applyAll` never persists. Saves are
|
||||||
|
whole-object, so **nothing persists until the first `getSettings` landed**
|
||||||
|
(`loaded`): a ⌘+ pressed while a window opens would otherwise write the
|
||||||
|
defaults over every remap. New setting = five touches here + types.ts
|
||||||
|
(backend merges automatically; sanitize hand-editable shapes in
|
||||||
|
`SettingsStore.load`).
|
||||||
- `focus.service.ts` — dependency-free `tick`/`bump()`; dialogs bump it, the
|
- `focus.service.ts` — dependency-free `tick`/`bump()`; dialogs bump it, the
|
||||||
main window's panels listen. Exists so dialogs don't inject PanelsService
|
main window's panels listen — but a panel only takes focus while NO dialog or
|
||||||
(whose constructor spawns a workspace — fatal in secondary windows).
|
menu is up (a run of overwrite questions reuses one dialog instance; focusing
|
||||||
|
the list behind it handed Enter/Delete to the panel). Exists so dialogs don't
|
||||||
|
inject PanelsService (whose constructor spawns a workspace — fatal in
|
||||||
|
secondary windows).
|
||||||
|
- `shortcuts.service.ts` — the keyboard layer over `src/shared/keymap.ts`; see
|
||||||
|
"Keyboard" below.
|
||||||
|
- `focus-cycle.ts` — `cycleFocus(root, event)`: Tab / ⇧Tab by hand (see
|
||||||
|
"Keyboard").
|
||||||
- `connections.service.ts` — store mirror + ssh-config hosts; reloads on the
|
- `connections.service.ts` — store mirror + ssh-config hosts; reloads on the
|
||||||
`connectionsChanged` push.
|
`connectionsChanged` push.
|
||||||
- `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always
|
- `i18n.service.ts` — runtime translator. `locale` signal + `t('ns.key', params)`
|
||||||
against an explicit panel; `menuTarget` remembers the right-clicked row.
|
(dotted lookup, `{param}` interpolation, `{one,other}` plural maps via
|
||||||
|
`Intl.PluralRules`); missing keys fall back to English then the raw key.
|
||||||
|
**Reactive by design**: `t()` reads the `locale` signal, so every template
|
||||||
|
binding that calls it re-renders live on a language switch (call-stack
|
||||||
|
tracking, see gotchas.md) — no pipe. Also exposes locale-aware `date()`/`size()`
|
||||||
|
/`speed()` so the file list reformats on switch. `SettingsService.applyLocale`
|
||||||
|
resolves the `locale` setting (`system` → `navigator.language`) into it, on
|
||||||
|
boot and on the `settingsChanged` broadcast (so editor/manager windows follow).
|
||||||
|
- `file-actions.service.ts` — **one file-command dispatcher**,
|
||||||
|
`run(id, { side, panel, entry })`, for every entry point: list keys (the
|
||||||
|
cursor row), row-menu picks (the right-clicked row, captured in the menu's
|
||||||
|
`onPick` closure), the macOS menu bar's File/View items and the panel
|
||||||
|
toolbar. Ids are keymap `FileCommand`s (`open`, `open-default`, `send`,
|
||||||
|
`edit`, `rename`, `new-folder`, `new-file`, `chmod`, `delete`, `trash`,
|
||||||
|
`copy-path`, `reveal`, `refresh`, `sort-*`). Selection if any, else the
|
||||||
|
target row; `..` stands for the folder. `open` enters folders and opens a
|
||||||
|
LOCAL file in its default app only when `openFilesOnEnter` is set (Settings
|
||||||
|
→ File list); `open-default` (the menu's Open on a local file) always does.
|
||||||
|
`trash` walks the selection one `moveToTrash` at a time. Main-window only
|
||||||
|
(it injects TransfersService).
|
||||||
|
- `context-menu.service.ts` + `skins/shadlike/context-menu.ts` — the panel and tab right-click
|
||||||
|
menus, in HTML. Electrobun's native `showContextMenu` is a no-op on Linux, so it was
|
||||||
|
retired on ALL platforms rather than kept as a second code path (see
|
||||||
|
platform-notes.md). The service builds the item list (row / empty-space menu,
|
||||||
|
the header column picker, and the panel **source picker** — Local, drives,
|
||||||
|
connections under per-stack `header` items, `~/.ssh/config` hosts — which
|
||||||
|
replaced a bespoke popover so it gets the same keys and look) and holds the
|
||||||
|
open request as a signal; the component renders it at a point
|
||||||
|
(`MenuPoint`: a MouseEvent, or a row rect for a keyboard-opened menu) with
|
||||||
|
edge-flip clamping, a scrolling clamped box, and close-on-blur/resize/scroll.
|
||||||
|
Items carry `shortcut` (a keymap id: its first key is the soft hint at 60%
|
||||||
|
of the row colour, and pressing it while the menu is open picks the item)
|
||||||
|
and `icon` (drawn in the check column; only the everyday verbs get one —
|
||||||
|
Send, Edit, Rename, Delete, New Folder, New/Close Tab, the toolbar's own
|
||||||
|
glyphs). Keys: ↑↓ Home End move the one highlight (mouse hover shares it),
|
||||||
|
Enter/Space pick, Esc closes, items are not Tab stops; every handled key
|
||||||
|
stops propagation so the window handler never runs it twice. A keyboard-opened
|
||||||
|
menu starts with its first item highlighted (`initialIndex`). A request's
|
||||||
|
`onPick` takes the pick (row menu → `FileActionsService.run`, source picker →
|
||||||
|
`FilePanel`); otherwise `col:*` / `tab:*` strings go through
|
||||||
|
`RpcService.emitMenuAction` to the SAME `App.handleMenuAction` dispatcher the
|
||||||
|
macOS app menubar uses. The three shell actions the webview cannot perform
|
||||||
|
(reveal, open-with-default, copy path) go back to bun via the
|
||||||
|
`shellItemAction` RPC, which is all that remains of the old native menu.
|
||||||
|
`tabItems()` builds the workspace-tab menu (`tab:new`, and
|
||||||
|
`tab:close|close-others|close-right|disconnect:<id>`), served by
|
||||||
|
`PanelsService.closeOtherWorkspaces` / `closeWorkspacesToRight` /
|
||||||
|
`disconnectWorkspace`. **The target id travels inside the action string**: the
|
||||||
|
right-clicked tab is not necessarily the active one, so nothing on that path
|
||||||
|
may fall back to `activeWorkspaceId`. Illegal entries are `disabled`, not
|
||||||
|
omitted — a menu whose items move between right-clicks is worse than one with
|
||||||
|
a dead row. The disabled styling (`pointer-events: none`) only stops the
|
||||||
|
pointer; the keyboard path never consults it — so `pick()` and the arrow-key
|
||||||
|
walker check the flag themselves.
|
||||||
- `file-icons.ts` — extension → category map driving glyphs, colors
|
- `file-icons.ts` — extension → category map driving glyphs, colors
|
||||||
(fixed `--icon-*` tokens, never accent-tinted) and the Kind column.
|
(fixed `--icon-*` tokens from Oxide's hue scales) and the Kind column.
|
||||||
|
- `icons.ts` — the one icon registry, passed to `provideIcons` in `app.config.ts`.
|
||||||
|
A name is `<icon><px>`: the pixel grid its glyph is drawn for, which must match
|
||||||
|
the size token where it renders (`server14` at `--icon-size-sm`, `server16` at
|
||||||
|
`-md`, `server24` at `-xl`). The one exception is `tdesignLoading`, which spins.
|
||||||
|
Every glyph is TDesign's shape redrawn on its pixel grid in `pixel-icons.ts`,
|
||||||
|
as whole-pixel runs plus 1.25px slants (rules in gotchas). Only `closeCircle24`
|
||||||
|
(TDesign's own 24px grid) and the spinner still come from
|
||||||
|
`@ng-icons/tdesign-icons`. Templates can't type-check
|
||||||
|
icon names, and an unregistered one renders nothing (a console warning only), so
|
||||||
|
`icons.spec.ts` checks every `<ng-icon>` in the skins. TS code that picks an icon
|
||||||
|
takes the `Icon14` / `Icon16` types.
|
||||||
- `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`,
|
- `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`,
|
||||||
`EDITOR_CONTEXT`, `IS_MAC_CHROME`, `IS_WINDOWS`, `MOD_LABEL`/`ALT_LABEL`)
|
`EDITOR_CONTEXT`, `IS_MAC`/`IS_MAC_CHROME`, `IS_WINDOWS`, `IS_LINUX`) +
|
||||||
+ `blockNativeContextMenu` (kills WebKit's Reload/Inspect menu outside
|
`blockNativeContextMenu` (kills WebKit's Reload/Inspect menu outside text
|
||||||
text fields).
|
fields — the app draws its own menu instead) + `blockReloadKeys` (swallows
|
||||||
|
unclaimed F5 / Ctrl+R / Ctrl+F5 / Ctrl+Shift+R on Windows, where WebView2
|
||||||
|
would reload the page and drop every session; each window root calls it
|
||||||
|
last).
|
||||||
|
- `updater.service.ts` — auto-updater state for the tab-bar pill. Signals
|
||||||
|
`state` (`idle→available→downloading→ready`|`error`), `version`, `progress`.
|
||||||
|
Subscribes the `updateStatus` push (backend `Updater.checkForUpdate` on
|
||||||
|
launch+6h, `onStatusChange` progress); `ready` is derived when the
|
||||||
|
`downloadUpdate` request resolves ok (not a pushed phase). `download()` /
|
||||||
|
`apply()` (relaunch) / `check()` (menu). Backend wiring lives in
|
||||||
|
`src/bun/index.ts`; feed is `release.baseUrl` (`https://miniscp.com/dl`, the
|
||||||
|
`/dl` host in `site/`). See build-and-release.md "Auto-updater".
|
||||||
|
|
||||||
|
## Keyboard
|
||||||
|
|
||||||
|
**One keymap** — `src/shared/keymap.ts`, shared so the webviews and bun (the
|
||||||
|
macOS menu bar) read the same table. `BINDINGS` lists every command: `id`,
|
||||||
|
`group` (Settings sections), `scopes`, and default combos per platform
|
||||||
|
(`mac` / `other`, best first: the first is the hint menus and tooltips show).
|
||||||
|
Combos are platform-neutral strings (`Mod+Shift+N`: Mod = ⌘ on macOS, Ctrl
|
||||||
|
elsewhere; `Ctrl` a literal ⌃ only macOS tells apart). Letters match the
|
||||||
|
layout's character (AZERTY's A is A) and fall back to the physical key under
|
||||||
|
Alt (⌥Z types Ω) and on non-Latin layouts; digits match physically (AZERTY
|
||||||
|
types "&" on 1); punctuation by character first (AZERTY's comma is on the M
|
||||||
|
key), physical second (⇧] makes "}"). **Matching is exact**: ⌃F is not ⌘F,
|
||||||
|
Ctrl+Tab is not Tab, and neither the Windows key nor AltGr is ever part of a
|
||||||
|
binding. `effectiveKeymap(overrides)` applies `AppSettings.keybindings`
|
||||||
|
(rows that differ from the defaults; `[]` unbinds) and drops anything
|
||||||
|
malformed. `conflicts()` / `ownerOf()` work over overlapping scopes; the
|
||||||
|
spec (`keymap.spec.ts`) proves the defaults conflict-free, round-trippable and
|
||||||
|
unshadowed on both platforms.
|
||||||
|
|
||||||
|
**Scopes.** `panel` = the focused file list (navigation, file commands,
|
||||||
|
sorting); `main` = anywhere in the main window (tabs, window commands); `editor`
|
||||||
|
/ `manager` / `settings` per window; `any` = every window (interface scale).
|
||||||
|
`panel` sits inside `main`, so they conflict-check together.
|
||||||
|
|
||||||
|
**`ShortcutsService`** resolves keydowns against the effective keymap
|
||||||
|
(reactive: a remap applies live in every window via the settings broadcast) and
|
||||||
|
formats hints — `label(id)` / `labels(id)` / `tip(text, id)` /
|
||||||
|
`tipFixed(text, 'Shift+Enter')`, with **localized key names** off macOS
|
||||||
|
(`keys.names.*`: German says Strg+Entf). `claim()` = resolve + `preventDefault`
|
||||||
|
(so the macOS menu never runs it twice) + auto-repeat only for movement-like
|
||||||
|
commands (holding ⌘W closes one tab). IME composition is never a shortcut.
|
||||||
|
From a text field only `firesInTextField()` combos count: ⌘/Ctrl and Alt chords
|
||||||
|
and F-keys, but not plain keys, not text-editing chords (⌘⌫, ⌥←, ⌘A) and not
|
||||||
|
Ctrl+Alt off macOS (AltGr).
|
||||||
|
|
||||||
|
**Dispatch** (one place per window): an open context menu owns its keys; an
|
||||||
|
open dialog owns Enter / Esc / Tab while the main window swallows window
|
||||||
|
commands and the panel commands the macOS menu bar carries (an unclaimed ⌘T
|
||||||
|
would otherwise reach the menu and open a tab behind the dialog); the focused
|
||||||
|
list (`FilePanel.onKeydown`) runs, in order, the fixed filter-editing keys
|
||||||
|
while a query is typed, the `panel` keymap (Shift + a movement key extends the
|
||||||
|
selection over the rows passed — a fixed rule that follows remaps), lets
|
||||||
|
`main` matches bubble, then type-to-filter, then Esc's fixed chain (clear the
|
||||||
|
filter → cancel a connect → clear the selection); `App` runs `main`. Every root
|
||||||
|
ends with `blockReloadKeys`. Keys that land on `<body>` (a click dropped focus)
|
||||||
|
are forwarded to the active list (`PanelsService.forwardListKey`), and `App`'s
|
||||||
|
`(mousedown)` keeps focus on the list after chrome-button clicks.
|
||||||
|
|
||||||
|
**Remaps** live in `AppSettings.keybindings` (sanitized on load), edited in
|
||||||
|
Settings → Keyboard (see the Settings window). `ShortcutsService.rebind`
|
||||||
|
stores only rows that differ from their defaults.
|
||||||
|
|
||||||
|
**macOS key equivalents are page-first.** WKWebView hands ⌘-keys to the page
|
||||||
|
before the app menu (WebKit `WebViewImpl::performKeyEquivalent`); only keys the
|
||||||
|
page doesn't `preventDefault` reach the menu bar. So the page owns every app
|
||||||
|
shortcut, and a window that doesn't claim a key lets it fall through to the
|
||||||
|
menu (⌘W in a window whose close-window was remapped away still hits Close Tab,
|
||||||
|
which bun routes to the focused Settings / editor window first).
|
||||||
|
|
||||||
|
**Focus without Keyboard navigation.** WKWebView's Tab skips buttons,
|
||||||
|
checkboxes and radios unless the user turned on System Settings → Keyboard →
|
||||||
|
Keyboard navigation, so `cycleFocus(root, event)` walks Tab / ⇧Tab by hand
|
||||||
|
(DOM order, wrap, a radio group is one stop): every dialog (it is also their
|
||||||
|
focus trap), the transfer list (⌘J opens it focused), the editor's save prompt.
|
||||||
|
Dialogs carry `role="dialog" aria-modal`, take initial focus (the chmod dialog
|
||||||
|
on its octal field, Enter applies) and stop Esc from propagating — a
|
||||||
|
window-level Esc (the connection manager closes on it) must not see it too. A
|
||||||
|
base-layer `:focus-visible` ring in `--ring` marks the focused control.
|
||||||
|
|
||||||
|
**Opening a row's menu from the keyboard**: ⇧F10 / the Menu key / ⌃↩ (macOS)
|
||||||
|
open the cursor row's menu under the row, first item highlighted; the
|
||||||
|
selection is kept, its commands act on it like the F-keys do.
|
||||||
|
|
||||||
## Theming
|
## Theming
|
||||||
|
|
||||||
`ui/src/styles.css`: shadcn-style OKLCH tokens; dark values are duplicated in
|
`ui/src/styles.css`: OKLCH tokens adapted from **Oxide's design system**
|
||||||
`:root[data-theme='dark']` AND a `prefers-color-scheme` media block (system
|
(docs.oxide.computer: a cool neutral scale at hue 260 with almost no chroma,
|
||||||
mode) — every dark token change goes in BOTH. Accent tones: one
|
one green accent, opaque strokes; the comments carry Oxide's step names
|
||||||
`--tone-h/--tone-c` line per tone + three formula blocks derive `--primary`,
|
n0…n1300 / green-800). Dark values are duplicated in `:root[data-theme='dark']`
|
||||||
`--ring`, `--cursor-tint`; graphite = attribute removed = pristine zinc.
|
AND a `prefers-color-scheme` media block (system mode) — every dark token
|
||||||
|
change goes in BOTH; retone neutrals along the one scale, or warm and cool
|
||||||
|
surfaces clash. **There is no accent setting**: `--primary` is Oxide green per
|
||||||
|
theme (green-800 dark, green-500 light), so selection (`bg-primary/15`), the
|
||||||
|
cursor row (`--cursor-tint`, a `color-mix` of `--primary`), focus rings
|
||||||
|
(green-600) and `--success` all read green. **Surface layers**, each its own
|
||||||
|
shade: `--chrome` (frame — tab/transfer/status bars, the editor/manager/settings
|
||||||
|
header+footer strips and sidebars), the file panel's header strip **stepping
|
||||||
|
toward the list** — session strip `--toolbar`, path bar `--toolbar-2`, column
|
||||||
|
headers `--toolbar-3` (`color-mix` steps declared once in `:root`, so every
|
||||||
|
theme follows) — then `--background` (file lists, editor text, forms;
|
||||||
|
`--row-alt` stripes odd file rows, skipped on the cursor/selected row so two
|
||||||
|
`bg-*` utilities never fight) and `--card` (popovers, dialogs, toasts). The
|
||||||
|
right-click menu is the exception: the darkest surface (`bg-chrome`) behind a
|
||||||
|
faint `border-border/60`, and its highlighted item takes Oxide's selected style —
|
||||||
|
`--primary-surface` fill (Oxide surface-accent: green-200 dark, green-1200
|
||||||
|
light) with `text-primary`; mouse and keyboard share that one highlight
|
||||||
|
(`activeIndex`). The
|
||||||
|
ACTIVE panel is marked by a 0.125rem `--primary` line along its top edge (an
|
||||||
|
inset box-shadow on the session strip, so switching panels never shifts
|
||||||
|
layout). Dark: frame 0.162 < list 0.195 < header 0.275 → 0.248 → 0.221 < card
|
||||||
|
0.29; light steps greys out from near-white content: frame 0.925 < header 0.95
|
||||||
|
→ 0.965 → 0.98 < list = card 0.995. **`--accent` is translucent** (n0 at 5%
|
||||||
|
light, white 7% dark): hover/selected states sit on every layer, and an opaque
|
||||||
|
hover vanishes on a surface of its own lightness. Strokes are opaque
|
||||||
|
(`--border` n300 dark / n900 light, `--input` one step stronger). The
|
||||||
|
"darker" tone is true-black AMOLED: chrome, background, toolbar (+ its steps)
|
||||||
|
and row-alt are all `#000` (the layers collapse on purpose); elevation
|
||||||
|
survives only via `--card`/`--muted`/`--accent` (opaque there). **Square
|
||||||
|
design**: no `rounded-*` utilities and no radius tokens; an `@layer base`
|
||||||
|
block restyles native controls to match — checkboxes/radios (`appearance:
|
||||||
|
none`, a drawn check / square dot), selects (a `currentColor` caret; use
|
||||||
|
`pl-*`, never `px-*`, on a select), hidden number spinners, and square
|
||||||
|
`::-webkit-scrollbar`s (which make macOS scrollbars always visible).
|
||||||
`@theme inline` maps tokens to Tailwind utilities (`bg-primary`,
|
`@theme inline` maps tokens to Tailwind utilities (`bg-primary`,
|
||||||
`text-icon-folder`, `bg-cursor-tint`). Selected rows are `bg-primary/15`
|
`text-icon-folder`, `bg-toolbar-2`, `bg-cursor-tint`). File-type icon colors
|
||||||
(follow the accent); the cursor row uses `--cursor-tint` so graphite stays
|
come from Oxide's hue scales (800 steps dark, 600 light). **Icon sizes** follow
|
||||||
pixel-identical to the old neutral hover. Text-size levels set `--row-h`,
|
TDesign's rule (an icon is 2px larger than its text, in whole pixels) through
|
||||||
`--list-font` etc. per `data-text` attribute, consumed via `h-(--row-h)` /
|
four rem tokens, and every `<ng-icon>` takes one: `--icon-size-sm` 14px (beside
|
||||||
`text-(length:--list-font)` arbitrary utilities.
|
11–12px text, small icon buttons), `-md` 16px (13–14px text: file rows, window
|
||||||
|
titles; the path-bar buttons), `-lg` 20px and `-xl` 24px (lone spinners and
|
||||||
|
status marks). The glyphs are drawn on the sm, md and xl grids (`icons.ts`).
|
||||||
|
**Interface scale** (`settings.uiScale`,
|
||||||
|
0.5–4 in 0.1 steps): one root font-size lever — `applyUiScale` sets
|
||||||
|
`html { font-size: 16px * scale }`, and since EVERY size in the app is rem
|
||||||
|
(panel tokens like `--row-h`, all Tailwind utilities, ng-icon sizes), the
|
||||||
|
whole UI scales, chrome included.
|
||||||
|
Driven by the Settings window's stepper, Ctrl/Cmd +/−/0, and preload-baked for a
|
||||||
|
flash-free first paint. It replaced the 3-tier `data-text` axis; the store
|
||||||
|
migrates old `textSize` values. TS that measures rem-sized CSS converts through
|
||||||
|
`@core/metrics` `remPx()` — never an assumed 16.
|
||||||
|
|||||||
@@ -10,62 +10,163 @@ electrobun}`). It is regenerated by `postinstall` and at the start of every
|
|||||||
records, hence gitignored. Fresh checkouts fail `tsc` until `bun install` has
|
records, hence gitignored. Fresh checkouts fail `tsc` until `bun install` has
|
||||||
run once (postinstall creates the file). `BRANDING.version` stays
|
run once (postinstall creates the file). `BRANDING.version` stays
|
||||||
semver-shaped (`0.1.<n>`) for electrobun's config/updater; `BRANDING.buildLabel`
|
semver-shaped (`0.1.<n>`) for electrobun's config/updater; `BRANDING.buildLabel`
|
||||||
("Build n") is what humans see (About box, options popover).
|
("Build n") is what humans see (About box, Settings → About).
|
||||||
|
|
||||||
## Dev loop
|
## Dev loop
|
||||||
|
|
||||||
- `bun run dev` — gen-version → scripts/dev.ts: starts `ng serve` (HMR) and
|
- `bun run dev` — gen-version → `electrobun prepare` → scripts/dev.ts: starts
|
||||||
`electrobun dev --watch`, with `DEV_SERVER_URL` pointing the webviews at the
|
`ng serve` (HMR) and `electrobun dev --watch`, with `DEV_SERVER_URL` pointing
|
||||||
dev server (only honored on the dev channel and only if the server answers).
|
the webviews at the dev server (only honored on the dev channel and only if
|
||||||
- `bun run build` — gen-version → Angular build → `electrobun build`
|
the server answers).
|
||||||
(**dev channel** — fine for boot checks, NOT a release).
|
- `bun run build` — gen-version → `electrobun prepare` → 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/)
|
- `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
|
fakes a Node version the Angular CLI accepts under Bun. Both preloads are
|
||||||
load-bearing.
|
load-bearing.
|
||||||
|
- **Electrobun 2.x is driven by Hutch.** The `electrobun` devDependency (exact
|
||||||
|
`2.0.2`) is only a bootstrap: its first command downloads the paired Hutch
|
||||||
|
(0.27.1) from the Electrobun GitHub release, checks its SHA-256 against
|
||||||
|
`hutch-artifacts.json`, caches it under `~/.hutch/npm/electrobun/<ver>/`, and
|
||||||
|
forwards every `electrobun …` to `hutch electrobun …`. Hutch then fetches the
|
||||||
|
Electrobun core, its build-time Cottontail and the pinned Bun 1.4.0 (the
|
||||||
|
runtime the app ships) into `~/.hutch` (~200 MB on a fresh machine) and
|
||||||
|
projects the SDK into the gitignored `.hutch/devkit/` (plus a gitignored
|
||||||
|
`.cottontail-tmp/` scratch dir). The devDependency version IS the toolchain
|
||||||
|
pin: moving Electrobun = bump it + `bun install`. No Node needed — the
|
||||||
|
bootstrap's `#!/usr/bin/env node` runs under `bunx`.
|
||||||
|
- `bunx electrobun prepare` (re)projects `.hutch/devkit`. It runs from
|
||||||
|
`postinstall` **and** at the start of `dev`/`start`/`build`/`release` (~0.5 s
|
||||||
|
when current — the slot the v1 CLI re-sign step held; that step is gone,
|
||||||
|
Hutch's binaries carry valid signatures). tsc (the root tsconfig extends
|
||||||
|
`.hutch/devkit/tsconfig.json`) and the Angular build / `ng serve` (the
|
||||||
|
`electrobun/view` path in `ui/tsconfig.json`) resolve the SDK there *before*
|
||||||
|
any electrobun command would project it; without it both fail on unresolved
|
||||||
|
`electrobun/*` imports. Bun stays the package manager: Hutch's own resolver
|
||||||
|
(`hutch.lock`, no lifecycle scripts) only runs on `hutch install`, which
|
||||||
|
nothing calls — hence no `hutch.config.ts`.
|
||||||
|
|
||||||
## Release builds
|
## Release builds
|
||||||
|
|
||||||
`bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64]` (default: host).
|
`bun scripts/release.ts [--target mac-arm64|win-x64|linux-x64]`
|
||||||
Steps: gen-version → gen-icons → Angular build → `electrobun build
|
(default: host).
|
||||||
--env=stable` → copy/rename artifacts into
|
Steps: gen-version → electrobun prepare → gen-icons → Angular build → empty
|
||||||
|
`artifacts/` → `electrobun build --env=stable` → copy/rename the installers into
|
||||||
`release/<target>/miniSCP-Build<n>-<target>.*`.
|
`release/<target>/miniSCP-Build<n>-<target>.*`.
|
||||||
|
|
||||||
Facts the script encodes:
|
Facts the script encodes:
|
||||||
|
|
||||||
- **`--env=stable` is mandatory for releases** — the CLI defaults to the dev
|
- **`--env=stable` is mandatory for releases** — `electrobun build` defaults to
|
||||||
channel (different app name `miniSCP-dev`, no distributable artifacts).
|
the dev channel (different app name `miniSCP-dev`, no distributable artifacts).
|
||||||
- **No OS cross-compile.** The CLI builds for its host (its `targets` config
|
- **No cross-compile, and no Intel Mac.** Hutch builds for its host OS *and*
|
||||||
field is unread in 1.18.1); packaging needs host tools (codesign/hdiutil on
|
arch only; packaging needs host tools (codesign/hdiutil on macOS,
|
||||||
macOS, rcedit/PowerShell on Windows). Hence one script run per artifact, on
|
rcedit/PowerShell on Windows). Hence one script run per artifact, on the
|
||||||
the matching machine; `win-x64` on a non-Windows host errors out.
|
matching machine; `win-x64` on a non-Windows host and `linux-x64` on a
|
||||||
- **mac-x64 from the arm64 Mac (Rosetta)**: the script downloads electrobun's
|
non-Linux host both error out. **Electrobun 2.x publishes no macOS x64 core
|
||||||
x64 CLI binary once (`electrobun-cli-darwin-x64.tar.gz` from the GitHub
|
(nor an x64 Hutch)**, so mac-x64 was dropped — the v1 trick of running the x64
|
||||||
release matching the installed version, cached at
|
CLI under Rosetta has nothing left to run. `--target mac-x64`, and any build
|
||||||
`~/.cache/miniscp/electrobun-cli-x64-<ver>/`) and runs it directly —
|
on an Intel Mac, fail with that message.
|
||||||
Rosetta translates it, the CLI sees `process.arch === "x64"`, downloads the
|
- **Runtime downloads**: on first use per Electrobun version Hutch fetches the
|
||||||
`dist-macos-x64` core into the electrobun package dir on first use, and
|
platform core from `github.com/blackboardsh/electrobun/releases`
|
||||||
packages a genuine x64 app. Note: Bun's `fetch`→`Bun.write` streaming hung
|
(SHA-256-checked against `electrobun-artifacts.json`), plus Cottontail and the
|
||||||
on the GitHub redirect chain; the script uses `curl -fsSL` deliberately.
|
pinned Bun, into `~/.hutch` — network is a build-time requirement on every
|
||||||
- **Core binary downloads**: the installed npm package ships mac-arm64
|
fresh machine/runner.
|
||||||
binaries only; other `dist-<os>-<arch>` cores are fetched from
|
- **`artifacts/` is emptied before the build** — `site/deploy.sh` publishes it
|
||||||
`github.com/blackboardsh/electrobun/releases` at build time — network to
|
verbatim, so it must hold exactly this build (stale files, e.g. v1-named
|
||||||
GitHub is a build-time requirement on every fresh machine/runner.
|
installers, would otherwise ship).
|
||||||
- **Artifact shapes** (stable channel, in `artifacts/`): macOS →
|
- **Artifact shapes** (stable channel, in `artifacts/`): 2.x installers carry
|
||||||
`stable-macos-<arch>-miniSCP.dmg` + `.app.tar.zst` + `update.json`
|
**no channel prefix**; only the updater feed keeps `stable-<os>-<arch>-` (the
|
||||||
(updater feed); Windows → `stable-win-x64-miniSCP-Setup.zip`
|
names v1.18.1+ clients request). macOS → `macos-arm64-miniSCP.dmg` +
|
||||||
(self-extracting installer → `%LOCALAPPDATA%`) plus the script's own
|
`stable-macos-arm64-miniSCP.app.tar.zst` + `stable-macos-arm64-update.json`
|
||||||
**portable zip** made from the assembled app folder under
|
(verified); Windows → `win-x64-miniSCP-Setup.zip` (setup exe + hidden payload)
|
||||||
`build/stable-win-x64/` (layout to confirm on the first real Windows build —
|
+ the `stable-win-x64-*` feed, plus the script's own **portable zip** of
|
||||||
the step warns instead of failing).
|
`build/stable-win-x64/miniSCP` (warns instead of failing if that folder is
|
||||||
|
missing); Linux → `linux-x64-miniSCP-Setup.tar.gz` (a self-extracting
|
||||||
|
`installer` + README) + the `stable-linux-x64-*` feed. The official Linux
|
||||||
|
installer IS the release artifact — the v1 portable tarball and its
|
||||||
|
`miniSCP.sh` NVIDIA wrapper are gone, so an installed build on NVIDIA needs
|
||||||
|
`WEBKIT_DISABLE_DMABUF_RENDERER=1` from the user (see gotchas.md). The
|
||||||
|
Windows/Linux names come from the 2.x docs — confirm on the first real 2.x
|
||||||
|
build there. A build that can reach the previous release at
|
||||||
|
`release.baseUrl` also emits `stable-<os>-<arch>-<previous-hash>.patch`.
|
||||||
|
- **Linux builds need the AppIndicator dev libraries present to RUN** (not to
|
||||||
|
build) — see platform-notes.md; an installed build started on a machine
|
||||||
|
without them dies at `dlopen`.
|
||||||
- The script filters artifacts by app name, so stale artifacts from older
|
- The script filters artifacts by app name, so stale artifacts from older
|
||||||
brandings don't get collected.
|
brandings don't get collected.
|
||||||
|
- **v1 → 2.x bridge**: Windows users on 0.1.78 (the only feed live on
|
||||||
|
miniscp.com/dl) update through their v1 updater because `app.name`,
|
||||||
|
`app.identifier` and `release.baseUrl` are unchanged, the build is
|
||||||
|
`--env=stable`, and `artifacts/` is published unrenamed. 2.x keeps the v1
|
||||||
|
top-level `update.json` fields (`version`/`hash`/`platform`/`arch`) and the
|
||||||
|
physical `stable` state root, so user data stays put. Unverified until the
|
||||||
|
first 2.x Windows release reaches a v1 install.
|
||||||
|
|
||||||
## CI (Gitea Actions)
|
## CI (Gitea Actions)
|
||||||
|
|
||||||
`.gitea/workflows/release.yml` — tag-triggered (`build-*`), macos + windows
|
`.gitea/workflows/release.yml` — tag-triggered (`build-*`), macos (mac-arm64) +
|
||||||
runner matrix, each running the same release script; inert until act_runners
|
windows (win-x64) runner matrix, each running the same release script; inert
|
||||||
with those labels are registered. Needs `fetch-depth: 0` (the build number is
|
until act_runners with those labels are registered. Needs `fetch-depth: 0` (the
|
||||||
the commit count). Runner prereqs are commented in the workflow file.
|
build number is the commit count); `bun install --frozen-lockfile` already
|
||||||
|
prepares the devkit (postinstall). Runner prereqs are commented in the workflow
|
||||||
|
file.
|
||||||
|
|
||||||
|
## Download host (`site/` + Caddy)
|
||||||
|
|
||||||
|
`site/` is a self-contained landing page **and** download host, served by Caddy on an
|
||||||
|
internal box (plain HTTP `:80`, no domain/TLS). Deploys to `/srv/miniscp/`.
|
||||||
|
|
||||||
|
- `site/index.html` — pure static, no JS, tinygrad-minimal. Download buttons link to **fixed
|
||||||
|
stable filenames** (`/dl/macos-arm64-miniSCP.dmg`, `/dl/win-x64-miniSCP-Setup.zip`, the portable
|
||||||
|
zip) so the page never changes per release. 2.x dropped the `stable-` prefix from installer
|
||||||
|
names (the feed kept it), so the v1 `stable-…-Setup.zip`/`.dmg` installers still on the server
|
||||||
|
are stale — delete them by hand. Logo/screenshot `<img>` slots are commented in, images live in
|
||||||
|
`site/assets/`.
|
||||||
|
- `site/Caddyfile` — one flat `/dl/*` folder with `file_server browse` + range requests, and
|
||||||
|
per-type `Cache-Control`: `*.patch` immutable (unique hashed names), `*update.json` `no-cache`
|
||||||
|
(updater must see new versions), installers (`*.dmg *.zip *.tar.zst *.tar.gz`) `max-age=300`
|
||||||
|
(stable names overwritten each release). Landing page has **no** SPA fallback → unknown paths 404.
|
||||||
|
Path matcher gotcha: use suffix globs like `*update.json`, not `*/*update.json`.
|
||||||
|
- **One flat folder, no per-build dirs** — this layout IS electrobun's `artifacts/` feed, so it
|
||||||
|
doubles as the updater `baseUrl`. Publishing = drop this machine's whole `artifacts/` into
|
||||||
|
`/dl/`, unrenamed; installers overwrite in place, `.patch` files accumulate (never delete them,
|
||||||
|
or older installs lose their incremental-update trail). **Tradeoff:** only the latest full
|
||||||
|
installer is downloadable; old full versions are overwritten.
|
||||||
|
- `site/deploy.sh` — run per build machine after `scripts/release.ts`; copies `artifacts/*`
|
||||||
|
verbatim and renames the build-numbered win portable zip to the stable
|
||||||
|
`stable-win-x64-miniSCP-portable.zip`. `MINISCP_DL_DIR` overrides the target (stage locally,
|
||||||
|
then rsync to the server).
|
||||||
|
- `release.baseUrl = https://miniscp.com/dl` is set in `electrobun.config.ts`; `miniscp.com` is
|
||||||
|
fronted by an external proxy for TLS (Caddy itself stays on `:80` behind it). The value is baked
|
||||||
|
into `version.json` at build time, so only builds made after it was added check for updates.
|
||||||
|
With `generatePatch` (default true), release builds need **build-time network to
|
||||||
|
`miniscp.com/dl`** so Hutch can fetch the currently-published version to diff a `.patch` (a
|
||||||
|
target with no published feed yet — currently mac and Linux — just ships the full artifacts).
|
||||||
|
|
||||||
|
## Auto-updater (app side)
|
||||||
|
|
||||||
|
Wired on top of the download host above. See the "Auto-updater" section in
|
||||||
|
[architecture.md](architecture.md) for the runtime flow.
|
||||||
|
|
||||||
|
- **Backend** ([src/bun/index.ts](../src/bun/index.ts)): `Updater.checkForUpdate()` runs ~5s after launch and
|
||||||
|
every 6h; on `updateAvailable` it pushes `rpc.send.updateStatus({phase:"available",…})`.
|
||||||
|
`Updater.onStatusChange` streams `download-progress` (0–100) → `updateStatus{downloading}`.
|
||||||
|
`checkForUpdate` self-guards the `dev` channel, so `bun run dev` never shows the pill.
|
||||||
|
- **RPC** ([src/shared/rpc.ts](../src/shared/rpc.ts)): requests `checkForUpdate` / `downloadUpdate` /
|
||||||
|
`applyUpdate` (handlers in [rpc-handlers.ts](../src/bun/rpc-handlers.ts) call the electrobun `Updater`;
|
||||||
|
`applyUpdate` goes through `events.applyUpdate` in index.ts); push message `updateStatus`.
|
||||||
|
- **`applyUpdate()` asks the before-quit guard first (2.x).** With transfers running or dirty
|
||||||
|
editors the guard vetoes and shows its Quit Anyway / Cancel box. `events.applyUpdate` marks the
|
||||||
|
quit as update-initiated, so Quit Anyway re-runs `Updater.applyUpdate()` (a plain `Utils.quit()`
|
||||||
|
would drop the downloaded update); Cancel leaves the pill at "Restart to apply".
|
||||||
|
- **UI**: `UpdaterService` ([ui/src/app/core/updater.service.ts](../ui/src/app/core/updater.service.ts)) holds signals
|
||||||
|
(`state`/`version`/`progress`); the tab-bar "update pill" ([tab-bar.ts](../ui/src/app/skins/shadlike/tab-bar/tab-bar.ts))
|
||||||
|
is one accent (`bg-primary`) control that announces → fills as a progress bar while downloading →
|
||||||
|
"Restart to apply". `state` is `available → downloading → ready` (ready derived from the
|
||||||
|
`downloadUpdate` response). App-menu "Check for Updates…" ([menu.ts](../src/bun/menu.ts)) routes a
|
||||||
|
`check-for-updates` menu action into `UpdaterService.check()`.
|
||||||
|
- **Unsigned was fine on 1.18.1:** `applyUpdate()` stripped the macOS quarantine xattr before
|
||||||
|
relaunch. 2.x hands the swap to a native post-exit helper (transactional replace, restores the
|
||||||
|
old app on failure) — re-check Gatekeeper on the first unsigned 2.x mac update.
|
||||||
|
|
||||||
## Icons
|
## Icons
|
||||||
|
|
||||||
@@ -77,8 +178,13 @@ Rerun after touching either SVG; outputs are committed (build inputs).
|
|||||||
|
|
||||||
## electrobun config notes
|
## electrobun config notes
|
||||||
|
|
||||||
`electrobun.config.ts`: app identity comes from `BRANDING`; `build.bun.external`
|
`electrobun.config.ts`: app identity comes from `BRANDING`; **`build.mainProcess:
|
||||||
must keep `["cpu-features", "*.node"]` (ssh2's optional native accelerators —
|
"bun"` is load-bearing** — 2.x defaults to Cottontail, which ignores the whole
|
||||||
bundling them breaks the build; runtime falls back to pure JS);
|
`build.bun` block (verified: without the line, `bunx electrobun config
|
||||||
`build.copy` ships the Angular dist and tray assets into `views://`;
|
--env=stable` resolves `mainProcess: "cottontail"` with no externals);
|
||||||
`bundleCEF: false` everywhere (system WebKit / WebView2).
|
`build.bun.external` must keep `["cpu-features", "*.node"]` (ssh2's optional
|
||||||
|
native accelerators — bundling them breaks the build; runtime falls back to pure
|
||||||
|
JS; a 2.x stable bundle keeps both as runtime `require`s); `build.copy` ships the
|
||||||
|
Angular dist and tray assets into `views://`; `bundleCEF: false` everywhere
|
||||||
|
(system WebKit / WebView2). `bunx electrobun config --env=stable` prints the
|
||||||
|
merged config Hutch will build with.
|
||||||
|
|||||||
@@ -20,25 +20,165 @@ touching the adjacent code.
|
|||||||
`[class.shadow-[...]]` (brackets) does NOT parse — use `[style.box-shadow]`.
|
`[class.shadow-[...]]` (brackets) does NOT parse — use `[style.box-shadow]`.
|
||||||
Static class attributes may use arbitrary values freely.
|
Static class attributes may use arbitrary values freely.
|
||||||
- Functions called from templates ARE reactive: signals read inside them
|
- Functions called from templates ARE reactive: signals read inside them
|
||||||
register on the view's consumer (call-stack-based tracking). That's why
|
register on the view's consumer (call-stack-based tracking). That's why a
|
||||||
`iconClass(entry)` reacting to `settings.iconColors()` works.
|
template calling `i18n.t()` re-renders on a language switch — `t()` reads
|
||||||
|
the `locale` signal.
|
||||||
- Dedicated tools > shell: IDE diagnostics between paired template/class
|
- Dedicated tools > shell: IDE diagnostics between paired template/class
|
||||||
edits are often stale mid-sequence — trust `bun run build:ui` (full AOT
|
edits are often stale mid-sequence — trust `bun run build:ui` (full AOT
|
||||||
type-check), not the first red squiggle.
|
type-check), not the first red squiggle.
|
||||||
- Tailwind v4 canonical class names: `wrap-break-word` not `break-words`,
|
- Tailwind v4 canonical class names: `wrap-break-word` not `break-words`,
|
||||||
`accent-primary` not `accent-[var(--primary)]`, `z-60` not `z-[60]`.
|
`accent-primary` not `accent-[var(--primary)]`, `z-60` not `z-[60]`.
|
||||||
Every color used in a template must resolve through `@theme inline`.
|
Every color used in a template must resolve through `@theme inline`.
|
||||||
|
- Stylesheet `url()` must resolve at BUILD time: assets referenced from
|
||||||
|
`styles.css` live under `ui/src/` (esbuild emits them hashed into `media/`
|
||||||
|
and rewrites the url), NOT `ui/public/` — a public path fails the build with
|
||||||
|
"Could not resolve".
|
||||||
|
- **Two `class` attributes on one element: the second is silently dropped.**
|
||||||
|
No warning, no build error — the layout classes simply never apply. This
|
||||||
|
collapsed the tab strip and two window headers at once (`fe028a9`), because
|
||||||
|
a scripted edit appended `class="…"` to elements that already had one.
|
||||||
|
When removing a conditional binding, check what the branch *contained*: the
|
||||||
|
bindings there were genuinely dead, but the layout classes inside them were
|
||||||
|
not, and nothing replaced them.
|
||||||
|
- **A component `template:` is a TS template literal, so no backticks inside
|
||||||
|
it** — not even in an HTML comment. A backtick in a comment terminates the
|
||||||
|
literal and the errors point at the prose, not at the quoting.
|
||||||
|
- **Reach `core/` through the `@core/*` alias, never a relative path.**
|
||||||
|
Components sit at varying depths and get moved; the alias is what let 67
|
||||||
|
imports survive two relocations untouched. A `../../core/...` path is a
|
||||||
|
future merge conflict.
|
||||||
|
- **The build DOES type-check components nothing imports.** ngtsc compiles the
|
||||||
|
whole program, so an unreferenced component still fails `bun run build:ui` on
|
||||||
|
a type or template error (verified by injecting an error into an
|
||||||
|
unreferenced file).
|
||||||
|
- **`overflow-x: auto` also clips vertically.** CSS refuses to pair `visible`
|
||||||
|
with a non-visible axis, so the computed `overflow-y` becomes `auto` too:
|
||||||
|
anything poking out of a horizontally scrolling strip (a raised tab, an
|
||||||
|
underline at `-bottom-px`) is silently clipped. Reserve padding instead.
|
||||||
|
|
||||||
|
## Theming (`ui/src/styles.css`)
|
||||||
|
|
||||||
|
- **A new surface token needs an AMOLED line, or AMOLED stops being
|
||||||
|
all-black.** The two `[data-dark-tone='darker']` blocks re-declare every
|
||||||
|
surface (`--chrome`, `--toolbar`, `--background`, `--row-alt`, …) as `#000`;
|
||||||
|
a token added only to the dark blocks shows through in AMOLED. Tokens derived
|
||||||
|
in `:root` by `color-mix` (`--toolbar-2/-3`) follow on their own.
|
||||||
|
- **Hover/selected color is a translucent overlay (`--accent`).** An opaque
|
||||||
|
hover vanishes on a surface of its own lightness — light's old 0.967 hover
|
||||||
|
on the 0.965 header strip did exactly that. Keep `--accent` alpha-based when
|
||||||
|
retoning surfaces.
|
||||||
|
- **A native control ignores `border-radius` and background until
|
||||||
|
`appearance: none`.** WebKit draws checkboxes, radios and selects itself;
|
||||||
|
the square restyle in the `@layer base` block depends on it. Put `pl-*`,
|
||||||
|
never `px-*`, on a `<select>`: utilities outrank the base layer, so a `px`
|
||||||
|
utility would erase the padding-right the drawn caret lives in.
|
||||||
|
- **A custom `::-webkit-scrollbar` turns off macOS overlay scrollbars.** The
|
||||||
|
moment the pseudo-element is styled, WKWebView draws classic always-visible
|
||||||
|
scrollbars that take layout width. Deliberate here (square design); don't
|
||||||
|
reach for it to "tint" scrollbars without accepting that.
|
||||||
|
- **An icon size is one of the four `--icon-size-*` tokens, never a one-off
|
||||||
|
rem.** TDesign glyphs are 24-unit SVGs with 2-unit strokes, so a fractional
|
||||||
|
size (0.9rem = 14.4px) lands every stroke between device pixels and blurs
|
||||||
|
it. Per-template picks had drifted to 12 sizes, and neighbours stopped
|
||||||
|
matching: a 14.4px cog beside a 12.8px +, and a 12px + beside 9.6px × in
|
||||||
|
the shortcut chips.
|
||||||
|
- **On a 1x screen, TDesign's glyphs smear at 14 and 16px, so every icon is
|
||||||
|
redrawn on its pixel grid (`pixel-icons.ts`).** Their 24-unit strokes land
|
||||||
|
between pixels, and a ~1px line covers two pixels at half ink. This is
|
||||||
|
geometry, not Electrobun: headless Chrome renders the same. The drawing rules
|
||||||
|
each cost a debugging round:
|
||||||
|
- **Straight lines are runs of whole pixels** (`h`, `v`, `box`) that end on
|
||||||
|
pixel edges. Never rely on caps or joins:
|
||||||
|
- WebKit drops square caps on a `<path>` with several subpaths.
|
||||||
|
- Skia (WebView2, WebKitGTK) strokes 1px lines as hairlines at 1x, which
|
||||||
|
leave a joined corner pixel three-quarters inked.
|
||||||
|
- **Slants and curves (`slant`) are 1.25px**, so they read as heavy as the
|
||||||
|
crisp 1px runs. Put their ends, vertices and a circle's extremes on pixel
|
||||||
|
centres; a 45° line is cleanest where x−y or x+y is whole.
|
||||||
|
- **A glyph is exact only on its own grid**: a 14px glyph blurs at 16px, so
|
||||||
|
the name's suffix must match the size token (`icons.spec.ts`). It is also
|
||||||
|
exact only at UI scale 100/200/300%.
|
||||||
|
- **Check new glyphs at 1x in WebKit and Chrome, not resvg.** resvg skews a
|
||||||
|
shape's closing edge and leaves path corners partly inked, so its pixels
|
||||||
|
don't predict either browser.
|
||||||
|
|
||||||
|
## The interface scale (root font-size lever)
|
||||||
|
|
||||||
|
- **electrobun's native page zoom is a silent no-op on Linux — do not reach for
|
||||||
|
it.** `webviewSetPageZoom` in the shipped `libNativeWrapper.so` (v1.18.1, and
|
||||||
|
the `_cef.so`, and the copy in our own build output) is a compiled
|
||||||
|
empty-body stub: the disassembly has no `call` instruction, and
|
||||||
|
`webkit_web_view_set_zoom_level` is not even in the library's imports.
|
||||||
|
`webviewGetPageZoom` returns a hardcoded `1.0` from `.rodata`, so the failure
|
||||||
|
is unobservable through the API — set 2.0, read back "1.0 = success". The
|
||||||
|
JSDoc's "(WebKit only)" means macOS WKWebView. This is why the scale is CSS.
|
||||||
|
(Disassembled on 1.18.1; 2.0.2's zoom fixes are CEF-only — re-check the
|
||||||
|
native Linux wrapper before trusting it.)
|
||||||
|
- **Everything the UI sizes with must be rem.** The scale works by setting the
|
||||||
|
root font-size, so a px value freezes while everything around it grows. The
|
||||||
|
px reservoirs were the panel-chrome tokens (`--row-h` etc.) and 93
|
||||||
|
`text-[11px]` arbitraries. 1px hairlines and focus rects stay px deliberately
|
||||||
|
(crisp at any scale).
|
||||||
|
- **px constants in TS that mirror rem-sized CSS are latent scale bugs.** The
|
||||||
|
context menu's `MENU_W = 224` was a hardcoded mirror of `w-56`
|
||||||
|
(14rem): box grew, math didn't, flip fired at the wrong edge. Convert through
|
||||||
|
`@core/metrics` `remPx()` at use time — it reads the LIVE root font-size,
|
||||||
|
and per call, since the scale changes mid-session. The editor's canvas-based
|
||||||
|
`charWidth` is cached keyed on the computed font string for the same reason.
|
||||||
|
|
||||||
## Electrobun
|
## Electrobun
|
||||||
|
|
||||||
- **Window `close` is not cancellable** (response type `{}`; only the
|
- **The `electrobun` npm package (2.x) is a bootstrap whose every export is a
|
||||||
app-level `before-quit` supports `event.response = {allow:false}`). All
|
throwing stub** ("Electrobun 2.x APIs come from the Hutch devkit, not
|
||||||
unsaved-state guards must either intercept app-controlled close paths
|
node_modules"). Every `electrobun/*` import must resolve through the
|
||||||
(⌘W, buttons) or survive the close (editor draft mirroring).
|
projected `.hutch/devkit/`: the root tsconfig extends
|
||||||
|
`.hutch/devkit/tsconfig.json`, and `ui/tsconfig.json` maps `electrobun/view`
|
||||||
|
to `../.hutch/devkit/api/browser/index.ts` (the exact export-map target). Let
|
||||||
|
an import fall through to `node_modules/electrobun` and the webview throws at
|
||||||
|
load. tsc and the UI build fail on unresolved `electrobun/*` imports until
|
||||||
|
`bunx electrobun prepare` has run (postinstall and every dev/build script do
|
||||||
|
it). Never point anything into `~/.hutch` — that store is Hutch-owned.
|
||||||
|
- **`build.mainProcess` defaults to Cottontail in 2.x**, and Cottontail ignores
|
||||||
|
the whole `build.bun` block — the ssh2 externals included, silently (verified
|
||||||
|
with `bunx electrobun config --env=stable`). `mainProcess: "bun"` in
|
||||||
|
`electrobun.config.ts` is load-bearing.
|
||||||
|
- **The browser SDK is raw TS compiled inside the Angular program**, so the UI's
|
||||||
|
strictness flags apply to upstream code: 2.0.2's `createTransport().send`
|
||||||
|
returns on one path only, which is why `ui/tsconfig.json` has
|
||||||
|
`noImplicitReturns: false`. Its `import "./global.d.ts"` also prints "File
|
||||||
|
'…/global.d.ts' not found in TypeScript compilation" on every build — benign,
|
||||||
|
and adding the file to the program does not silence it.
|
||||||
|
- **Window `close` is not cancellable, but 2.x added `will-close`.** The `close`
|
||||||
|
event's response type is `{}`; `will-close` (fired by the titlebar/traffic-light
|
||||||
|
close and `win.requestClose()`) honors `event.response = {allow:false}`, and
|
||||||
|
`win.close()` skips it. The editor close safety predates this: all
|
||||||
|
unsaved-state guards still intercept app-controlled close paths (⌘W, buttons)
|
||||||
|
or survive the close (editor draft mirroring) — a `will-close` prompt is a
|
||||||
|
possible follow-up.
|
||||||
|
- **`Updater.applyUpdate()` asks the `before-quit` handlers first (2.x)**, so the
|
||||||
|
transfers/unsaved guard can veto an update restart. Its "Quit Anyway" must
|
||||||
|
re-run `Updater.applyUpdate()` (`events.applyUpdate` flags the quit as
|
||||||
|
update-initiated) — a plain `Utils.quit()` there quits without installing.
|
||||||
|
`Utils.quit()` runs the same approval, which is why the guard checks
|
||||||
|
`quitConfirmed` first.
|
||||||
|
- **macOS ⌘-keys go to the PAGE first; only unclaimed ones reach the app
|
||||||
|
menu.** WKWebView's `performKeyEquivalent` hands key equivalents to the page
|
||||||
|
while the webview is first responder (WebKit `WebViewImpl.mm`: "lets
|
||||||
|
webpages have a crack at intercepting key-modified keypresses"); a key the
|
||||||
|
page `preventDefault`s never reaches the menu. Old comments here claimed the
|
||||||
|
opposite ("the menu intercepts ⌘W first") — what was really happening is that
|
||||||
|
windows which didn't claim ⌘W let it fall through to the menu's Close Tab.
|
||||||
|
Two consequences: a handled shortcut MUST `preventDefault` (`claim()` does),
|
||||||
|
or the menu runs it a second time; and a ⌘W a secondary window doesn't claim
|
||||||
|
still arrives as the MAIN window's `close-tab` action — bun checks the
|
||||||
|
focused Settings window, then editors, first and closes THAT, so a new
|
||||||
|
window kind must either claim close-window or join that check.
|
||||||
- **One rpc instance per window** — BrowserView binding steals the rpc's
|
- **One rpc instance per window** — BrowserView binding steals the rpc's
|
||||||
transport; a shared instance silently sends to the wrong webview.
|
transport; a shared instance silently sends to the wrong webview.
|
||||||
- **RPC before socket open is silently lost.** Everything gates on the
|
- **RPC before socket open was silently lost on 1.18.1.** 2.x Electroview queues
|
||||||
socket-open promise (RpcService `ready`).
|
sends while its `hostSocket` (v1: `bunSocket`) is CONNECTING and falls back to
|
||||||
|
the native bridge; RpcService still gates everything on the socket-open
|
||||||
|
promise (`ready`) as belt-and-braces.
|
||||||
- The emitter has no `off()`. Window event closures must not capture records
|
- The emitter has no `off()`. Window event closures must not capture records
|
||||||
you want garbage collected.
|
you want garbage collected.
|
||||||
- `electrobun build` defaults to the **dev** channel; release builds need
|
- `electrobun build` defaults to the **dev** channel; release builds need
|
||||||
@@ -48,21 +188,169 @@ touching the adjacent code.
|
|||||||
- Tray: never call `setImage` after construction on macOS — the template
|
- Tray: never call `setImage` after construction on macOS — the template
|
||||||
treatment is lost and the icon renders black on dark menu bars.
|
treatment is lost and the icon renders black on dark menu bars.
|
||||||
- `ApplicationMenu` is macOS-only in our usage; accelerators on Windows are
|
- `ApplicationMenu` is macOS-only in our usage; accelerators on Windows are
|
||||||
single-character only. ContextMenu exists on macOS+Windows, not Linux.
|
single-character only.
|
||||||
|
- **`ContextMenu.showContextMenu` is a silent no-op on Linux** (electrobun's own
|
||||||
|
`.hutch/devkit/api/sdks/main/proc/linux.md` says so, still in 2.0.2; the app
|
||||||
|
also prints "Application menus are not supported on Linux" at boot). Because
|
||||||
|
the UI suppresses the webview's own right-click menu, that left EVERY panel
|
||||||
|
context menu dead on Linux — no error, nothing happens. The menu is plain
|
||||||
|
HTML now (`ui/src/app/skins/shadlike/context-menu.ts`) on every platform;
|
||||||
|
don't reintroduce the native one for "nativeness" on mac/win without keeping
|
||||||
|
a Linux path.
|
||||||
- **Window-open flash prevention is three-layer** — remove any one and the
|
- **Window-open flash prevention is three-layer** — remove any one and the
|
||||||
white/inverted blink returns: (1) windows are created `hidden: true` and
|
white/inverted blink returns: (1) windows are created `hidden: true` and
|
||||||
revealed by `showWhenReady` on the webview's first `dom-ready` (800 ms
|
revealed by `showWhenReady` on the webview's first `dom-ready` (800 ms
|
||||||
fallback), (2) the bun preload bakes the persisted `data-theme`/tone/accent
|
fallback), (2) the bun preload bakes the persisted `data-theme`/tone
|
||||||
attributes before first paint (`buildPreload`), (3) `ui/src/index.html`
|
attributes and the interface scale before first paint (`buildPreload`),
|
||||||
carries an inline `<style>` with the html background per theme — its oklch
|
(3) `ui/src/index.html` carries an inline `<style>` with the html background
|
||||||
values MUST stay in sync with the `--background` tokens in styles.css.
|
per theme — its oklch values MUST stay in sync with the `--background` tokens
|
||||||
|
in styles.css. (The win7 branch once replaced (3) with a transparent rule and
|
||||||
|
the modern skin silently lost it — the other two layers do not cover it.)
|
||||||
- Dev vs prod URL: `getMainViewUrl()` resolves the ng-serve dev server only
|
- 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
|
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
|
server answers a HEAD request; secondary windows must reuse the resolved
|
||||||
URL, never hardcode `views://`.
|
URL, never hardcode `views://`.
|
||||||
- Upstream Bun panic (`FFI_Callback_threadsafe_call` → `web_worker.spin`):
|
- Upstream Bun panic (`FFI_Callback_threadsafe_call` → `web_worker.spin`):
|
||||||
rare segfault under long sessions; upstream electrobun×Bun issue, restart
|
rare segfault under long sessions; upstream electrobun×Bun issue, restart
|
||||||
recovers, nothing app-side to fix.
|
recovers, nothing app-side to fix. Seen on Bun 1.3.13 (Electrobun 1.18.1);
|
||||||
|
2.0.2 ships Bun 1.4.0 — unconfirmed whether that fixed it.
|
||||||
|
- **Preloads and frames are construction-time only.** The preload is a
|
||||||
|
persistent `WebKitUserScript` at DOCUMENT_START, frozen at webview
|
||||||
|
construction — `webview.loadURL()` re-runs the OLD one
|
||||||
|
(`updatePreloadScriptToWebView` is only reachable from electrobun's internal
|
||||||
|
`<electrobun-webview>` handler). `titleBarStyle`/`transparent` have no
|
||||||
|
setters. Anything `buildPreload` bakes is a first-paint snapshot the UI must
|
||||||
|
correct live over RPC. Recreating the main window is not an escape hatch:
|
||||||
|
`exitOnLastWindowClosed`, push closures and the tray capture it, and there
|
||||||
|
is no `off()`.
|
||||||
|
- **`trafficLightOffset` changed meaning in 2.x.** 1.18.1 nudged the buttons
|
||||||
|
by the offset but clamped them inside the stock titlebar, so `{12, 13}` really
|
||||||
|
meant "as low as the titlebar allows" (left 21 / top 18 on macOS 26). 2.x adds
|
||||||
|
the full offset to macOS's default position (9/9 in the 32pt Tahoe titlebar)
|
||||||
|
and grows the titlebar to fit — the same numbers dropped the lights 4pt.
|
||||||
|
`chromeOptions()` now passes `{12, 4}`: left 21 / top 13, the 14pt buttons
|
||||||
|
vertically centred in the 40px `h-10` header (1.18.1's clamped spot, top 18,
|
||||||
|
was 5pt low). The default shifts with macOS's titlebar metrics (28pt / 7
|
||||||
|
before macOS 26), so re-check after an OS release. `win.setWindowButtonPosition(x, top)` is the absolute
|
||||||
|
alternative (re-applied on resize/fullscreen) if per-version drift matters.
|
||||||
|
- **Drag regions ignore `preventDefault`.** The preload starts a window move
|
||||||
|
from a bubble-phase `mousedown` on `document` and never reads
|
||||||
|
`defaultPrevented`; a `pointerdown` handler doesn't stop it either (the
|
||||||
|
compat `mousedown` still fires). `isAppRegionDragTarget` lets a `no-drag`
|
||||||
|
region win — the `.electrobun-webkit-app-region-no-drag` class (or, since
|
||||||
|
2.x, `app-region: no-drag` CSS) on the element is the only reliable
|
||||||
|
suppressor (`.hutch/devkit/api/preload/dragRegions.ts`; re-checked on 2.0.2).
|
||||||
|
- **No resize, maximize-event or double-click-maximize primitives (1.18.1, still
|
||||||
|
absent from 2.0.2's `BrowserWindow`).**
|
||||||
|
`titleBarStyle: "hidden"` drops the whole frame (Cocoa `Titled` — the only
|
||||||
|
way to remove the traffic lights; `gtk_window_set_decorated(false)` on
|
||||||
|
Linux) and with it the WM resize borders; nothing restores them (no
|
||||||
|
`startWindowResize`, no `gtk_window_begin_resize_drag`, no
|
||||||
|
`setResizable`/min-size options — only `setFrame`/`getFrame`). No maximize
|
||||||
|
event exists (derive it from `resize` + `isMaximized()`), and drag regions
|
||||||
|
don't double-click-maximize. A custom-chrome window has to build all three.
|
||||||
|
|
||||||
|
## Linux
|
||||||
|
|
||||||
|
- **`ERR_DLOPEN_FAILED` on `bun run start`** means a missing system library, not
|
||||||
|
broken app code. `libNativeWrapper.so` links `libayatana-appindicator3.so.1`
|
||||||
|
*hard* (for the tray), so the app cannot start at all without it — even though
|
||||||
|
the tray is a minor feature. Diagnose with
|
||||||
|
`ldd build/dev-linux-x64/*/bin/libNativeWrapper.so | grep 'not found'`; the
|
||||||
|
`libasar.so => not found` line in that output is a red herring (it ships beside
|
||||||
|
the wrapper and resolves at load). Install list per distro is in
|
||||||
|
[platform-notes.md](platform-notes.md).
|
||||||
|
- **Black window on NVIDIA + `Failed to create GBM buffer of size WxH: Invalid
|
||||||
|
argument`** (usually with `X11 Error: GLXBadWindow (code 170)`). The app boots,
|
||||||
|
logs "miniSCP started", reports no error — and paints nothing. WebKitGTK's
|
||||||
|
DMABUF renderer can't allocate buffers on the NVIDIA proprietary driver;
|
||||||
|
`WEBKIT_DISABLE_DMABUF_RENDERER=1` is the upstream workaround.
|
||||||
|
- **The app cannot apply this to itself.** WebKit reads the variable when it
|
||||||
|
forks its web process, and **Bun's `process.env` writes do not propagate to
|
||||||
|
child processes** (verified: a spawned child sees it unset). Setting it in
|
||||||
|
`src/bun/index.ts` — even in a module imported before `electrobun/main` —
|
||||||
|
does nothing. It must be in the environment before the app process starts.
|
||||||
|
- Only the dev launch paths inject it: `scripts/linux-gpu.ts` (`launchEnv()`)
|
||||||
|
for `bun run dev` / `bun run start`. **A new dev launch path must call
|
||||||
|
`launchEnv()` or it will render black on NVIDIA.** The release is the
|
||||||
|
official 2.x Setup installer now (the v1 portable tarball and its
|
||||||
|
`miniSCP.sh` wrapper are gone), so an installed build on NVIDIA needs the
|
||||||
|
user to launch it with `WEBKIT_DISABLE_DMABUF_RENDERER=1` (e.g. in the
|
||||||
|
desktop entry's `Exec`).
|
||||||
|
- Gated on `/proc/driver/nvidia` so Intel/AMD keep hardware compositing, and
|
||||||
|
an explicit `WEBKIT_DISABLE_DMABUF_RENDERER` from the user always wins.
|
||||||
|
- A "does it boot?" log check does NOT catch this — grep for the GBM line, or
|
||||||
|
look at the window.
|
||||||
|
- **`bun run start` leaves the old instance running, and
|
||||||
|
`import -window miniSCP` grabs the OLDEST match.** Instances pile up across
|
||||||
|
rebuilds, so every screenshot silently comes from a stale build and the fix
|
||||||
|
you just made looks like it did nothing. Cost three wrong readings in one
|
||||||
|
session, including a "the CSS is in the bundle but does not apply" dead end.
|
||||||
|
Kill first (`pkill -f dev-linux-x64`), confirm with
|
||||||
|
`pgrep -cf 'dev-linux-x64/miniSCP-dev/bin/launcher'`, then launch and capture.
|
||||||
|
Note `pkill -f 'electrobun dev'` also matches the shell running it and kills
|
||||||
|
your own command (exit 144) — match on the build path instead. That is only
|
||||||
|
half the trap: `pkill -f dev-linux-x64` does the same thing, because the
|
||||||
|
pattern appears in the killing command's OWN argv. Break the literal with a
|
||||||
|
character class — `pkill -f 'dev-linux-x[6]4'` — the way `ps | grep [x]yz`
|
||||||
|
has always worked.
|
||||||
|
- **Launching the app steals focus — ask first.** `bun run start` opens a real
|
||||||
|
window that takes focus, which interrupts whatever the user is doing; there
|
||||||
|
is no Xvfb/xvfb-run on this machine and Wayland offers no
|
||||||
|
launch-without-focus. Batch every live check into a single launch.
|
||||||
|
- Benign startup noise on Linux — non-fatal, the app runs normally:
|
||||||
|
`libayatana-appindicator is deprecated. Please use libayatana-appindicator-glib`
|
||||||
|
(upstream electrobun's choice, not ours).
|
||||||
|
|
||||||
|
## Keyboard (`src/shared/keymap.ts`, `ui/src/app/core/shortcuts.service.ts`)
|
||||||
|
|
||||||
|
- **WKWebView skips buttons, checkboxes and radios on Tab** unless the user
|
||||||
|
turned on System Settings → Keyboard → Keyboard navigation (or presses ⌥Tab).
|
||||||
|
Dialogs, Settings and the manager were mouse-only on a default Mac. Tab is
|
||||||
|
walked by hand: `cycleFocus(root, event)` (core/focus-cycle.ts) — use it in
|
||||||
|
any new dialog or window.
|
||||||
|
- **WebKit doesn't focus a clicked button — focus drops to `<body>`** (Windows
|
||||||
|
parks it on the button, where Space clicks it again). Every list key died
|
||||||
|
until the list was clicked. `App` `(mousedown)` keeps focus on chrome-button
|
||||||
|
clicks and forwards `<body>` keys to the active list; keep both.
|
||||||
|
- **Match modifiers exactly.** The old panel switch caught every Tab, so
|
||||||
|
Ctrl+Tab flipped the active panel AND switched tabs; `isMod = metaKey ||
|
||||||
|
ctrlKey` made ⌃F (Cocoa's forward-one-character) open the editor's find.
|
||||||
|
`matchesEvent` compares all four modifiers, platform-exact.
|
||||||
|
- **AltGr is Ctrl+Alt on Windows/Linux.** A Ctrl+Alt+letter binding steals
|
||||||
|
"@", "€", "ł"… — never a default there, never fired from a text field, and
|
||||||
|
an event with `getModifierState('AltGraph')` matches nothing.
|
||||||
|
- **Type-to-filter takes every printable key.** A plain-letter binding (vim
|
||||||
|
j/k) must resolve BEFORE it — and then that letter no longer starts a query
|
||||||
|
(Settings warns). While a query is being typed, printable keys (Space too)
|
||||||
|
always extend it.
|
||||||
|
- **In a master-detail list, selection must not follow focus** when the detail
|
||||||
|
is an unsaved form: the connection manager's `edit()` replaces the draft
|
||||||
|
without asking, so arrowing through the list would silently discard edits.
|
||||||
|
Arrows move focus; Enter loads.
|
||||||
|
- **A dialog's Esc must `stopPropagation`.** The connection manager closes on
|
||||||
|
a window-level Escape; the dialog cleared its own signal first, so the
|
||||||
|
window handler saw "no dialog open" and closed the whole window.
|
||||||
|
- **An unclaimed key behind a dialog reaches the macOS menu** — ⌘T opened a
|
||||||
|
tab behind the rename prompt. The main window swallows window commands and
|
||||||
|
the menu bar's panel commands while a dialog or menu is up.
|
||||||
|
- **WebView2 reloads the page on unclaimed F5 / Ctrl+R** (default browser
|
||||||
|
accelerators), dropping every session and the editor's unsaved text. Every
|
||||||
|
window root ends with `blockReloadKeys`; a blanket `stopPropagation` (the old
|
||||||
|
path bar) hides keys from it — don't add one.
|
||||||
|
- **Hold-to-repeat** fired ⌘W once per repeat — every tab closed. `claim()`
|
||||||
|
repeats only movement-like commands (`isRepeatable`).
|
||||||
|
- **Electrobun's native menu accelerator turns a combo with no modifier token
|
||||||
|
into ⌘+key** ("f7" shows and fires ⌘F7), its `backspace` / `delete` tokens
|
||||||
|
are characters AppKit matches unreliably, and AppKit matches Shift+punctuation
|
||||||
|
by the shifted character ("}" for ⇧]). `toAccelerator` returns null for all of
|
||||||
|
those; the menu item then shows no key and the page still handles it.
|
||||||
|
- **Recording a key must take the keydown before every window handler** — and
|
||||||
|
must NOT take reserved ones: preventDefault on ⌘Q while recording would make
|
||||||
|
the app unquittable from the keyboard.
|
||||||
|
- **Focus after a state change needs `afterNextRender({ injector })`.** Under
|
||||||
|
zoneless CD `queueMicrotask` runs before the render that creates an `@if`
|
||||||
|
element (the editor's find box and textarea), so `.focus()` hit nothing.
|
||||||
|
|
||||||
## Domain logic
|
## Domain logic
|
||||||
|
|
||||||
@@ -81,11 +369,47 @@ touching the adjacent code.
|
|||||||
- Settings: the backend spread-merges (`{...DEFAULT_SETTINGS, ...data}`) so
|
- Settings: the backend spread-merges (`{...DEFAULT_SETTINGS, ...data}`) so
|
||||||
new keys auto-default, but the UI SettingsService reads field-by-field —
|
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.
|
a new setting silently stays at default until wired in all five spots.
|
||||||
|
- **Saves are whole-object: a window must not save before its first
|
||||||
|
`getSettings` landed.** A ⌘+ pressed while an editor opened wrote
|
||||||
|
`DEFAULT_SETTINGS` (no keyboard remaps) over the user's file.
|
||||||
|
`SettingsService.persist()` is a no-op until `applyAll` ran once.
|
||||||
|
- **Every window's RPC must serve `saveSettings`.** The scale shortcuts
|
||||||
|
shipped in editor and manager windows while only the main RPC served it:
|
||||||
|
the request went unanswered, the change applied locally, and main's next
|
||||||
|
broadcast snapped it back. All three factories now share
|
||||||
|
`saveSettingsHandler`, and `settingsSaved()` in index.ts rebroadcasts to
|
||||||
|
every window but the sender — an echo would drag a held Cmd+ backwards.
|
||||||
- The editor draft mirror sends full content per tick (2 MB cap upstream);
|
- The editor draft mirror sends full content per tick (2 MB cap upstream);
|
||||||
don't shrink the 400 ms debounce.
|
don't shrink the 400 ms debounce.
|
||||||
- `panel.touched` gates localDir auto-navigation; `connectToken` guards
|
- `panel.touched` gates localDir auto-navigation; `connectToken` guards
|
||||||
against a slow connect resolving into a panel the user already reused.
|
against a slow connect resolving into a panel the user already reused.
|
||||||
|
|
||||||
|
## i18n
|
||||||
|
|
||||||
|
- **Translations live in `src/shared/i18n/*.ts` (TS, not JSON)** — deliberate:
|
||||||
|
it sidesteps `resolveJsonModule` differences between the Angular esbuild and
|
||||||
|
Bun builds and gives full typing. `en.ts` is the source of truth; other
|
||||||
|
locales are partial and fall back to English then the raw key.
|
||||||
|
- **`i18n.t()` is reactive because it reads the `locale` signal** — that's what
|
||||||
|
makes live language switching work with no pipe. If you cache a translated
|
||||||
|
string in a plain field it WON'T update on switch; call `t()` in the template
|
||||||
|
(or in a signal-reading getter) instead. Same for `i18n.date()/size()`.
|
||||||
|
- Dictionary values are TS string literals: **French/other apostrophes break
|
||||||
|
single-quoted entries** (`'…c'est…'`). Use double quotes for any value
|
||||||
|
containing an apostrophe. Plurals are `{ one, other }` maps (not ICU strings);
|
||||||
|
zh/ja supply only `other`.
|
||||||
|
- **Native menus can't use the Angular service** — `menu.ts` goes through backend
|
||||||
|
`i18n.ts`, and the app menu must be rebuilt when the locale changes (wired in
|
||||||
|
index.ts `settingsChanged`), or it keeps the old language. The context menu no
|
||||||
|
longer has this problem: it is Angular now and reads `I18nService` directly.
|
||||||
|
- `eu`/`zh`/`ja` are AI drafts pending native review (see the i18n README).
|
||||||
|
- **Silent auto-refresh must snapshot sessionId+cwd and discard a mismatched
|
||||||
|
result.** A normal `refresh()` is masked by `busy` (input blocked), but the
|
||||||
|
silent variant (poll/watch) runs while the user can navigate — without the
|
||||||
|
guard, a poll that started in dir A resolves and stamps A's listing over the
|
||||||
|
dir B the user just opened. It also never calls `scrollCursorIntoView`
|
||||||
|
(scroll must stay put) and returns early if `busy()` or already refreshing.
|
||||||
|
|
||||||
## Build / tooling
|
## Build / tooling
|
||||||
|
|
||||||
- Angular CLI rejects Bun's Node-compat version — `scripts/node-version-shim.ts`
|
- Angular CLI rejects Bun's Node-compat version — `scripts/node-version-shim.ts`
|
||||||
@@ -93,7 +417,14 @@ touching the adjacent code.
|
|||||||
breaks `ng`.
|
breaks `ng`.
|
||||||
- ssh2's optional native deps must stay `external: ["cpu-features", "*.node"]`
|
- ssh2's optional native deps must stay `external: ["cpu-features", "*.node"]`
|
||||||
in `electrobun.config.ts` — bundling them breaks the build; ssh2 falls back
|
in `electrobun.config.ts` — bundling them breaks the build; ssh2 falls back
|
||||||
to pure JS at runtime.
|
to pure JS at runtime. (Inert without `mainProcess: "bun"` — see Electrobun.)
|
||||||
|
- **2.x installers dropped the `stable-` prefix; the updater feed kept it.**
|
||||||
|
`macos-arm64-miniSCP.dmg` / `win-x64-miniSCP-Setup.zip` /
|
||||||
|
`linux-x64-miniSCP-Setup.tar.gz` next to `stable-<os>-<arch>-update.json`,
|
||||||
|
`.tar.zst` and `.patch`. Anything that globs `artifacts/stable-*` misses the
|
||||||
|
installers — release.ts collects by `<os>-<arch>-`, deploy.sh publishes
|
||||||
|
`artifacts/*` verbatim (release.ts empties `artifacts/` first), and the site
|
||||||
|
links the new names.
|
||||||
- `bun run --cwd ui build` (flag order matters; `bun --cwd ui run build` does
|
- `bun run --cwd ui build` (flag order matters; `bun --cwd ui run build` does
|
||||||
not do what you want).
|
not do what you want).
|
||||||
- `version.gen.ts` is gitignored and regenerated by postinstall + every build
|
- `version.gen.ts` is gitignored and regenerated by postinstall + every build
|
||||||
|
|||||||
@@ -1,43 +1,162 @@
|
|||||||
# Platform notes — macOS / Windows behavior matrix
|
# Platform notes — macOS / Windows / Linux behavior matrix
|
||||||
|
|
||||||
macOS is the primary target; Windows 11+ x64 is a **beta** target (win-arm64
|
macOS (Apple Silicon) is the primary target; Windows 11+ x64 and Linux x64 are
|
||||||
deferred; Linux untargeted — electrobun lacks menus/context menus there).
|
**beta** targets (win-arm64 and linux-arm64 deferred). **Intel Macs were
|
||||||
|
dropped** with the move to Electrobun 2.x, which publishes no macOS x64 core.
|
||||||
Every platform seam carries a `PORTABILITY:` comment at the branch site.
|
Every platform seam carries a `PORTABILITY:` comment at the branch site.
|
||||||
|
|
||||||
## Seam inventory (where platform branches live)
|
## Seam inventory (where platform branches live)
|
||||||
|
|
||||||
| Concern | File | mac | win | other |
|
| Concern | File | mac | win | linux |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Window chrome | `src/bun/windows.ts`, `index.ts` | hiddenInset + traffic-light offset, UI drag header | native titlebar, compact header | same as win |
|
| Window chrome | `src/bun/windows.ts chromeOptions` | hiddenInset + traffic-light offset, UI drag header | native titlebar, compact header | same as win |
|
||||||
|
| Scrollbars | `ui/src/styles.css` (`::-webkit-scrollbar`) | custom square, always visible (styling them turns off overlay scrollbars) | custom square | custom square |
|
||||||
| External editor launch | `src/bun/edits.ts launchEditor` | `open [-a app]` | `cmd /c start "" [app] file` | app directly / `xdg-open` |
|
| 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 |
|
| 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` |
|
| 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 |
|
| 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 |
|
| chmod/Permissions | context-menu.service.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 |
|
| Shell wording | `ui context-menu.service.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+ |
|
| Shortcut labels | `src/shared/keymap.ts formatCombo` (+ `keys.names.*`) | ⌘⇧⌥⌃ glyphs: ⇧⌘N | Ctrl+Shift+N, localized key names (Strg+Entf) | as Windows |
|
||||||
| Tray | `src/bun/tray.ts` | 22 pt template PNG | 16 px colored PNG | colored |
|
| Command modifier | `keymap.ts` `Mod` | ⌘ only (⌃ stays Cocoa's text keys) | Ctrl | Ctrl |
|
||||||
| App menu | `src/bun/menu.ts` | full menu incl. About | none (early return) — About lives in the options popover | none |
|
| Key equivalents | `shortcuts.service.ts claim` | page first, unclaimed → app menu | page only (no app menu) | page only |
|
||||||
|
| Tab focus | `core/focus-cycle.ts cycleFocus` | WKWebView skips buttons/checkboxes without Keyboard navigation → walked by hand | native order (same walk) | native order (same walk) |
|
||||||
|
| Reload keys | `platform.ts blockReloadKeys` | — | WebView2 reloads on F5 / Ctrl+R → swallowed when unclaimed | — |
|
||||||
|
| Tray | `src/bun/tray.ts` | 22 pt template PNG | 16 px colored PNG | colored (needs AppIndicator, see below) |
|
||||||
|
| App menu | `src/bun/menu.ts` | full menu incl. About; accelerators follow the keymap (remaps included), ⌘Q fixed | none (early return) — Settings opens from the tab-bar gear (Ctrl+,), About from its About section | none (unsupported upstream) |
|
||||||
| connections.json protection | `store/connections.ts` | chmod 0600 | `icacls` current-user ACL (0600 is meaningless) | 0600 |
|
| 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 |
|
| 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 |
|
| 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 | "/" |
|
| Paths in UI | `ui format.ts joinPath/isRootPath` | "/" | separator detected from the dir string; drive-root regex | "/" |
|
||||||
|
|
||||||
|
The **context menu is HTML on every platform** (`ui/src/app/skins/shadlike/context-menu.ts` +
|
||||||
|
`context-menu.service.ts`), not a native electrobun menu — see the Linux section
|
||||||
|
below for why. Only the three shell actions it can't do itself (reveal, open with
|
||||||
|
the default app, copy path) cross to bun, via the `shellItemAction` RPC.
|
||||||
|
|
||||||
Remote (SFTP) paths are ALWAYS POSIX regardless of host or server OS
|
Remote (SFTP) paths are ALWAYS POSIX regardless of host or server OS
|
||||||
(`path.posix` throughout `fs/sftp.ts`).
|
(`path.posix` throughout `fs/sftp.ts`).
|
||||||
|
|
||||||
|
## Memory footprint (expected, not a bug)
|
||||||
|
|
||||||
|
Investigated 2026-07 — packaged build. Roughly **~200 MB macOS / ~300 MB
|
||||||
|
Windows** (Linux sits near macOS: WebKitGTK, like WKWebView, is lighter than
|
||||||
|
Chromium), which is normal for a Bun + system-WebView app (30 MB is native /
|
||||||
|
no-web-engine territory; Tauri-class apps run ~80–200 MB, Electron 150–400 MB).
|
||||||
|
Not reducible to tens of MB without dropping the web UI.
|
||||||
|
|
||||||
|
- macOS splits across several "miniSCP" processes: the **Bun main** (~120 MB:
|
||||||
|
backend + the bundled electrobun runtime + live SSH sessions — the largest and
|
||||||
|
least reducible piece), the **`views://app`** WKWebView content process (~60 MB,
|
||||||
|
the Angular UI), and the standard WebKit **Networking** / **Graphics and
|
||||||
|
Media** helpers. All are one app; WebKit just names them per-role.
|
||||||
|
- Windows is higher because **WebView2 is Chromium** (multi-process: browser +
|
||||||
|
GPU + renderer + utility + crashpad), vs macOS's lighter WKWebKit.
|
||||||
|
- Measure against a **packaged** build — `bun run dev` inflates the WebContent
|
||||||
|
heap (ng-serve, unminified, sourcemaps, live-reload) and is not representative.
|
||||||
|
- Verified non-issues: transfers stream (no whole-file buffering), icons
|
||||||
|
tree-shaken to ~33 glyphs, zoneless Angular, 364 KB prod bundle.
|
||||||
|
- Only real growth levers if this ever matters: the file list is **not
|
||||||
|
virtualized** (a directory with tens of thousands of entries retains a DOM row
|
||||||
|
each), and each open tab keeps both panels' full `entries` arrays; terminal
|
||||||
|
transfers are retained until cleared. None affect normal use.
|
||||||
|
|
||||||
|
## Linux runtime dependencies (REQUIRED — the app will not start without them)
|
||||||
|
|
||||||
|
electrobun's `libNativeWrapper.so` links AppIndicator *hard* for the tray, so a
|
||||||
|
missing library is a hard boot failure (`ERR_DLOPEN_FAILED` out of `bun:ffi`
|
||||||
|
`dlopen`), not a degraded tray. The transitive deps (`libdbusmenu-glib`,
|
||||||
|
`libdbusmenu-gtk3`, `libayatana-indicator3`, `libayatana-ido3`) come in as
|
||||||
|
package dependencies:
|
||||||
|
|
||||||
|
| Distro | Install |
|
||||||
|
|---|---|
|
||||||
|
| Arch / CachyOS | `sudo pacman -S libayatana-appindicator` |
|
||||||
|
| Debian / Ubuntu | `sudo apt install libayatana-appindicator3-1` |
|
||||||
|
| Fedora | `sudo dnf install libayatana-appindicator-gtk3` |
|
||||||
|
|
||||||
|
GTK/WebKit itself comes from the distro too — electrobun uses the system webview
|
||||||
|
(`bundleCEF: false`), so `webkit2gtk` must be present.
|
||||||
|
|
||||||
|
Diagnosing a boot failure:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ldd build/dev-linux-x64/*/bin/libNativeWrapper.so | grep 'not found'
|
||||||
|
```
|
||||||
|
|
||||||
|
`libasar.so => not found` in that output is **expected and harmless** — it ships
|
||||||
|
in the same `bin/` folder and resolves at load time; only an unresolved
|
||||||
|
`libayatana-appindicator3.so.1` is fatal. (Hard link and path verified on
|
||||||
|
1.18.1 — re-check both on the first 2.x Linux build.)
|
||||||
|
|
||||||
|
**Tray visibility** is a separate matter from linking: GNOME 3.26+ hides the
|
||||||
|
legacy tray, so the icon needs the
|
||||||
|
[AppIndicator extension](https://extensions.gnome.org/extension/615/appindicator-support/).
|
||||||
|
KDE Plasma, XFCE, MATE and Cinnamon show it out of the box. The app runs fine
|
||||||
|
either way — the tray is a convenience, and every action it offers exists in-app.
|
||||||
|
|
||||||
|
## Linux known issues (upstream electrobun)
|
||||||
|
|
||||||
|
- **No native menus of any kind.** Application menus and `showContextMenu` are
|
||||||
|
both no-ops (electrobun's `.hutch/devkit/api/sdks/main/proc/linux.md`, still in
|
||||||
|
2.0.2; the app logs
|
||||||
|
"Application menus are not supported on Linux" at boot). The app menubar was
|
||||||
|
already macOS-only, and the panel context menus are now HTML on every platform,
|
||||||
|
so nothing is missing at the UI level — but do not add a feature that depends
|
||||||
|
on a native menu.
|
||||||
|
- **NVIDIA renders black without the DMABUF workaround.** WebKitGTK can't
|
||||||
|
allocate GBM buffers on the proprietary driver, so the window paints nothing
|
||||||
|
while the log looks perfectly healthy. The dev launches
|
||||||
|
(`scripts/linux-gpu.ts`) set `WEBKIT_DISABLE_DMABUF_RENDERER=1` when
|
||||||
|
`/proc/driver/nvidia` exists; an installed build (the official Setup
|
||||||
|
installer) does not — launch it with that variable set (e.g. in the desktop
|
||||||
|
entry's `Exec`). The app can't do it itself (Bun's `process.env` writes don't
|
||||||
|
reach child processes); see [gotchas.md](gotchas.md#linux).
|
||||||
|
- **Benign startup noise**: a deprecation warning pointing at
|
||||||
|
`libayatana-appindicator-glib`. Not fatal.
|
||||||
|
- **Unsigned; Electrobun's own installer**: the release artifact is
|
||||||
|
`linux-x64-miniSCP-Setup.tar.gz` — a self-extracting `installer` + README
|
||||||
|
(2.x does the desktop-entry packaging and ships an uninstaller); no AppImage
|
||||||
|
or `.deb`. Layout unverified until the first 2.x Linux build.
|
||||||
|
|
||||||
|
## First-Linux-boot smoke test
|
||||||
|
|
||||||
|
1. Install the AppIndicator package for your distro (table above), then
|
||||||
|
`bun run start`. The window opens **and actually renders the UI** (a black
|
||||||
|
window on NVIDIA means the DMABUF workaround didn't apply — check for
|
||||||
|
`Failed to create GBM buffer` in the log), and the tray icon appears (or
|
||||||
|
doesn't, on bare GNOME — that's the extension caveat, not a failure).
|
||||||
|
2. Right-click a file row: menu appears at the pointer, Send to Other Panel is
|
||||||
|
first, Permissions is present, Escape / click-outside / arrow keys + Enter all
|
||||||
|
work, and a right-click near the bottom-right flips the menu instead of
|
||||||
|
overflowing.
|
||||||
|
3. Right-click empty panel space (New Folder / New File / Refresh / Show in File
|
||||||
|
Manager) and a column header (checkmarks toggle columns live).
|
||||||
|
4. Copy Path puts the path on the clipboard; Show in File Manager opens the
|
||||||
|
distro's file manager; Open (local file) launches the default app via
|
||||||
|
`xdg-open`.
|
||||||
|
5. local↔remote transfers both directions; external editor round-trip
|
||||||
|
(temp download → save → auto-upload) with the inotify watcher.
|
||||||
|
6. Move to Trash lands the item in the desktop's trash; connections.json is 0600.
|
||||||
|
7. `bun scripts/release.ts --target linux-x64` produces
|
||||||
|
`release/linux-x64/miniSCP-Build<N>-linux-x64-Setup.tar.gz`; unpack it
|
||||||
|
elsewhere, run its `installer`, then launch the installed app (on NVIDIA with
|
||||||
|
`WEBKIT_DISABLE_DMABUF_RENDERER=1`).
|
||||||
|
|
||||||
## Windows known issues (upstream electrobun, documented not fixed)
|
## Windows known issues (upstream electrobun, documented not fixed)
|
||||||
|
|
||||||
- **HiDPI blur** — WebView2 renders at 1x on scaled displays
|
- **HiDPI blur** — WebView2 renders at 1x on scaled displays
|
||||||
(blackboardsh/electrobun#324, open). Most user-visible defect; wait for
|
(blackboardsh/electrobun#324, seen on 1.18.1). Most user-visible defect; 2.x
|
||||||
upstream DPI awareness.
|
lists per-monitor DPI setup among its Windows fixes — re-check on the first
|
||||||
|
2.x Windows build.
|
||||||
- **Notifications** are legacy balloon style (`Shell_NotifyIcon`), not
|
- **Notifications** are legacy balloon style (`Shell_NotifyIcon`), not
|
||||||
Windows toasts.
|
Windows toasts.
|
||||||
- **Unsigned binaries** — Setup.exe and the portable exe both trigger
|
- **Unsigned binaries** — Setup.exe and the portable exe both trigger
|
||||||
SmartScreen / Mark-of-the-Web on first run. No Authenticode support in
|
SmartScreen / Mark-of-the-Web on first run. No Authenticode support (2.x:
|
||||||
electrobun 1.18.1 (signtool post-hook or electrobun-builder are options
|
"Windows release signing is not currently part of Hutch's packaging
|
||||||
later). Installer unpacks to `%LOCALAPPDATA%`, desktop shortcut only — no
|
pipeline"; a signtool post-hook is an option later). Installer unpacks to
|
||||||
Start Menu entry or uninstaller.
|
`%LOCALAPPDATA%`, desktop shortcut only; 2.x installer builds add an
|
||||||
|
uninstaller (`%LOCALAPPDATA%\<identifier>\<install-root>\uninstall.exe`).
|
||||||
- `hiddenInset` degrades to `hidden` on Windows — irrelevant here because
|
- `hiddenInset` degrades to `hidden` on Windows — irrelevant here because
|
||||||
non-mac windows use the native titlebar.
|
non-mac windows use the native titlebar.
|
||||||
- `findInPage` is a `window.find()` shim on WebView2 — irrelevant here, the
|
- `findInPage` is a `window.find()` shim on WebView2 — irrelevant here, the
|
||||||
@@ -48,10 +167,11 @@ Remote (SFTP) paths are ALWAYS POSIX regardless of host or server OS
|
|||||||
Code paths written blind (no Windows box was available); verify in order:
|
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 —
|
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
|
Hutch, the core and the pinned Bun download into `~/.hutch` on first run;
|
||||||
the portable zip appear under `release/win-x64/` (the portable step warns
|
Setup.zip AND the portable zip appear under `release/win-x64/` (the portable
|
||||||
instead of failing if the assembled-app folder layout differs — fix the
|
step warns instead of failing if the assembled-app folder layout differs —
|
||||||
`appDir` path in release.ts if so).
|
fix the `appDir` path in release.ts if so). A v1 0.1.78 install must pick up
|
||||||
|
the published 2.x build through its updater (the bridge release).
|
||||||
2. App boots, tray icon renders colored, window has a native titlebar.
|
2. App boots, tray icon renders colored, window has a native titlebar.
|
||||||
3. Drives dropdown in each panel lists `C:\` (+ others); navigating to a
|
3. Drives dropdown in each panel lists `C:\` (+ others); navigating to a
|
||||||
drive root hides the `'..'` row; the path bar shows consistent `\` paths.
|
drive root hides the `'..'` row; the path bar shows consistent `\` paths.
|
||||||
@@ -60,12 +180,13 @@ Code paths written blind (no Windows box was available); verify in order:
|
|||||||
5. External editor: default app opens via `cmd /c start`; the Choose… picker
|
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 →
|
starts in Program Files and accepts an .exe; remote temp-edit → save →
|
||||||
auto-upload works (directory watcher on NTFS).
|
auto-upload works (directory watcher on NTFS).
|
||||||
6. Context menus: Show in Explorer, Move to Recycle Bin (item restorable from
|
6. Context menus: Send to Other Panel is the first row item; Show in Explorer,
|
||||||
the bin), Copy Path; Permissions ABSENT on local rows, present on remote.
|
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+…".
|
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
|
8. connections.json ACL: `icacls connections.json` shows only the current
|
||||||
user after saving a connection.
|
user after saving a connection.
|
||||||
9. Editor/manager windows open, modal bounce works, About shows from the
|
9. Editor/manager/Settings windows open, modal bounce works, Ctrl+, opens
|
||||||
options popover.
|
Settings and About shows from its About section.
|
||||||
10. Known-issue confirmation: HiDPI blur on a scaled display (expected until
|
10. Known-issue check: HiDPI blur on a scaled display (seen on 1.18.1 — 2.x's
|
||||||
upstream fixes it).
|
per-monitor DPI setup may have fixed it).
|
||||||
|
|||||||
@@ -7,7 +7,18 @@ export default {
|
|||||||
identifier: BRANDING.identifier,
|
identifier: BRANDING.identifier,
|
||||||
version: BRANDING.version,
|
version: BRANDING.version,
|
||||||
},
|
},
|
||||||
|
// Auto-updater feed. The flat artifacts land under /dl on the host (see site/);
|
||||||
|
// miniscp.com is fronted by an external proxy for TLS. baseUrl is baked into
|
||||||
|
// version.json at build time, so only builds made after this takes effect check
|
||||||
|
// for updates. generatePatch (default true) needs build-time network to this URL
|
||||||
|
// to diff against the currently-published version.
|
||||||
|
release: {
|
||||||
|
baseUrl: "https://miniscp.com/dl",
|
||||||
|
},
|
||||||
build: {
|
build: {
|
||||||
|
// Load-bearing: 2.x defaults to Cottontail, which ignores the whole
|
||||||
|
// `bun` block below — the ssh2 externals included.
|
||||||
|
mainProcess: "bun",
|
||||||
bun: {
|
bun: {
|
||||||
entrypoint: "src/bun/index.ts",
|
entrypoint: "src/bun/index.ts",
|
||||||
// ssh2's optional native accelerators must never be bundled; its
|
// ssh2's optional native accelerators must never be bundled; its
|
||||||
|
|||||||
@@ -7,16 +7,15 @@
|
|||||||
"ui"
|
"ui"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun scripts/gen-version.ts && bun scripts/dev.ts",
|
"dev": "bun scripts/gen-version.ts && bunx electrobun prepare && bun scripts/dev.ts",
|
||||||
"dev:ui": "bun run --cwd ui start",
|
"dev:ui": "bun run --cwd ui start",
|
||||||
"build:ui": "bun run --cwd ui build",
|
"build:ui": "bun run --cwd ui build",
|
||||||
"build": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun build",
|
"build": "bun scripts/gen-version.ts && bunx electrobun prepare && bun run build:ui && bunx electrobun build",
|
||||||
"start": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun dev",
|
"start": "bun scripts/gen-version.ts && bunx electrobun prepare && bun run build:ui && bun scripts/start.ts",
|
||||||
"postinstall": "bun scripts/gen-version.ts",
|
"postinstall": "bun scripts/gen-version.ts && bunx electrobun prepare",
|
||||||
"release": "bun scripts/release.ts"
|
"release": "bun scripts/release.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electrobun": "1.18.1",
|
|
||||||
"ssh-config": "^5.2.0",
|
"ssh-config": "^5.2.0",
|
||||||
"ssh2": "^1.17.0"
|
"ssh2": "^1.17.0"
|
||||||
},
|
},
|
||||||
@@ -24,6 +23,7 @@
|
|||||||
"@resvg/resvg-js": "^2.6.2",
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/ssh2": "^1.15.5",
|
"@types/ssh2": "^1.15.5",
|
||||||
|
"electrobun": "2.0.2",
|
||||||
"png-to-ico": "^3.0.2",
|
"png-to-ico": "^3.0.2",
|
||||||
"typescript": "~5.9.3"
|
"typescript": "~5.9.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// Dev orchestrator: starts ng serve (UI hot reload), waits for it, then runs
|
// Dev orchestrator: starts ng serve (UI hot reload), waits for it, then runs
|
||||||
// `electrobun dev --watch` (relaunches the app when bun-side code changes).
|
// `electrobun dev --watch` (relaunches the app when bun-side code changes).
|
||||||
|
import { launchEnv } from "./linux-gpu";
|
||||||
|
|
||||||
const DEV_SERVER_URL = "http://localhost:4200";
|
const DEV_SERVER_URL = "http://localhost:4200";
|
||||||
|
|
||||||
const ui = Bun.spawn(["bun", "run", "--cwd", "ui", "start"], {
|
const ui = Bun.spawn(["bun", "run", "--cwd", "ui", "start"], {
|
||||||
@@ -27,7 +29,7 @@ console.log("Angular dev server is up, launching Electrobun ...");
|
|||||||
const app = Bun.spawn(["bunx", "electrobun", "dev", "--watch"], {
|
const app = Bun.spawn(["bunx", "electrobun", "dev", "--watch"], {
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
stderr: "inherit",
|
stderr: "inherit",
|
||||||
env: { ...process.env, DEV_SERVER_URL },
|
env: launchEnv({ DEV_SERVER_URL }),
|
||||||
});
|
});
|
||||||
|
|
||||||
function shutdown() {
|
function shutdown() {
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// PORTABILITY: WebKitGTK's DMABUF renderer cannot allocate GBM buffers on the
|
||||||
|
// NVIDIA proprietary driver — the app window paints BLACK and stderr fills with
|
||||||
|
// "Failed to create GBM buffer of size WxH: Invalid argument" (usually with an
|
||||||
|
// "X11 Error: GLXBadWindow" alongside). Disabling that renderer path is the
|
||||||
|
// standard upstream workaround.
|
||||||
|
//
|
||||||
|
// It has to be in the environment BEFORE the app process starts: WebKit reads
|
||||||
|
// it when it forks its web process, and Bun's `process.env` writes do NOT
|
||||||
|
// propagate to children (verified — a child sees the variable unset), so the
|
||||||
|
// app cannot set this for itself from inside src/bun/. Only the dev launch
|
||||||
|
// paths (`bun run dev` / `bun run start`) inject it; an installed build (the
|
||||||
|
// official Linux Setup installer) needs the user to set
|
||||||
|
// WEBKIT_DISABLE_DMABUF_RENDERER=1 themselves on NVIDIA.
|
||||||
|
//
|
||||||
|
// Scoped to NVIDIA so Intel/AMD keep hardware compositing, and an explicit
|
||||||
|
// value from the user's environment always wins.
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
|
||||||
|
export function needsDmabufWorkaround(): boolean {
|
||||||
|
return (
|
||||||
|
process.platform === "linux" &&
|
||||||
|
process.env.WEBKIT_DISABLE_DMABUF_RENDERER === undefined &&
|
||||||
|
existsSync("/proc/driver/nvidia")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `process.env` plus the workaround, ready to hand to Bun.spawn. */
|
||||||
|
export function launchEnv(extra: Record<string, string> = {}): Record<string, string | undefined> {
|
||||||
|
return {
|
||||||
|
...process.env,
|
||||||
|
...(needsDmabufWorkaround() ? { WEBKIT_DISABLE_DMABUF_RENDERER: "1" } : {}),
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,22 +1,21 @@
|
|||||||
// Build one release artifact for one target, on the matching host:
|
// Build one release artifact for one target, on the matching host:
|
||||||
// bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64]
|
// bun scripts/release.ts [--target mac-arm64|win-x64|linux-x64]
|
||||||
// Default target = the host. electrobun cannot cross-compile OSes (its
|
// Default target = the host. electrobun cannot cross-compile (Hutch builds for
|
||||||
// packaging needs host tools: codesign/hdiutil on mac, rcedit/PowerShell on
|
// its host OS and arch only; packaging needs host tools: codesign/hdiutil on
|
||||||
// Windows), so win-x64 must run on a Windows machine. mac-x64 IS buildable on
|
// mac, rcedit/PowerShell on Windows), so win-x64 must run on a Windows machine.
|
||||||
// an arm64 Mac: we fetch electrobun's x64 CLI binary and run it under
|
// There is no mac-x64: Electrobun 2.x publishes no macOS x64 core at all.
|
||||||
// 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>.
|
||||||
// Artifacts land in release/<target>/miniSCP-Build<N>-<target>.<ext>.
|
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
||||||
import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
const ROOT = path.join(import.meta.dir, "..");
|
const ROOT = path.join(import.meta.dir, "..");
|
||||||
const TARGETS = ["mac-arm64", "mac-x64", "win-x64"] as const;
|
const TARGETS = ["mac-arm64", "win-x64", "linux-x64"] as const;
|
||||||
type Target = (typeof TARGETS)[number];
|
type Target = (typeof TARGETS)[number];
|
||||||
|
|
||||||
function hostTarget(): Target {
|
function hostTarget(): Target {
|
||||||
if (process.platform === "win32") return "win-x64";
|
if (process.platform === "win32") return "win-x64";
|
||||||
return process.arch === "arm64" ? "mac-arm64" : "mac-x64";
|
if (process.platform === "linux") return "linux-x64";
|
||||||
|
return "mac-arm64";
|
||||||
}
|
}
|
||||||
|
|
||||||
function fail(message: string): never {
|
function fail(message: string): never {
|
||||||
@@ -37,20 +36,25 @@ function run(cmd: string[], opts: { cwd?: string } = {}): void {
|
|||||||
// ---- target selection + host guards ----
|
// ---- target selection + host guards ----
|
||||||
|
|
||||||
const argIndex = process.argv.indexOf("--target");
|
const argIndex = process.argv.indexOf("--target");
|
||||||
const requested = (argIndex >= 0 ? process.argv[argIndex + 1] : hostTarget()) as Target;
|
const arg = argIndex >= 0 ? process.argv[argIndex + 1] : hostTarget();
|
||||||
if (!TARGETS.includes(requested)) fail(`unknown target "${requested}" (${TARGETS.join(", ")})`);
|
if (arg === "mac-x64") fail("mac-x64 was dropped — Electrobun 2.x publishes no macOS x64 core.");
|
||||||
|
if (!TARGETS.includes(arg as Target)) fail(`unknown target "${arg}" (${TARGETS.join(", ")})`);
|
||||||
|
const requested = arg as Target;
|
||||||
|
|
||||||
if (requested === "win-x64" && process.platform !== "win32") {
|
if (requested === "win-x64" && process.platform !== "win32") {
|
||||||
fail("win-x64 must be built on a Windows machine/runner — electrobun cannot cross-compile.");
|
fail("win-x64 must be built on a Windows machine/runner — electrobun cannot cross-compile.");
|
||||||
}
|
}
|
||||||
if (requested.startsWith("mac") && process.platform !== "darwin") {
|
if (requested === "linux-x64" && process.platform !== "linux") {
|
||||||
fail(`${requested} must be built on macOS.`);
|
fail("linux-x64 must be built on a Linux machine/runner — electrobun cannot cross-compile.");
|
||||||
|
}
|
||||||
|
if (requested === "mac-arm64" && (process.platform !== "darwin" || process.arch !== "arm64")) {
|
||||||
|
fail("mac-arm64 must be built on an Apple Silicon Mac (Electrobun 2.x has no macOS x64 build).");
|
||||||
}
|
}
|
||||||
const viaRosetta = requested === "mac-x64" && process.arch === "arm64";
|
|
||||||
|
|
||||||
// ---- prepare: version, icons, UI bundle ----
|
// ---- prepare: version, devkit, icons, UI bundle ----
|
||||||
|
|
||||||
run(["bun", "scripts/gen-version.ts"]);
|
run(["bun", "scripts/gen-version.ts"]);
|
||||||
|
run(["bunx", "electrobun", "prepare"]);
|
||||||
run(["bun", "scripts/gen-icons.ts"]);
|
run(["bun", "scripts/gen-icons.ts"]);
|
||||||
run(["bun", "run", "--cwd", "ui", "build"]);
|
run(["bun", "run", "--cwd", "ui", "build"]);
|
||||||
|
|
||||||
@@ -58,62 +62,43 @@ const { BUILD } = await import("../src/shared/version.gen");
|
|||||||
const { BRANDING } = await import("../src/shared/branding");
|
const { BRANDING } = await import("../src/shared/branding");
|
||||||
const buildName = `Build${BUILD.number}`;
|
const buildName = `Build${BUILD.number}`;
|
||||||
|
|
||||||
// ---- electrobun build (native, or x64 CLI under Rosetta) ----
|
// ---- electrobun build ----
|
||||||
|
|
||||||
async function x64Cli(): Promise<string> {
|
// Start from an empty artifacts/: site/deploy.sh publishes it verbatim (the
|
||||||
const pkg = await Bun.file(path.join(ROOT, "node_modules", "electrobun", "package.json")).json();
|
// updater contract), so an earlier build's files must not ride along.
|
||||||
const dir = path.join(os.homedir(), ".cache", "miniscp", `electrobun-cli-x64-${pkg.version}`);
|
const artifactsDir = path.join(ROOT, "artifacts");
|
||||||
const bin = path.join(dir, "electrobun");
|
rmSync(artifactsDir, { recursive: true, force: true });
|
||||||
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"]);
|
run(["bunx", "electrobun", "build", "--env=stable"]);
|
||||||
}
|
|
||||||
|
|
||||||
// ---- collect artifacts ----
|
// ---- collect artifacts ----
|
||||||
|
|
||||||
const [osName, archName] = requested === "win-x64" ? ["win", "x64"] : ["macos", requested.slice(4)];
|
const [osName, archName] =
|
||||||
const prefix = `stable-${osName}-${archName}-`;
|
requested === "win-x64" ? ["win", "x64"] : requested === "linux-x64" ? ["linux", "x64"] : ["macos", "arm64"];
|
||||||
const artifactsDir = path.join(ROOT, "artifacts");
|
// 2.x installers carry no channel prefix (macos-arm64-miniSCP.dmg); only the
|
||||||
|
// update feed (stable-<os>-<arch>-*) keeps it, for v1-compatible clients.
|
||||||
|
const prefix = `${osName}-${archName}-`;
|
||||||
const outDir = path.join(ROOT, "release", requested);
|
const outDir = path.join(ROOT, "release", requested);
|
||||||
mkdirSync(outDir, { recursive: true });
|
mkdirSync(outDir, { recursive: true });
|
||||||
|
|
||||||
const produced = readdirSync(artifactsDir).filter(
|
const produced = readdirSync(artifactsDir).filter(
|
||||||
(f) => f.startsWith(prefix) && f.includes(BRANDING.appName),
|
(f) => f.startsWith(prefix) && f.includes(BRANDING.appName),
|
||||||
);
|
);
|
||||||
if (!produced.length) fail(`no ${prefix}* artifacts found in artifacts/ — did the build run?`);
|
if (!produced.length) fail(`no ${prefix}* installer found in artifacts/ — did the build run?`);
|
||||||
|
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
for (const file of produced) {
|
for (const file of produced) {
|
||||||
const ext = file.endsWith(".dmg") ? ".dmg" : file.endsWith("-Setup.zip") ? "-Setup.zip" : null;
|
const ext = [".dmg", "-Setup.zip", "-Setup.tar.gz"].find((e) => file.endsWith(e));
|
||||||
if (!ext) continue; // skip the raw .app.tar.zst + update.json
|
if (!ext) continue;
|
||||||
const name = `${BRANDING.appName}-${buildName}-${requested}${ext}`;
|
const name = `${BRANDING.appName}-${buildName}-${requested}${ext}`;
|
||||||
cpSync(path.join(artifactsDir, file), path.join(outDir, name));
|
cpSync(path.join(artifactsDir, file), path.join(outDir, name));
|
||||||
out.push(name);
|
out.push(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Windows: also produce a portable zip from the assembled app folder (the
|
// Windows: also zip the assembled app folder — the portable build (unzip
|
||||||
// Setup.exe is an installer; portable = unzip anywhere and run). Best-effort:
|
// anywhere and run) next to the Setup installer. Best-effort: the folder
|
||||||
// the folder layout is confirmed by the first real Windows build.
|
// layout is confirmed by the first real 2.x Windows build.
|
||||||
if (requested === "win-x64") {
|
if (requested === "win-x64") {
|
||||||
const appDir = path.join(ROOT, "build", `stable-${osName}-${archName}`, BRANDING.appName);
|
const appDir = path.join(ROOT, "build", "stable-win-x64", BRANDING.appName);
|
||||||
if (existsSync(appDir)) {
|
if (existsSync(appDir)) {
|
||||||
const portable = path.join(outDir, `${BRANDING.appName}-${buildName}-win-x64-portable.zip`);
|
const portable = path.join(outDir, `${BRANDING.appName}-${buildName}-win-x64-portable.zip`);
|
||||||
run(["powershell", "-Command", `Compress-Archive -Force -Path '${appDir}\\*' -DestinationPath '${portable}'`]);
|
run(["powershell", "-Command", `Compress-Archive -Force -Path '${appDir}\\*' -DestinationPath '${portable}'`]);
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// `bun run start`: launch the app against the prebuilt Angular bundle (no ng
|
||||||
|
// serve / hot reload — that's `bun run dev`). Exists as a script rather than a
|
||||||
|
// bare `bunx electrobun dev` in package.json so the Linux GPU workaround can be
|
||||||
|
// injected into the environment before the app process starts.
|
||||||
|
import { launchEnv } from "./linux-gpu";
|
||||||
|
|
||||||
|
const app = Bun.spawn(["bunx", "electrobun", "dev"], {
|
||||||
|
stdout: "inherit",
|
||||||
|
stderr: "inherit",
|
||||||
|
env: launchEnv(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function shutdown(): void {
|
||||||
|
app.kill();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
|
|
||||||
|
process.exit(await app.exited);
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# miniSCP landing page + artifact host.
|
||||||
|
# Local / internal, plain HTTP on :80 (no domain, no TLS).
|
||||||
|
# Deploy target is /srv/miniscp — see site/README.md.
|
||||||
|
|
||||||
|
:80 {
|
||||||
|
root * /srv/miniscp
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
# Flat artifact feed: these files are BOTH the human downloads AND (later) the
|
||||||
|
# electrobun updater baseUrl. Publishing a build = drop its artifacts/ files here;
|
||||||
|
# stable-named installers overwrite in place, hashed .patch files accumulate.
|
||||||
|
handle /dl/* {
|
||||||
|
# Incremental update patches have unique hashed names → cache forever.
|
||||||
|
@patch path *.patch
|
||||||
|
header @patch Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
|
||||||
|
# The updater feed must always reflect the newest published version.
|
||||||
|
@feed path *update.json
|
||||||
|
header @feed Cache-Control "no-cache"
|
||||||
|
|
||||||
|
# Installers/payloads keep stable names but get overwritten each release.
|
||||||
|
@pkg path *.dmg *.zip *.tar.zst *.tar.gz
|
||||||
|
header @pkg Cache-Control "public, max-age=300"
|
||||||
|
|
||||||
|
# browse => directory listing; file_server serves range requests
|
||||||
|
# (resumable large .dmg downloads) by default.
|
||||||
|
file_server browse
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static landing page + assets. No SPA index fallback: unknown paths 404.
|
||||||
|
handle {
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# miniSCP site
|
||||||
|
|
||||||
|
The all-in-one landing page **and** download host for miniSCP. One static page, one flat
|
||||||
|
download folder, served by [Caddy](https://caddyserver.com). No JS, no build step.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
site/
|
||||||
|
index.html # the landing page (pure static HTML/CSS)
|
||||||
|
Caddyfile # local :80 server config
|
||||||
|
deploy.sh # copy a build's artifacts into the download folder
|
||||||
|
assets/ # images (logo, screenshots) — dropped in as they're ready
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploys to `/srv/miniscp` on the server:
|
||||||
|
|
||||||
|
```
|
||||||
|
/srv/miniscp/
|
||||||
|
index.html
|
||||||
|
assets/
|
||||||
|
dl/ # flat folder of all artifacts (the downloads + future updater feed)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# from the repo root, on the server
|
||||||
|
sudo mkdir -p /srv/miniscp/dl
|
||||||
|
sudo cp site/index.html /srv/miniscp/
|
||||||
|
sudo cp -r site/assets /srv/miniscp/ # once assets exist
|
||||||
|
|
||||||
|
caddy run --config site/Caddyfile --adapter caddyfile
|
||||||
|
# or install as a service: sudo caddy start --config site/Caddyfile
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://<host>/`.
|
||||||
|
|
||||||
|
## Publish a build
|
||||||
|
|
||||||
|
electrobun can't cross-compile, so each OS/arch is built on its own machine. On each build
|
||||||
|
machine, after producing artifacts, drop them into the shared `dl/` folder:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun scripts/release.ts # or --target win-x64 / linux-x64
|
||||||
|
site/deploy.sh # -> /srv/miniscp/dl
|
||||||
|
```
|
||||||
|
|
||||||
|
Serving on a different box than you build on? Stage locally, then sync:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MINISCP_DL_DIR=./_dl site/deploy.sh
|
||||||
|
rsync -av ./_dl/ user@host:/srv/miniscp/dl/
|
||||||
|
```
|
||||||
|
|
||||||
|
That's the whole workflow: **build, drop the artifacts, done.** Installers keep stable names and
|
||||||
|
overwrite in place, so the download links on the page never change.
|
||||||
|
|
||||||
|
> **Don't delete old `*.patch` files.** They're the incremental-update trail for the
|
||||||
|
> built-in updater — removing them forces older installs to re-download the full app.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Downloads live under `/dl/` and are browsable at `http://<host>/dl/`.
|
||||||
|
- The page links to fixed, stable filenames (`macos-arm64-miniSCP.dmg`, `win-x64-miniSCP-Setup.zip`,
|
||||||
|
…) so it stays static and always points at the latest build. Electrobun 2.x names installers
|
||||||
|
without the `stable-` prefix; only the updater feed (`stable-<os>-<arch>-update.json`, `.tar.zst`,
|
||||||
|
`.patch`) keeps it. Installers left over under the old v1 `stable-…` names are stale — delete them.
|
||||||
|
- Builds are currently unsigned — the FAQ on the page covers the macOS `xattr` step and the
|
||||||
|
Windows SmartScreen prompt.
|
||||||
|
- `/dl/` is also the electrobun auto-updater's `release.baseUrl`.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Image assets
|
||||||
|
|
||||||
|
Drop images here as they're ready; the page references them by relative path.
|
||||||
|
|
||||||
|
Planned slots (commented into `../index.html`):
|
||||||
|
|
||||||
|
- `logo.svg` — hero logo above the wordmark (`.logo`, 128×128). Source lives at repo
|
||||||
|
`assets/logo.svg`.
|
||||||
|
- `screenshot.png` — product shot under the intro paragraphs (`.shot`).
|
||||||
|
|
||||||
|
To wire one up, uncomment the matching `<img>` in `index.html`.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
# Publish this machine's built artifacts into the served flat download folder.
|
||||||
|
#
|
||||||
|
# Run from the repo root AFTER building for this host's target, e.g.:
|
||||||
|
# bun scripts/release.ts # or --target win-x64 / linux-x64
|
||||||
|
# site/deploy.sh
|
||||||
|
#
|
||||||
|
# electrobun cannot cross-compile, so each OS/arch is built (and deployed) on its
|
||||||
|
# own machine; every run just adds/overwrites that target's files in one flat dir.
|
||||||
|
# Installers keep stable names (overwrite in place); .patch files accumulate — never
|
||||||
|
# delete old ones, or users on older versions lose their incremental update trail.
|
||||||
|
#
|
||||||
|
# Serving locally? Leave MINISCP_DL_DIR unset (defaults to /srv/miniscp/dl).
|
||||||
|
# Serving on a remote box? Point MINISCP_DL_DIR at a local staging dir, then rsync:
|
||||||
|
# MINISCP_DL_DIR=./_dl site/deploy.sh
|
||||||
|
# rsync -av ./_dl/ user@host:/srv/miniscp/dl/
|
||||||
|
|
||||||
|
dl="${MINISCP_DL_DIR:-/srv/miniscp/dl}"
|
||||||
|
mkdir -p "$dl"
|
||||||
|
|
||||||
|
# electrobun's flat artifacts, verbatim — the updater contract, never rename them:
|
||||||
|
# the update feed (stable-<os>-<arch>-update.json, .tar.zst, .patch) plus the
|
||||||
|
# installers, which 2.x names without the channel prefix (macos-arm64-miniSCP.dmg,
|
||||||
|
# win-x64-miniSCP-Setup.zip). release.ts empties artifacts/ first, so it holds
|
||||||
|
# exactly this build.
|
||||||
|
if compgen -G 'artifacts/*' >/dev/null; then
|
||||||
|
cp -f artifacts/* "$dl"/
|
||||||
|
else
|
||||||
|
echo "deploy: WARN — artifacts/ is empty (did scripts/release.ts run?)" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Windows portable zip is a release.ts extra (build-numbered) → give it a stable name.
|
||||||
|
port=$(ls -1 release/win-x64/*-win-x64-portable.zip 2>/dev/null | head -n1 || true)
|
||||||
|
if [ -n "$port" ]; then
|
||||||
|
cp -f "$port" "$dl/stable-win-x64-miniSCP-portable.zip"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Published to $dl:"
|
||||||
|
ls -1 "$dl"
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>miniSCP: a dual-pane SFTP file manager</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Lucida Console', Menlo, Consolas, monospace;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #111;
|
||||||
|
background-color: white;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 50px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordmark {
|
||||||
|
font-size: 64px;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: -2px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordmark .lo {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
color: #555;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image slot for the logo — drop assets/logo.svg in later.
|
||||||
|
<img class="logo" src="assets/logo.svg" alt="miniSCP"> */
|
||||||
|
.logo {
|
||||||
|
width: 128px;
|
||||||
|
height: 128px;
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-container {
|
||||||
|
font-size: 22px;
|
||||||
|
margin: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-container a {
|
||||||
|
text-decoration: none;
|
||||||
|
margin: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
letter-spacing: -9px;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
height: 2px;
|
||||||
|
background-color: lightgrey;
|
||||||
|
width: 100%;
|
||||||
|
margin: 40px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feat {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feat b {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screenshot slot — drop assets/screenshot.png in later.
|
||||||
|
<img class="shot" src="assets/screenshot.png" alt="miniSCP screenshot"> */
|
||||||
|
.shot {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 20px auto;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl {
|
||||||
|
flex: 1 1 200px;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 2px solid #111;
|
||||||
|
padding: 14px 18px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl:hover {
|
||||||
|
background: #111;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl small {
|
||||||
|
display: block;
|
||||||
|
color: #888;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl:hover small {
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browse {
|
||||||
|
color: #888;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faqtable dt {
|
||||||
|
margin-bottom: 0.4em;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faqtable dd {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-bottom: 1.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: #f2f2f2;
|
||||||
|
padding: 1px 5px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="landing">
|
||||||
|
<!-- logo image slot goes here later -->
|
||||||
|
<h1 class="wordmark">mini<span class="lo">SCP</span></h1>
|
||||||
|
<p class="tagline">A dual-pane SFTP file manager.<br>Same tool you know, only a little different.</p>
|
||||||
|
<div class="links-container">
|
||||||
|
<a href="#different">what's different</a><span class="separator">|</span>
|
||||||
|
<a href="#download">download</a><span class="separator">|</span>
|
||||||
|
<a href="#faq">FAQ</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<h2 id="different">Same tool you know, only a little different</h2>
|
||||||
|
<p>If you've ever used a two-pane file manager to move files over SFTP, you already know how to
|
||||||
|
use miniSCP. Two panels, a connection on each side, send between them. Nothing to relearn.</p>
|
||||||
|
|
||||||
|
<p>The difference is in the corners. Both panels are fully symmetric — either side can be
|
||||||
|
your local disk or any saved server — so sending isn't only up or down. You can move files
|
||||||
|
<b>straight from one server to another</b>, remote to remote, without staging them on your
|
||||||
|
machine first.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>It's the same familiar tool, sanded down where the old ones snag.</p>
|
||||||
|
|
||||||
|
<!-- screenshot slot goes here later -->
|
||||||
|
|
||||||
|
<p><b>What's different:</b></p>
|
||||||
|
|
||||||
|
<p class="feat"><b>Remote → remote.</b> Send straight between two servers — nothing
|
||||||
|
staged on your machine.</p>
|
||||||
|
<p class="feat"><b>Symmetric panels.</b> Either side is your local disk or any saved server. Every
|
||||||
|
panel is equal.</p>
|
||||||
|
<p class="feat"><b>Knows your servers already.</b> Reads the hosts from your
|
||||||
|
<code>~/.ssh/config</code>, and imports the saved sessions from your previous client.
|
||||||
|
</p>
|
||||||
|
<p class="feat"><b>Themes.</b> Light, dark, or follow the system — with a set of accent
|
||||||
|
tones.</p>
|
||||||
|
<p class="feat"><b>macOS and Windows.</b> macOS is the primary target; Windows 11+ (x64) is in
|
||||||
|
beta.</p>
|
||||||
|
|
||||||
|
|
||||||
|
<p><b>What's the same:</b></p>
|
||||||
|
|
||||||
|
<p class="feat"><b>Tabs that keep working.</b> Open several sessions at once; transfers keep
|
||||||
|
running in background tabs.</p>
|
||||||
|
<p class="feat"><b>Edit in place.</b> Open a remote file in your editor — every save uploads
|
||||||
|
itself, automatically, until you disconnect.</p>
|
||||||
|
<p class="feat"><b>Won't eat your files.</b> A multi-layer guard catches when the source and
|
||||||
|
destination are secretly the same file, even behind NAT or split-DNS, before it can truncate
|
||||||
|
anything.</p>
|
||||||
|
<p class="feat"><b>Honest overwrites.</b> Compares size and date, tells you which copy is newer,
|
||||||
|
and lets you replace-all or skip-all in one go.</p>
|
||||||
|
<p class="feat"><b>A queue with an ETA.</b> Per-file speed and estimate, cancel anytime.</p>
|
||||||
|
<p class="feat"><b>Permissions.</b> Rename, delete, create folders and files, and change
|
||||||
|
permissions (chmod) on remote files.</p>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<h2 id="download">Download</h2>
|
||||||
|
<p>Always the latest stable build.</p>
|
||||||
|
<div class="downloads">
|
||||||
|
<a class="dl" href="/dl/macos-arm64-miniSCP.dmg">macOS<small>Apple Silicon</small></a>
|
||||||
|
<a class="dl" href="/dl/win-x64-miniSCP-Setup.zip">Windows<small>Installer</small></a>
|
||||||
|
<a class="dl" href="/dl/stable-win-x64-miniSCP-portable.zip">Windows<small>Portable</small></a>
|
||||||
|
</div>
|
||||||
|
<p class="browse">Looking for everything? <a href="/dl/">browse all files</a>.</p>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<h2 id="faq">FAQ</h2>
|
||||||
|
<dl class="faqtable">
|
||||||
|
<dt>Which platforms are supported?</dt>
|
||||||
|
<dd>macOS (Apple Silicon) and Windows 11+ (x64). macOS is the primary target;
|
||||||
|
Windows is currently in beta. Intel Macs are not supported.</dd>
|
||||||
|
|
||||||
|
<dt>macOS says the app is "damaged and can't be opened".</dt>
|
||||||
|
<dd>The builds aren't code-signed yet, so macOS quarantines them on download. Clear the
|
||||||
|
quarantine flag once and it opens normally:
|
||||||
|
<br><code>xattr -cr /Applications/miniSCP.app</code>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Windows warns me with a blue SmartScreen box.</dt>
|
||||||
|
<dd>The Windows builds aren't signed yet either, so SmartScreen flags them on first run. Choose
|
||||||
|
"More info" → "Run anyway". The installer unpacks to your local app data; the portable
|
||||||
|
zip runs from anywhere.</dd>
|
||||||
|
|
||||||
|
<dt>Can I bring my existing servers over?</dt>
|
||||||
|
<dd>Yes. miniSCP reads the hosts from your <code>~/.ssh/config</code> automatically, and it can
|
||||||
|
import the saved sessions exported from your previous file-transfer client.</dd>
|
||||||
|
|
||||||
|
<dt>Installer or portable on Windows?</dt>
|
||||||
|
<dd>The installer sets miniSCP up in your user profile with a desktop shortcut. The portable zip
|
||||||
|
is just the app — unzip it anywhere and run it, no install.</dd>
|
||||||
|
</dl>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import { ContextMenu, Utils, type ApplicationMenuItemConfig } from "electrobun/bun";
|
|
||||||
import type { ColumnKey } from "../shared/types";
|
|
||||||
|
|
||||||
/** Optional list columns, in display order (Name is fixed). */
|
|
||||||
const COLUMNS: { key: ColumnKey; label: string }[] = [
|
|
||||||
{ key: "size", label: "Size" },
|
|
||||||
{ key: "mtime", label: "Modified" },
|
|
||||||
{ key: "kind", label: "Kind" },
|
|
||||||
{ key: "rights", label: "Rights" },
|
|
||||||
{ key: "owner", label: "Owner" },
|
|
||||||
{ 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;
|
|
||||||
type: "file" | "dir" | "symlink";
|
|
||||||
isLocal: boolean;
|
|
||||||
hasEntry: boolean; // false = right-click on empty panel space
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Native right-click menus for the file panels. Local-only items run directly
|
|
||||||
* here via Utils (reveal, open with default app, copy path); everything else
|
|
||||||
* forwards to the webview through the menuAction channel — the UI kept its own
|
|
||||||
* context (which panel/row was clicked) when it requested the menu.
|
|
||||||
*/
|
|
||||||
export class PanelContextMenu {
|
|
||||||
private context: RowMenuContext | null = null;
|
|
||||||
/** Forwards actions the UI must handle (ctx-open, ctx-delete, ...). */
|
|
||||||
onAction: (action: string) => void = () => {};
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
ContextMenu.on("context-menu-clicked", (event) => {
|
|
||||||
const action = (event as { data?: { action?: string } }).data?.action;
|
|
||||||
if (!action) return;
|
|
||||||
// column toggles carry no row context
|
|
||||||
if (action.startsWith("col:")) {
|
|
||||||
this.onAction(action);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const ctx = this.context;
|
|
||||||
if (!ctx) return;
|
|
||||||
switch (action) {
|
|
||||||
case "ctx-reveal":
|
|
||||||
Utils.showItemInFolder(ctx.path);
|
|
||||||
break;
|
|
||||||
case "ctx-open-default":
|
|
||||||
Utils.openPath(ctx.path);
|
|
||||||
break;
|
|
||||||
case "ctx-copy-path":
|
|
||||||
Utils.clipboardWriteText(ctx.path);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
if (action.startsWith("ctx-")) this.onAction(action);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
show(context: RowMenuContext): void {
|
|
||||||
this.context = context;
|
|
||||||
ContextMenu.showContextMenu(this.build(context));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Column picker for the list headers: checkable toggles per column. */
|
|
||||||
showColumns(visible: ColumnKey[]): void {
|
|
||||||
ContextMenu.showContextMenu(
|
|
||||||
COLUMNS.map(({ key, label }) => ({
|
|
||||||
label,
|
|
||||||
action: `col:${key}`,
|
|
||||||
checked: visible.includes(key),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private build(ctx: RowMenuContext): ApplicationMenuItemConfig[] {
|
|
||||||
const items: ApplicationMenuItemConfig[] = [];
|
|
||||||
|
|
||||||
if (!ctx.hasEntry || ctx.name === "..") {
|
|
||||||
// empty space (or the parent row): folder-level actions
|
|
||||||
items.push({ label: "New Folder", action: "ctx-new-folder" });
|
|
||||||
items.push({ label: "New File", action: "ctx-new-file" });
|
|
||||||
items.push({ type: "separator" });
|
|
||||||
items.push({ label: "Refresh", action: "ctx-refresh" });
|
|
||||||
if (ctx.isLocal) {
|
|
||||||
items.push({ type: "separator" });
|
|
||||||
items.push({ label: REVEAL_LABEL, action: "ctx-reveal" });
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ctx.type === "dir") {
|
|
||||||
items.push({ label: "Open", action: "ctx-open" });
|
|
||||||
} else {
|
|
||||||
if (ctx.isLocal) {
|
|
||||||
items.push({ label: "Open", action: "ctx-open-default", tooltip: "Open with the default application" });
|
|
||||||
}
|
|
||||||
items.push({ label: "Edit", action: "ctx-edit", accelerator: "e" });
|
|
||||||
}
|
|
||||||
items.push({ label: "Copy to Other Panel", action: "ctx-copy" });
|
|
||||||
items.push({ label: "Rename", action: "ctx-rename" });
|
|
||||||
// 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_LABEL, action: "ctx-reveal" });
|
|
||||||
}
|
|
||||||
items.push({ type: "separator" });
|
|
||||||
if (ctx.isLocal) {
|
|
||||||
items.push({
|
|
||||||
label: TRASH_LABEL,
|
|
||||||
action: "ctx-trash",
|
|
||||||
...(process.platform === "darwin"
|
|
||||||
? { tooltip: "Finder's Restore is not available for items trashed from here" }
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
items.push({ label: "Delete", action: "ctx-delete" });
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { t } from "../i18n";
|
||||||
import { LocalProvider } from "./local";
|
import { LocalProvider } from "./local";
|
||||||
import type { FileSystemProvider } from "./provider";
|
import type { FileSystemProvider } from "./provider";
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ export class SessionRegistry {
|
|||||||
|
|
||||||
get(sessionId: string): FileSystemProvider {
|
get(sessionId: string): FileSystemProvider {
|
||||||
const provider = this.sessions.get(sessionId);
|
const provider = this.sessions.get(sessionId);
|
||||||
if (!provider) throw new Error(`Session "${sessionId}" is not connected`);
|
if (!provider) throw new Error(t("errors.sessionNotConnected", { id: sessionId }));
|
||||||
return provider;
|
return provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ export class SessionRegistry {
|
|||||||
|
|
||||||
async remove(sessionId: string): Promise<void> {
|
async remove(sessionId: string): Promise<void> {
|
||||||
if (sessionId === LOCAL_SESSION_ID)
|
if (sessionId === LOCAL_SESSION_ID)
|
||||||
throw new Error("The local session cannot be disconnected");
|
throw new Error(t("errors.localCannotDisconnect"));
|
||||||
const provider = this.sessions.get(sessionId);
|
const provider = this.sessions.get(sessionId);
|
||||||
if (!provider) return;
|
if (!provider) return;
|
||||||
this.sessions.delete(sessionId);
|
this.sessions.delete(sessionId);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { Client, type ConnectConfig, type SFTPWrapper } from "ssh2";
|
import { Client, type ConnectConfig, type SFTPWrapper } from "ssh2";
|
||||||
import type { ConnectionConfig, EntryType, FileEntry } from "../../shared/types";
|
import type { ConnectionConfig, EntryType, FileEntry } from "../../shared/types";
|
||||||
|
import { t } from "../i18n";
|
||||||
import { expandTilde } from "../store/ssh-config";
|
import { expandTilde } from "../store/ssh-config";
|
||||||
import type { FileSystemProvider } from "./provider";
|
import type { FileSystemProvider } from "./provider";
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ export class SftpProvider implements FileSystemProvider {
|
|||||||
connectConfig.password = config.auth.password;
|
connectConfig.password = config.auth.password;
|
||||||
break;
|
break;
|
||||||
case "agent":
|
case "agent":
|
||||||
if (!agentSock) throw new Error("ssh-agent not available (SSH_AUTH_SOCK is not set)");
|
if (!agentSock) throw new Error(t("errors.sshAgentUnavailable"));
|
||||||
connectConfig.agent = agentSock;
|
connectConfig.agent = agentSock;
|
||||||
break;
|
break;
|
||||||
case "key": {
|
case "key": {
|
||||||
@@ -73,7 +74,7 @@ export class SftpProvider implements FileSystemProvider {
|
|||||||
connectConfig.privateKey = readFileSync(expandTilde(config.auth.privateKeyPath));
|
connectConfig.privateKey = readFileSync(expandTilde(config.auth.privateKeyPath));
|
||||||
} catch {
|
} catch {
|
||||||
if (!agentSock)
|
if (!agentSock)
|
||||||
throw new Error(`identity file not found: ${config.auth.privateKeyPath}`);
|
throw new Error(t("errors.identityFileNotFound", { path: config.auth.privateKeyPath }));
|
||||||
}
|
}
|
||||||
if (config.auth.passphrase) connectConfig.passphrase = config.auth.passphrase;
|
if (config.auth.passphrase) connectConfig.passphrase = config.auth.passphrase;
|
||||||
else if (agentSock) connectConfig.agent = agentSock;
|
else if (agentSock) connectConfig.agent = agentSock;
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { type FSWatcher, watch } from "node:fs";
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 150;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watches a set of local directories with node's fs.watch and fires onChange
|
||||||
|
* (debounced per dir — fs.watch emits bursts) when their contents change. Used
|
||||||
|
* to auto-refresh the active tab's local panels instantly; SFTP has no
|
||||||
|
* equivalent, so remote panels are polled UI-side instead.
|
||||||
|
*
|
||||||
|
* onChange is late-bound (assigned after the RPC exists, like AppDeps.events)
|
||||||
|
* so it can push pathRefresh to the main webview.
|
||||||
|
*/
|
||||||
|
export class LocalDirWatcher {
|
||||||
|
onChange: (dir: string) => void = () => {};
|
||||||
|
private readonly handles = new Map<string, FSWatcher>();
|
||||||
|
private readonly debounces = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
/** Replace the watched set: drop handles no longer wanted, open new ones. */
|
||||||
|
setWatched(dirs: string[]): void {
|
||||||
|
const wanted = new Set(dirs);
|
||||||
|
for (const [dir, handle] of this.handles) {
|
||||||
|
if (!wanted.has(dir)) {
|
||||||
|
handle.close();
|
||||||
|
this.handles.delete(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const dir of wanted) {
|
||||||
|
if (this.handles.has(dir)) continue;
|
||||||
|
try {
|
||||||
|
const handle = watch(dir, () => this.fire(dir));
|
||||||
|
// a watched dir that gets deleted/unmounted errors — just drop it
|
||||||
|
handle.on("error", () => this.drop(dir));
|
||||||
|
this.handles.set(dir, handle);
|
||||||
|
} catch {
|
||||||
|
// dir vanished between listing and watching — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fire(dir: string): void {
|
||||||
|
const pending = this.debounces.get(dir);
|
||||||
|
if (pending) clearTimeout(pending);
|
||||||
|
this.debounces.set(
|
||||||
|
dir,
|
||||||
|
setTimeout(() => {
|
||||||
|
this.debounces.delete(dir);
|
||||||
|
this.onChange(dir);
|
||||||
|
}, DEBOUNCE_MS),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private drop(dir: string): void {
|
||||||
|
this.handles.get(dir)?.close();
|
||||||
|
this.handles.delete(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
for (const handle of this.handles.values()) handle.close();
|
||||||
|
this.handles.clear();
|
||||||
|
for (const pending of this.debounces.values()) clearTimeout(pending);
|
||||||
|
this.debounces.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { DICTS, type Messages, resolveLocale } from "../shared/i18n";
|
||||||
|
import type { AppSettings, SupportedLocale } from "../shared/types";
|
||||||
|
|
||||||
|
// Backend-side translator for the native menus and thrown error messages
|
||||||
|
// (the Angular I18nService can't reach OS chrome). The locale is cached and
|
||||||
|
// refreshed from settings (see index.ts wiring); no signals here.
|
||||||
|
let locale: SupportedLocale = "en";
|
||||||
|
|
||||||
|
/** OS locale for the "system" setting, e.g. "es-ES" -> resolves to "es". */
|
||||||
|
function osLocale(): string {
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat().resolvedOptions().locale;
|
||||||
|
} catch {
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLocale(setting: AppSettings["locale"]): void {
|
||||||
|
locale = resolveLocale(setting, osLocale());
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookup(dict: Messages, key: string): string | undefined {
|
||||||
|
let node: string | Messages | undefined = dict;
|
||||||
|
for (const part of key.split(".")) {
|
||||||
|
if (typeof node !== "object") return undefined;
|
||||||
|
node = node[part];
|
||||||
|
}
|
||||||
|
return typeof node === "string" ? node : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Translate a key with {name} interpolation; falls back to English then the key. */
|
||||||
|
export function t(key: string, params?: Record<string, string | number>): string {
|
||||||
|
const value = lookup(DICTS[locale], key) ?? lookup(DICTS.en, key) ?? key;
|
||||||
|
if (!params) return value;
|
||||||
|
return value.replace(/\{(\w+)\}/g, (_, name: string) =>
|
||||||
|
name in params ? String(params[name]) : `{${name}}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +1,22 @@
|
|||||||
import Electrobun, { ApplicationMenu, BrowserWindow, Updater, Utils } from "electrobun/bun";
|
import Electrobun, { ApplicationMenu, BrowserWindow, Updater, Utils } from "electrobun/main";
|
||||||
import { BRANDING } from "../shared/branding";
|
import { BRANDING } from "../shared/branding";
|
||||||
import type { EditorOpenContext } from "../shared/rpc";
|
import type { EditorOpenContext } from "../shared/rpc";
|
||||||
import { effectiveConcurrency, type AppSettings } from "../shared/types";
|
import { effectiveConcurrency, type AppSettings } from "../shared/types";
|
||||||
import { PanelContextMenu } from "./context-menu";
|
|
||||||
import { EditManager } from "./edits";
|
import { EditManager } from "./edits";
|
||||||
import { EditorWindows } from "./editor-windows";
|
import { EditorWindows } from "./editor-windows";
|
||||||
import { SessionRegistry } from "./fs/registry";
|
import { SessionRegistry } from "./fs/registry";
|
||||||
|
import { LocalDirWatcher } from "./fs/watcher";
|
||||||
import { ManagerWindow } from "./manager-window";
|
import { ManagerWindow } from "./manager-window";
|
||||||
|
import { setLocale as setBackendLocale } from "./i18n";
|
||||||
import { applyApplicationMenu } from "./menu";
|
import { applyApplicationMenu } from "./menu";
|
||||||
import { createAppRPC } from "./rpc-handlers";
|
import { createAppRPC } from "./rpc-handlers";
|
||||||
|
import { SettingsWindow } from "./settings-window";
|
||||||
import { ConnectionStore } from "./store/connections";
|
import { ConnectionStore } from "./store/connections";
|
||||||
import { migrateLegacyDataDir } from "./store/paths";
|
import { migrateLegacyDataDir } from "./store/paths";
|
||||||
import { SettingsStore } from "./store/settings";
|
import { SettingsStore } from "./store/settings";
|
||||||
import { AppTray } from "./tray";
|
import { AppTray } from "./tray";
|
||||||
import { TransferEngine } from "./transfers/engine";
|
import { TransferEngine } from "./transfers/engine";
|
||||||
import { buildPreload, showWhenReady } from "./windows";
|
import { buildPreload, chromeOptions, showWhenReady } from "./windows";
|
||||||
|
|
||||||
const PROD_URL = "views://app/index.html";
|
const PROD_URL = "views://app/index.html";
|
||||||
|
|
||||||
@@ -44,24 +46,27 @@ const engine = new TransferEngine(registry);
|
|||||||
const store = new ConnectionStore();
|
const store = new ConnectionStore();
|
||||||
const settings = new SettingsStore();
|
const settings = new SettingsStore();
|
||||||
const edits = new EditManager(registry, engine, settings);
|
const edits = new EditManager(registry, engine, settings);
|
||||||
const contextMenu = new PanelContextMenu();
|
const watcher = new LocalDirWatcher();
|
||||||
const events = {
|
const events = {
|
||||||
sessionClosed: (_info: { sessionId: string; reason: string }) => {},
|
sessionClosed: (_info: { sessionId: string; reason: string }) => {},
|
||||||
connectionsChanged: () => {},
|
connectionsChanged: () => {},
|
||||||
openEditor: (_ctx: EditorOpenContext) => {},
|
openEditor: (_ctx: EditorOpenContext) => {},
|
||||||
settingsChanged: (_settings: AppSettings) => {},
|
settingsChanged: (_settings: AppSettings) => {},
|
||||||
openConnectionManager: () => {},
|
openConnectionManager: () => {},
|
||||||
|
openSettings: () => {},
|
||||||
showAbout: () => {},
|
showAbout: () => {},
|
||||||
|
applyUpdate: async () => {},
|
||||||
};
|
};
|
||||||
const rpc = createAppRPC({ registry, engine, store, settings, edits, contextMenu, events });
|
const rpc = createAppRPC({ registry, engine, store, settings, edits, watcher, events });
|
||||||
|
// late-bound like events: a watched local dir changed -> tell the main webview
|
||||||
|
watcher.onChange = (dir) => rpc.send.pathRefresh({ sessionId: "local", dir });
|
||||||
const url = await getMainViewUrl();
|
const url = await getMainViewUrl();
|
||||||
|
|
||||||
// Loaded before any window exists: the persisted theme is baked into every
|
// Loaded before any window exists: the persisted theme is baked into every
|
||||||
// window's preload so the first paint is already themed (no flash). Kept
|
// window's preload so the first paint is already themed (no flash). Kept
|
||||||
// fresh via the saveSettings flow below.
|
// fresh via the saveSettings flow below.
|
||||||
let currentSettings = await settings.load();
|
let currentSettings = await settings.load();
|
||||||
|
setBackendLocale(currentSettings.locale); // native menus/errors follow the UI language
|
||||||
const isMac = process.platform === "darwin";
|
|
||||||
|
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
title: BRANDING.appName,
|
title: BRANDING.appName,
|
||||||
@@ -72,12 +77,8 @@ const win = new BrowserWindow({
|
|||||||
x: 120,
|
x: 120,
|
||||||
y: 120,
|
y: 120,
|
||||||
},
|
},
|
||||||
// macOS: fuse the tab bar with the titlebar — traffic lights overlay the
|
// frame decision lives in chromeOptions (windows.ts); constructor-only
|
||||||
// UI's h-10 tab row (offset centers them vertically). Other platforms keep
|
...chromeOptions(),
|
||||||
// the native titlebar and the tab bar stays a separate row.
|
|
||||||
...(isMac
|
|
||||||
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
|
||||||
: {}),
|
|
||||||
// lets the UI pick the fused layout + theme before first paint
|
// lets the UI pick the fused layout + theme before first paint
|
||||||
preload: buildPreload("main", currentSettings),
|
preload: buildPreload("main", currentSettings),
|
||||||
rpc,
|
rpc,
|
||||||
@@ -94,6 +95,7 @@ const editors = new EditorWindows({
|
|||||||
currentSettings: () => currentSettings,
|
currentSettings: () => currentSettings,
|
||||||
mainFrame: () => win.getFrame(),
|
mainFrame: () => win.getFrame(),
|
||||||
notifyMain: { pathRefresh: (info) => rpc.send.pathRefresh(info) },
|
notifyMain: { pathRefresh: (info) => rpc.send.pathRefresh(info) },
|
||||||
|
onSettingsSaved: (s, key) => settingsSaved(s, { editor: key }),
|
||||||
});
|
});
|
||||||
const manager = new ManagerWindow({
|
const manager = new ManagerWindow({
|
||||||
store,
|
store,
|
||||||
@@ -106,16 +108,48 @@ const manager = new ManagerWindow({
|
|||||||
connectionsChanged: () => rpc.send.connectionsChanged({}),
|
connectionsChanged: () => rpc.send.connectionsChanged({}),
|
||||||
menuAction: (info) => rpc.send.menuAction(info),
|
menuAction: (info) => rpc.send.menuAction(info),
|
||||||
},
|
},
|
||||||
rebuildMenu: () => void applyApplicationMenu(store),
|
rebuildMenu: () => void applyApplicationMenu(store, () => currentSettings.keybindings),
|
||||||
|
onSettingsSaved: (s) => settingsSaved(s, "manager"),
|
||||||
|
});
|
||||||
|
const settingsWin = new SettingsWindow({
|
||||||
|
settings,
|
||||||
|
url,
|
||||||
|
currentSettings: () => currentSettings,
|
||||||
|
mainFrame: () => win.getFrame(),
|
||||||
|
onSettingsSaved: (s) => settingsSaved(s, "settings"),
|
||||||
|
showAbout: () => showAbout(),
|
||||||
});
|
});
|
||||||
|
|
||||||
events.openEditor = (ctx) => editors.open(ctx);
|
events.openEditor = (ctx) => editors.open(ctx);
|
||||||
events.settingsChanged = (s) => {
|
/**
|
||||||
|
* Every settings save lands here, whichever window made it (editor and manager
|
||||||
|
* windows save too — the scale shortcuts — and the Settings window is nothing
|
||||||
|
* but saves): side effects run once, then every OTHER window follows. The
|
||||||
|
* sender is skipped — it already shows the new values, and an echo arriving
|
||||||
|
* behind a newer local change (a held Cmd+) would drag it backwards.
|
||||||
|
*/
|
||||||
|
function settingsSaved(
|
||||||
|
s: AppSettings,
|
||||||
|
sender: "main" | "manager" | "settings" | { editor: string },
|
||||||
|
): void {
|
||||||
|
engine.setConcurrency(effectiveConcurrency(s));
|
||||||
|
const localeChanged = s.locale !== currentSettings.locale;
|
||||||
|
const keysChanged = JSON.stringify(s.keybindings) !== JSON.stringify(currentSettings.keybindings);
|
||||||
currentSettings = s; // future windows' preloads bake the fresh theme
|
currentSettings = s; // future windows' preloads bake the fresh theme
|
||||||
editors.broadcastSettings(s);
|
if (localeChanged) {
|
||||||
manager.broadcastSettings(s);
|
setBackendLocale(s.locale);
|
||||||
};
|
settingsWin.retitle();
|
||||||
|
}
|
||||||
|
// relabel the native menu in the new language / give it the remapped keys
|
||||||
|
if (localeChanged || keysChanged) void applyApplicationMenu(store, () => currentSettings.keybindings);
|
||||||
|
if (sender !== "main") rpc.send.settingsChanged({ settings: s });
|
||||||
|
if (sender !== "manager") manager.broadcastSettings(s);
|
||||||
|
if (sender !== "settings") settingsWin.broadcastSettings(s);
|
||||||
|
editors.broadcastSettings(s, typeof sender === "object" ? sender.editor : undefined);
|
||||||
|
}
|
||||||
|
events.settingsChanged = (s) => settingsSaved(s, "main");
|
||||||
events.openConnectionManager = () => manager.open();
|
events.openConnectionManager = () => manager.open();
|
||||||
|
events.openSettings = () => settingsWin.open();
|
||||||
|
|
||||||
function showAbout(): void {
|
function showAbout(): void {
|
||||||
const { build } = BRANDING;
|
const { build } = BRANDING;
|
||||||
@@ -134,11 +168,11 @@ events.showAbout = showAbout;
|
|||||||
// and the modal manager has nothing to guard
|
// and the modal manager has nothing to guard
|
||||||
win.on("close", () => {
|
win.on("close", () => {
|
||||||
manager.close();
|
manager.close();
|
||||||
|
settingsWin.close();
|
||||||
editors.closeAllForAppExit();
|
editors.closeAllForAppExit();
|
||||||
});
|
});
|
||||||
|
|
||||||
new AppTray(win);
|
new AppTray(win);
|
||||||
contextMenu.onAction = (action) => rpc.send.menuAction({ action });
|
|
||||||
|
|
||||||
// notify on finished transfers while the window is in the background
|
// notify on finished transfers while the window is in the background
|
||||||
let windowFocused = true;
|
let windowFocused = true;
|
||||||
@@ -159,6 +193,7 @@ engine.onUpdate = (snapshot) => {
|
|||||||
if (
|
if (
|
||||||
!windowFocused &&
|
!windowFocused &&
|
||||||
!editors.anyFocused() &&
|
!editors.anyFocused() &&
|
||||||
|
!settingsWin.isFocused() &&
|
||||||
(snapshot.state === "completed" || snapshot.state === "failed")
|
(snapshot.state === "completed" || snapshot.state === "failed")
|
||||||
) {
|
) {
|
||||||
Utils.showNotification({
|
Utils.showNotification({
|
||||||
@@ -179,11 +214,24 @@ engine.onUpdate = (snapshot) => {
|
|||||||
|
|
||||||
// don't quit out from under running transfers or unsaved editors
|
// don't quit out from under running transfers or unsaved editors
|
||||||
let quitConfirmed = false;
|
let quitConfirmed = false;
|
||||||
|
// Updater.applyUpdate() asks this guard first (2.x), so remember the quit is
|
||||||
|
// update-initiated: "Quit Anyway" must re-run the apply, or a plain quit would
|
||||||
|
// drop the downloaded update.
|
||||||
|
let applyingUpdate = false;
|
||||||
|
events.applyUpdate = async () => {
|
||||||
|
applyingUpdate = true;
|
||||||
|
try {
|
||||||
|
await Updater.applyUpdate();
|
||||||
|
} finally {
|
||||||
|
applyingUpdate = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
Electrobun.events.on("before-quit", (event) => {
|
Electrobun.events.on("before-quit", (event) => {
|
||||||
const transfers = engine.activeJobCount();
|
const transfers = engine.activeJobCount();
|
||||||
const dirtyEditors = editors.dirtyCount();
|
const dirtyEditors = editors.dirtyCount();
|
||||||
if (quitConfirmed || (transfers === 0 && dirtyEditors === 0)) return;
|
if (quitConfirmed || (transfers === 0 && dirtyEditors === 0)) return;
|
||||||
(event as { response: { allow: boolean } }).response = { allow: false };
|
(event as { response: { allow: boolean } }).response = { allow: false };
|
||||||
|
const updating = applyingUpdate; // read now: it clears once the vetoed apply returns
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const parts = [
|
const parts = [
|
||||||
transfers > 0 && `${transfers} transfer${transfers === 1 ? " is" : "s are"} still running`,
|
transfers > 0 && `${transfers} transfer${transfers === 1 ? " is" : "s are"} still running`,
|
||||||
@@ -201,7 +249,10 @@ Electrobun.events.on("before-quit", (event) => {
|
|||||||
});
|
});
|
||||||
if (response === 0) {
|
if (response === 0) {
|
||||||
quitConfirmed = true;
|
quitConfirmed = true;
|
||||||
Utils.quit();
|
// the trailing quit is a no-op once the apply is quitting, and still
|
||||||
|
// quits if it bails (no prepared download)
|
||||||
|
if (updating) void Updater.applyUpdate().finally(() => Utils.quit());
|
||||||
|
else Utils.quit();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
@@ -209,17 +260,24 @@ events.sessionClosed = (info) => {
|
|||||||
rpc.send.sessionClosed(info);
|
rpc.send.sessionClosed(info);
|
||||||
editors.notifySessionClosed(info);
|
editors.notifySessionClosed(info);
|
||||||
};
|
};
|
||||||
events.connectionsChanged = () => void applyApplicationMenu(store);
|
events.connectionsChanged = () => void applyApplicationMenu(store, () => currentSettings.keybindings);
|
||||||
engine.setConcurrency(effectiveConcurrency(currentSettings));
|
engine.setConcurrency(effectiveConcurrency(currentSettings));
|
||||||
|
|
||||||
void applyApplicationMenu(store);
|
void applyApplicationMenu(store, () => currentSettings.keybindings);
|
||||||
ApplicationMenu.on("application-menu-clicked", (event) => {
|
ApplicationMenu.on("application-menu-clicked", (event) => {
|
||||||
const action = (event as { data?: { action?: string } }).data?.action;
|
const action = (event as { data?: { action?: string } }).data?.action;
|
||||||
if (!action) return;
|
if (!action) return;
|
||||||
// ⌘W while an editor window is focused closes that editor (with its dirty
|
// Their pages claim close-window (⌘W) themselves — macOS hands key
|
||||||
// prompt), not a main-window tab. Other menu actions deliberately still
|
// equivalents to the page first — so a close-tab that still arrives while
|
||||||
// target the main window.
|
// the Settings window or an editor is focused is a ⌘W the page let through
|
||||||
|
// (or the menu item clicked): close THAT window (an editor with its dirty
|
||||||
|
// prompt), never a main-window tab behind it. Other menu actions
|
||||||
|
// deliberately still target the main window.
|
||||||
if (action === "close-tab") {
|
if (action === "close-tab") {
|
||||||
|
if (settingsWin.isFocused()) {
|
||||||
|
settingsWin.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const focusedEditor = editors.focusedKey();
|
const focusedEditor = editors.focusedKey();
|
||||||
if (focusedEditor) {
|
if (focusedEditor) {
|
||||||
editors.requestClose(focusedEditor);
|
editors.requestClose(focusedEditor);
|
||||||
@@ -230,13 +288,48 @@ ApplicationMenu.on("application-menu-clicked", (event) => {
|
|||||||
manager.open();
|
manager.open();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action === "settings") {
|
||||||
|
settingsWin.open();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (action === "about") {
|
if (action === "about") {
|
||||||
showAbout();
|
showAbout();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action === "quit") {
|
||||||
|
// Utils.quit() emits before-quit -> the guard runs (transfers/unsaved).
|
||||||
|
// Handled before the manager check so ⌘Q quits even with it open.
|
||||||
|
Utils.quit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
// the manager is modal to the main window — don't drive main behind it
|
// the manager is modal to the main window — don't drive main behind it
|
||||||
if (manager.isOpen()) return;
|
if (manager.isOpen()) return;
|
||||||
|
// everything below acts on the main window: from a focused editor or the
|
||||||
|
// Settings window (⌘T, ⌘N…), bring it forward so the effect is visible
|
||||||
|
if (settingsWin.isFocused() || editors.focusedKey()) win.activate();
|
||||||
rpc.send.menuAction({ action });
|
rpc.send.menuAction({ action });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- auto-updater: check on launch + every 6h, stream status to the tab bar ----
|
||||||
|
// Updater.checkForUpdate() self-guards the dev channel (returns updateAvailable:
|
||||||
|
// false), so the pill never appears during `bun run dev`.
|
||||||
|
Updater.onStatusChange((entry) => {
|
||||||
|
if (entry.status === "download-progress") {
|
||||||
|
rpc.send.updateStatus({ phase: "downloading", progress: entry.details?.progress ?? 0 });
|
||||||
|
} else if (entry.status === "error") {
|
||||||
|
rpc.send.updateStatus({ phase: "error", error: entry.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function runUpdateCheck(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const r = await Updater.checkForUpdate();
|
||||||
|
if (r.updateAvailable) rpc.send.updateStatus({ phase: "available", version: r.version });
|
||||||
|
} catch {
|
||||||
|
// offline / feed unreachable — stay quiet and retry on the next tick
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTimeout(() => void runUpdateCheck(), 5_000); // let the window settle first
|
||||||
|
setInterval(() => void runUpdateCheck(), 6 * 60 * 60 * 1000);
|
||||||
|
|
||||||
console.log(`${BRANDING.appName} started (${url})`);
|
console.log(`${BRANDING.appName} started (${url})`);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { BrowserWindow } from "electrobun/bun";
|
import type { BrowserWindow } from "electrobun/main";
|
||||||
import type { AppSettings } from "../shared/types";
|
import type { AppSettings } from "../shared/types";
|
||||||
import { createManagerRPC } from "./rpc-handlers";
|
import { createManagerRPC } from "./rpc-handlers";
|
||||||
import type { ConnectionStore } from "./store/connections";
|
import type { ConnectionStore } from "./store/connections";
|
||||||
@@ -24,6 +24,8 @@ export interface ManagerWindowDeps {
|
|||||||
};
|
};
|
||||||
/** Rebuilds the native app menu after store changes. */
|
/** Rebuilds the native app menu after store changes. */
|
||||||
rebuildMenu: () => void;
|
rebuildMenu: () => void;
|
||||||
|
/** The manager saved settings — rebroadcast them to every other window. */
|
||||||
|
onSettingsSaved: (settings: AppSettings) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,6 +72,7 @@ export class ManagerWindow {
|
|||||||
this.close();
|
this.close();
|
||||||
},
|
},
|
||||||
onClose: () => this.close(),
|
onClose: () => this.close(),
|
||||||
|
onSettingsSaved: (next) => this.deps.onSettingsSaved(next),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,37 @@
|
|||||||
import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/bun";
|
import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/main";
|
||||||
import { BRANDING } from "../shared/branding";
|
import { BRANDING } from "../shared/branding";
|
||||||
|
import { effectiveKeymap, type ShortcutId, toAccelerator } from "../shared/keymap";
|
||||||
import type { SavedConnection } from "../shared/types";
|
import type { SavedConnection } from "../shared/types";
|
||||||
|
import { t } from "./i18n";
|
||||||
import type { ConnectionStore } from "./store/connections";
|
import type { ConnectionStore } from "./store/connections";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* macOS application menu (electrobun menus are macOS-only). Standard roles plus
|
* macOS application menu (electrobun menus are macOS-only). Standard roles plus
|
||||||
* app actions; custom actions are forwarded to the webview as menuAction
|
* app actions; custom actions are forwarded to the webview as menuAction
|
||||||
* messages. Rebuilt whenever saved connections change so the Connect submenu
|
* messages. Rebuilt whenever saved connections, the language or the keyboard
|
||||||
* stays current.
|
* remaps change.
|
||||||
|
*
|
||||||
|
* Accelerators come from the shared keymap, so the menu bar shows — and
|
||||||
|
* fires — the user's keys. The page sees ⌘-keys first and claims the ones it
|
||||||
|
* handles; the menu only runs what falls through (see shortcuts.service.ts).
|
||||||
|
* ⌘Q stays fixed: quitting must always work.
|
||||||
*/
|
*/
|
||||||
export async function applyApplicationMenu(store: ConnectionStore): Promise<void> {
|
export async function applyApplicationMenu(
|
||||||
|
store: ConnectionStore,
|
||||||
|
keybindings: () => Record<string, string[]>,
|
||||||
|
): Promise<void> {
|
||||||
if (process.platform !== "darwin") return;
|
if (process.platform !== "darwin") return;
|
||||||
|
|
||||||
const { stacks, connections } = await store.load();
|
const { stacks, connections } = await store.load();
|
||||||
|
// read after the await: a remap saved meanwhile must not be lost
|
||||||
|
const keymap = effectiveKeymap(keybindings(), true);
|
||||||
|
const accelerator = (id: ShortcutId): string | undefined => {
|
||||||
|
for (const combo of keymap.find((b) => b.id === id)?.combos ?? []) {
|
||||||
|
const native = toAccelerator(combo);
|
||||||
|
if (native) return native;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
const item = (c: SavedConnection): ApplicationMenuItemConfig => ({
|
const item = (c: SavedConnection): ApplicationMenuItemConfig => ({
|
||||||
label: c.name,
|
label: c.name,
|
||||||
action: `connect:${c.id}`,
|
action: `connect:${c.id}`,
|
||||||
@@ -27,37 +46,48 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
|
|||||||
...looseItems,
|
...looseItems,
|
||||||
...stackMenus,
|
...stackMenus,
|
||||||
...(looseItems.length || stackMenus.length ? [{ type: "separator" } as const] : []),
|
...(looseItems.length || stackMenus.length ? [{ type: "separator" } as const] : []),
|
||||||
{ label: "Connection Manager…", action: "manage-connections" },
|
{
|
||||||
|
label: t("menu.connectionManager"),
|
||||||
|
action: "manage-connections",
|
||||||
|
accelerator: accelerator("connection-manager"),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
ApplicationMenu.setApplicationMenu([
|
ApplicationMenu.setApplicationMenu([
|
||||||
{
|
{
|
||||||
submenu: [
|
submenu: [
|
||||||
{ label: `About ${BRANDING.appName}`, action: "about" },
|
{ label: t("menu.about", { app: BRANDING.appName }), action: "about" },
|
||||||
|
{ type: "separator" },
|
||||||
|
{ label: t("menu.settings"), action: "settings", accelerator: accelerator("open-settings") },
|
||||||
|
{ type: "separator" },
|
||||||
|
{ label: t("menu.checkForUpdates"), action: "check-for-updates" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "hide" },
|
{ role: "hide" },
|
||||||
{ role: "hideOthers" },
|
{ role: "hideOthers" },
|
||||||
{ role: "showAll" },
|
{ role: "showAll" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ label: "Quit", role: "quit" },
|
// Custom action (not role:"quit") + explicit ⌘Q: routes through
|
||||||
|
// Utils.quit() so the before-quit guard (active transfers / unsaved
|
||||||
|
// editors) always runs instead of the OS terminating directly.
|
||||||
|
{ label: t("menu.quit"), action: "quit", accelerator: "q" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "File",
|
label: t("menu.file"),
|
||||||
submenu: [
|
submenu: [
|
||||||
{ label: "New Tab", action: "new-tab", accelerator: "t" },
|
{ label: t("menu.newTab"), action: "new-tab", accelerator: accelerator("new-tab") },
|
||||||
{ label: "Close Tab", action: "close-tab", accelerator: "w" },
|
{ label: t("menu.closeTab"), action: "close-tab", accelerator: accelerator("close-tab") },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ label: "New Folder", action: "new-folder" },
|
{ label: t("menu.newFolder"), action: "new-folder", accelerator: accelerator("new-folder") },
|
||||||
{ label: "New File", action: "new-file" },
|
{ label: t("menu.newFile"), action: "new-file", accelerator: accelerator("new-file") },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ label: "Edit File", action: "edit-file", accelerator: "e" },
|
{ label: t("menu.editFile"), action: "edit-file", accelerator: accelerator("edit") },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ label: "Connect", submenu: connectSubmenu },
|
{ label: t("menu.connect"), submenu: connectSubmenu },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Edit",
|
label: t("menu.edit"),
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "undo" },
|
{ role: "undo" },
|
||||||
{ role: "redo" },
|
{ role: "redo" },
|
||||||
@@ -71,15 +101,15 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "View",
|
label: t("menu.view"),
|
||||||
submenu: [
|
submenu: [
|
||||||
{ label: "Refresh", action: "refresh-active", accelerator: "r" },
|
{ label: t("menu.refresh"), action: "refresh-active", accelerator: accelerator("refresh") },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "toggleFullScreen" },
|
{ role: "toggleFullScreen" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Window",
|
label: t("menu.window"),
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "minimize" },
|
{ role: "minimize" },
|
||||||
{ role: "zoom" },
|
{ role: "zoom" },
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { BrowserView, Utils } from "electrobun/bun";
|
import { BrowserView, Updater, Utils } from "electrobun/main";
|
||||||
import type { AppRPC, EditorOpenContext } from "../shared/rpc";
|
import type { AppRPC, EditorOpenContext } from "../shared/rpc";
|
||||||
import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types";
|
import type { AppSettings, Result } from "../shared/types";
|
||||||
import type { PanelContextMenu } from "./context-menu";
|
|
||||||
import { readFileText, writeFileText, type EditManager } from "./edits";
|
import { readFileText, writeFileText, type EditManager } from "./edits";
|
||||||
import { listLocalDrives } from "./fs/local";
|
import { listLocalDrives } from "./fs/local";
|
||||||
import type { SessionRegistry } from "./fs/registry";
|
import type { SessionRegistry } from "./fs/registry";
|
||||||
|
import type { LocalDirWatcher } from "./fs/watcher";
|
||||||
import { SftpProvider } from "./fs/sftp";
|
import { SftpProvider } from "./fs/sftp";
|
||||||
import type { ConnectionStore } from "./store/connections";
|
import type { ConnectionStore } from "./store/connections";
|
||||||
import type { SettingsStore } from "./store/settings";
|
import type { SettingsStore } from "./store/settings";
|
||||||
@@ -23,7 +23,7 @@ export interface AppDeps {
|
|||||||
store: ConnectionStore;
|
store: ConnectionStore;
|
||||||
settings: SettingsStore;
|
settings: SettingsStore;
|
||||||
edits: EditManager;
|
edits: EditManager;
|
||||||
contextMenu: PanelContextMenu;
|
watcher: LocalDirWatcher;
|
||||||
/** Assigned after the rpc object exists (it needs rpc.send). */
|
/** Assigned after the rpc object exists (it needs rpc.send). */
|
||||||
events: {
|
events: {
|
||||||
sessionClosed: (info: { sessionId: string; reason: string }) => void;
|
sessionClosed: (info: { sessionId: string; reason: string }) => void;
|
||||||
@@ -31,7 +31,10 @@ export interface AppDeps {
|
|||||||
openEditor: (ctx: EditorOpenContext) => void;
|
openEditor: (ctx: EditorOpenContext) => void;
|
||||||
settingsChanged: (settings: AppSettings) => void;
|
settingsChanged: (settings: AppSettings) => void;
|
||||||
openConnectionManager: () => void;
|
openConnectionManager: () => void;
|
||||||
|
openSettings: () => void;
|
||||||
showAbout: () => void;
|
showAbout: () => void;
|
||||||
|
/** "Restart to apply" — lives next to the before-quit guard it must get past. */
|
||||||
|
applyUpdate: () => Promise<void>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +102,22 @@ function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void)
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* saveSettings for every window kind: persist, then hand the new object to the
|
||||||
|
* composition root (index.ts), which applies the side effects once and
|
||||||
|
* rebroadcasts to every OTHER window. Known ceiling: whole-object writes — two
|
||||||
|
* windows saving in the same instant, the later one wins. Upgrade path: send a
|
||||||
|
* patch and merge it bun-side.
|
||||||
|
*/
|
||||||
|
const saveSettingsHandler =
|
||||||
|
(store: SettingsStore, onSaved: (next: AppSettings) => void) =>
|
||||||
|
({ settings: next }: { settings: AppSettings }) =>
|
||||||
|
wrap(async () => {
|
||||||
|
await store.save(next);
|
||||||
|
onSaved(next);
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
const pickKeyFileHandler = () =>
|
const pickKeyFileHandler = () =>
|
||||||
wrap(async () => {
|
wrap(async () => {
|
||||||
const { homedir } = await import("node:os");
|
const { homedir } = await import("node:os");
|
||||||
@@ -114,13 +133,35 @@ const pickKeyFileHandler = () =>
|
|||||||
return { path };
|
return { path };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** External-editor picker, shared by the main and settings window rpcs. */
|
||||||
|
const pickEditorAppHandler = () =>
|
||||||
|
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,
|
||||||
|
allowedFileTypes,
|
||||||
|
canChooseFiles: true,
|
||||||
|
canChooseDirectory: false,
|
||||||
|
allowsMultipleSelection: false,
|
||||||
|
});
|
||||||
|
const path = picked.find((p) => p.trim().length > 0) ?? null;
|
||||||
|
return { path };
|
||||||
|
});
|
||||||
|
|
||||||
export function createAppRPC({
|
export function createAppRPC({
|
||||||
registry,
|
registry,
|
||||||
engine,
|
engine,
|
||||||
store,
|
store,
|
||||||
settings,
|
settings,
|
||||||
edits,
|
edits,
|
||||||
contextMenu,
|
watcher,
|
||||||
events,
|
events,
|
||||||
}: AppDeps) {
|
}: AppDeps) {
|
||||||
return BrowserView.defineRPC<AppRPC>({
|
return BrowserView.defineRPC<AppRPC>({
|
||||||
@@ -160,6 +201,12 @@ export function createAppRPC({
|
|||||||
return { path: dir, entries: await fs.list(dir) };
|
return { path: dir, entries: await fs.list(dir) };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
setWatchedDirs: ({ dirs }) =>
|
||||||
|
wrap(async () => {
|
||||||
|
watcher.setWatched(dirs);
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
|
||||||
mkdir: ({ sessionId, path }) =>
|
mkdir: ({ sessionId, path }) =>
|
||||||
wrap(async () => {
|
wrap(async () => {
|
||||||
await registry.get(sessionId).mkdir(path);
|
await registry.get(sessionId).mkdir(path);
|
||||||
@@ -218,13 +265,7 @@ export function createAppRPC({
|
|||||||
// ---- settings ----
|
// ---- settings ----
|
||||||
getSettings: () => wrap(async () => ({ settings: await settings.load() })),
|
getSettings: () => wrap(async () => ({ settings: await settings.load() })),
|
||||||
|
|
||||||
saveSettings: ({ settings: next }) =>
|
saveSettings: saveSettingsHandler(settings, (next) => events.settingsChanged(next)),
|
||||||
wrap(async () => {
|
|
||||||
await settings.save(next);
|
|
||||||
engine.setConcurrency(effectiveConcurrency(next));
|
|
||||||
events.settingsChanged(next);
|
|
||||||
return {};
|
|
||||||
}),
|
|
||||||
|
|
||||||
// ---- file editing ----
|
// ---- file editing ----
|
||||||
readFile: ({ sessionId, path }) =>
|
readFile: ({ sessionId, path }) =>
|
||||||
@@ -241,26 +282,7 @@ export function createAppRPC({
|
|||||||
editExternal: ({ sessionId, path }) =>
|
editExternal: ({ sessionId, path }) =>
|
||||||
wrap(() => edits.editExternal(sessionId, path)),
|
wrap(() => edits.editExternal(sessionId, path)),
|
||||||
|
|
||||||
pickEditorApp: () =>
|
pickEditorApp: pickEditorAppHandler,
|
||||||
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,
|
|
||||||
allowedFileTypes,
|
|
||||||
canChooseFiles: true,
|
|
||||||
canChooseDirectory: false,
|
|
||||||
allowsMultipleSelection: false,
|
|
||||||
});
|
|
||||||
const path = picked.find((p) => p.trim().length > 0) ?? null;
|
|
||||||
return { path };
|
|
||||||
}),
|
|
||||||
|
|
||||||
pickKeyFile: pickKeyFileHandler,
|
pickKeyFile: pickKeyFileHandler,
|
||||||
|
|
||||||
@@ -273,10 +295,12 @@ export function createAppRPC({
|
|||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// ---- context menu + local shell integration ----
|
// ---- local shell integration (menu itself is HTML, see ui/) ----
|
||||||
showRowMenu: (params) =>
|
shellItemAction: ({ action, path }) =>
|
||||||
wrap(async () => {
|
wrap(async () => {
|
||||||
contextMenu.show(params);
|
if (action === "reveal") Utils.showItemInFolder(path);
|
||||||
|
else if (action === "open") Utils.openPath(path);
|
||||||
|
else Utils.clipboardWriteText(path);
|
||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -286,12 +310,6 @@ export function createAppRPC({
|
|||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
showColumnsMenu: ({ visible }) =>
|
|
||||||
wrap(async () => {
|
|
||||||
contextMenu.showColumns(visible);
|
|
||||||
return {};
|
|
||||||
}),
|
|
||||||
|
|
||||||
// ---- editor windows ----
|
// ---- editor windows ----
|
||||||
openEditor: (ctx) =>
|
openEditor: (ctx) =>
|
||||||
wrap(async () => {
|
wrap(async () => {
|
||||||
@@ -306,6 +324,13 @@ export function createAppRPC({
|
|||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ---- settings window ----
|
||||||
|
openSettings: () =>
|
||||||
|
wrap(async () => {
|
||||||
|
events.openSettings();
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
|
||||||
showAbout: () =>
|
showAbout: () =>
|
||||||
wrap(async () => {
|
wrap(async () => {
|
||||||
events.showAbout();
|
events.showAbout();
|
||||||
@@ -313,6 +338,25 @@ export function createAppRPC({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
listDrives: () => wrap(async () => ({ drives: listLocalDrives() })),
|
listDrives: () => wrap(async () => ({ drives: listLocalDrives() })),
|
||||||
|
|
||||||
|
// ---- auto-updater ----
|
||||||
|
checkForUpdate: () =>
|
||||||
|
wrap(async () => {
|
||||||
|
const r = await Updater.checkForUpdate();
|
||||||
|
return { updateAvailable: r.updateAvailable, version: r.version };
|
||||||
|
}),
|
||||||
|
|
||||||
|
downloadUpdate: () =>
|
||||||
|
wrap(async () => {
|
||||||
|
await Updater.downloadUpdate();
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
|
||||||
|
applyUpdate: () =>
|
||||||
|
wrap(async () => {
|
||||||
|
await events.applyUpdate(); // relaunches + quits; may never resolve
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -326,6 +370,8 @@ export interface EditorRpcHooks {
|
|||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
/** Webview resolved its dirty prompt: close without the native fallback. */
|
/** Webview resolved its dirty prompt: close without the native fallback. */
|
||||||
onCloseConfirmed: () => void;
|
onCloseConfirmed: () => void;
|
||||||
|
/** This window saved settings (e.g. the scale shortcuts) — rebroadcast them. */
|
||||||
|
onSettingsSaved: (next: AppSettings) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -347,6 +393,7 @@ export function createEditorRPC(
|
|||||||
})),
|
})),
|
||||||
|
|
||||||
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
|
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
|
||||||
|
saveSettings: saveSettingsHandler(deps.settings, hooks.onSettingsSaved),
|
||||||
|
|
||||||
editorSave: ({ content }) =>
|
editorSave: ({ content }) =>
|
||||||
wrap(async () => {
|
wrap(async () => {
|
||||||
@@ -376,6 +423,8 @@ export interface ManagerRpcHooks {
|
|||||||
onConnect: (id: string) => void;
|
onConnect: (id: string) => void;
|
||||||
onOpenStack: (id: string) => void;
|
onOpenStack: (id: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
/** This window saved settings (e.g. the scale shortcuts) — rebroadcast them. */
|
||||||
|
onSettingsSaved: (next: AppSettings) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** RPC for the single connection-manager window (per-window instance). */
|
/** RPC for the single connection-manager window (per-window instance). */
|
||||||
@@ -390,6 +439,7 @@ export function createManagerRPC(
|
|||||||
...connectionStoreHandlers(deps.store, hooks.onChanged),
|
...connectionStoreHandlers(deps.store, hooks.onChanged),
|
||||||
pickKeyFile: pickKeyFileHandler,
|
pickKeyFile: pickKeyFileHandler,
|
||||||
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
|
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
|
||||||
|
saveSettings: saveSettingsHandler(deps.settings, hooks.onSettingsSaved),
|
||||||
|
|
||||||
managerConnect: ({ id }) =>
|
managerConnect: ({ id }) =>
|
||||||
wrap(async () => {
|
wrap(async () => {
|
||||||
@@ -412,3 +462,35 @@ export function createManagerRPC(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SettingsRpcHooks {
|
||||||
|
/** Every edit here is a settings save — rebroadcast it to the other windows. */
|
||||||
|
onSettingsSaved: (next: AppSettings) => void;
|
||||||
|
showAbout: () => void;
|
||||||
|
/** The window's own close-window key. */
|
||||||
|
close: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RPC for the single Settings window (per-window instance). */
|
||||||
|
export function createSettingsRPC(deps: { settings: SettingsStore }, hooks: SettingsRpcHooks) {
|
||||||
|
return BrowserView.defineRPC<AppRPC>({
|
||||||
|
maxRequestTime: MAX_REQUEST_TIME_MS,
|
||||||
|
handlers: {
|
||||||
|
requests: {
|
||||||
|
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
|
||||||
|
saveSettings: saveSettingsHandler(deps.settings, hooks.onSettingsSaved),
|
||||||
|
pickEditorApp: pickEditorAppHandler,
|
||||||
|
showAbout: () =>
|
||||||
|
wrap(async () => {
|
||||||
|
hooks.showAbout();
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
settingsClose: () =>
|
||||||
|
wrap(async () => {
|
||||||
|
hooks.close();
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { BrowserWindow } from "electrobun/main";
|
||||||
|
import type { AppSettings } from "../shared/types";
|
||||||
|
import { t } from "./i18n";
|
||||||
|
import { createSettingsRPC } from "./rpc-handlers";
|
||||||
|
import type { SettingsStore } from "./store/settings";
|
||||||
|
import { openSecondaryWindow } from "./windows";
|
||||||
|
|
||||||
|
const SETTINGS_WIDTH = 680;
|
||||||
|
const SETTINGS_HEIGHT = 480;
|
||||||
|
|
||||||
|
export interface SettingsWindowDeps {
|
||||||
|
settings: SettingsStore;
|
||||||
|
/** The once-resolved dev/prod view url. */
|
||||||
|
url: string;
|
||||||
|
/** Live settings snapshot — baked into the window's preload (theme flash). */
|
||||||
|
currentSettings: () => AppSettings;
|
||||||
|
mainFrame: () => { x: number; y: number; width: number; height: number };
|
||||||
|
/** This window saved settings — rebroadcast them to every other window. */
|
||||||
|
onSettingsSaved: (settings: AppSettings) => void;
|
||||||
|
showAbout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single Settings window. Unlike the connection manager it is NOT modal:
|
||||||
|
* every edit is an ordinary settings save, so the other windows follow live
|
||||||
|
* while it stays open. Its page claims its own close-window key (settingsClose).
|
||||||
|
* A ⌘W it doesn't claim (remapped away) falls through to the app menu's Close
|
||||||
|
* Tab, which index.ts routes here while this window is focused.
|
||||||
|
*/
|
||||||
|
export class SettingsWindow {
|
||||||
|
private win: BrowserWindow | null = null;
|
||||||
|
private rpc: ReturnType<typeof createSettingsRPC> | null = null;
|
||||||
|
private focused = false;
|
||||||
|
|
||||||
|
constructor(private deps: SettingsWindowDeps) {}
|
||||||
|
|
||||||
|
isFocused(): boolean {
|
||||||
|
return this.focused;
|
||||||
|
}
|
||||||
|
|
||||||
|
open(): void {
|
||||||
|
if (this.win) {
|
||||||
|
this.win.activate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rpc = createSettingsRPC(
|
||||||
|
{ settings: this.deps.settings },
|
||||||
|
{
|
||||||
|
onSettingsSaved: (next) => this.deps.onSettingsSaved(next),
|
||||||
|
showAbout: () => this.deps.showAbout(),
|
||||||
|
close: () => this.close(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const main = this.deps.mainFrame();
|
||||||
|
this.win = openSecondaryWindow({
|
||||||
|
title: t("options.title"),
|
||||||
|
url: this.deps.url,
|
||||||
|
mode: "settings",
|
||||||
|
settings: this.deps.currentSettings(),
|
||||||
|
frame: {
|
||||||
|
x: Math.max(0, main.x + (main.width - SETTINGS_WIDTH) / 2),
|
||||||
|
y: Math.max(0, main.y + (main.height - SETTINGS_HEIGHT) / 2),
|
||||||
|
width: SETTINGS_WIDTH,
|
||||||
|
height: SETTINGS_HEIGHT,
|
||||||
|
},
|
||||||
|
rpc: this.rpc,
|
||||||
|
});
|
||||||
|
this.win.on("focus", () => (this.focused = true));
|
||||||
|
this.win.on("blur", () => (this.focused = false));
|
||||||
|
this.win.on("close", () => {
|
||||||
|
this.win = null;
|
||||||
|
this.rpc = null;
|
||||||
|
this.focused = false;
|
||||||
|
});
|
||||||
|
console.log("[settings] window opened");
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.win?.close(); // the close event does the cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The native title follows a language switch (the header row is the UI's own). */
|
||||||
|
retitle(): void {
|
||||||
|
this.win?.setTitle(t("options.title"));
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastSettings(settings: AppSettings): void {
|
||||||
|
this.rpc?.send.settingsChanged({ settings });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { DEFAULT_SETTINGS, type AppSettings } from "../../shared/types";
|
import { sanitizeKeybindings } from "../../shared/keymap";
|
||||||
|
import { clampUiScale, DEFAULT_SETTINGS, type AppSettings } from "../../shared/types";
|
||||||
import { appDataDir } from "./paths";
|
import { appDataDir } from "./paths";
|
||||||
|
|
||||||
|
/** The retired 3-tier axis, mapped onto the scale that replaced it. */
|
||||||
|
const LEGACY_TEXT_SIZE: Record<string, number> = { compact: 0.9, default: 1, large: 1.2 };
|
||||||
|
|
||||||
/** App settings persisted bun-side (WKWebView localStorage on views:// is unreliable). */
|
/** App settings persisted bun-side (WKWebView localStorage on views:// is unreliable). */
|
||||||
export class SettingsStore {
|
export class SettingsStore {
|
||||||
private readonly file = path.join(appDataDir(), "settings.json");
|
private readonly file = path.join(appDataDir(), "settings.json");
|
||||||
@@ -10,7 +14,21 @@ export class SettingsStore {
|
|||||||
async load(): Promise<AppSettings> {
|
async load(): Promise<AppSettings> {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(await fs.readFile(this.file, "utf8"));
|
const data = JSON.parse(await fs.readFile(this.file, "utf8"));
|
||||||
return { ...DEFAULT_SETTINGS, ...data };
|
const settings: AppSettings = { ...DEFAULT_SETTINGS, ...data };
|
||||||
|
// migrate the retired textSize tier into the scale that replaced it —
|
||||||
|
// but never over an explicit uiScale (a file carrying both has already
|
||||||
|
// been migrated once and then adjusted)
|
||||||
|
if (data.uiScale === undefined && typeof data.textSize === "string") {
|
||||||
|
settings.uiScale = LEGACY_TEXT_SIZE[data.textSize] ?? 1;
|
||||||
|
}
|
||||||
|
// clamp ALWAYS, not just on migration: buildPreload writes this value
|
||||||
|
// into every window's root font-size, so a hand-edited settings.json
|
||||||
|
// (uiScale: 100, "2", NaN) must not be able to render the UI unusable
|
||||||
|
settings.uiScale = clampUiScale(Number(settings.uiScale));
|
||||||
|
// same reasoning: every window resolves keys through this map
|
||||||
|
settings.keybindings = sanitizeKeybindings(data.keybindings);
|
||||||
|
settings.openFilesOnEnter = data.openFilesOnEnter === true;
|
||||||
|
return settings;
|
||||||
} catch {
|
} catch {
|
||||||
return { ...DEFAULT_SETTINGS };
|
return { ...DEFAULT_SETTINGS };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { BRANDING } from "../../shared/branding";
|
|||||||
import type { EntryType, TransferSnapshot, TransferState } from "../../shared/types";
|
import type { EntryType, TransferSnapshot, TransferState } from "../../shared/types";
|
||||||
import type { FileSystemProvider } from "../fs/provider";
|
import type { FileSystemProvider } from "../fs/provider";
|
||||||
import type { SessionRegistry } from "../fs/registry";
|
import type { SessionRegistry } from "../fs/registry";
|
||||||
|
import { t } from "../i18n";
|
||||||
import { walkTree } from "./walk";
|
import { walkTree } from "./walk";
|
||||||
|
|
||||||
const PROGRESS_INTERVAL_MS = 100;
|
const PROGRESS_INTERVAL_MS = 100;
|
||||||
@@ -59,7 +60,7 @@ async function ensureDir(fs: FileSystemProvider, path: string): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const st = await fs.stat(path);
|
const st = await fs.stat(path);
|
||||||
if (st.type === "dir") return;
|
if (st.type === "dir") return;
|
||||||
throw new Error(`"${path}" exists and is not a directory`);
|
throw new Error(t("errors.notADirectory", { path }));
|
||||||
} catch {
|
} catch {
|
||||||
await fs.mkdir(path);
|
await fs.mkdir(path);
|
||||||
}
|
}
|
||||||
@@ -235,7 +236,7 @@ export class TransferEngine {
|
|||||||
if (sameFs) {
|
if (sameFs) {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file.srcPath === file.dstPath) {
|
if (file.srcPath === file.dstPath) {
|
||||||
throw new Error(`"${file.name}": source and destination are the same file`);
|
throw new Error(t("errors.sameFileTransfer", { name: file.name }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -343,10 +344,10 @@ export class TransferEngine {
|
|||||||
|
|
||||||
if (samePath) {
|
if (samePath) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
isDir ? "Cannot copy a folder onto itself" : "Source and destination are the same file",
|
isDir ? t("errors.folderOntoItself") : t("errors.sameFileNoName"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw new Error("Cannot copy a folder into itself");
|
throw new Error(t("errors.folderIntoItself"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Tray, type BrowserWindow } from "electrobun/bun";
|
import { Tray, type BrowserWindow } from "electrobun/main";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Menu-bar / notification-area icon. Clicking it brings the app window to the
|
* Menu-bar / notification-area icon. Clicking it brings the app window to the
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { BrowserWindow } from "electrobun/bun";
|
import { BrowserWindow } from "electrobun/main";
|
||||||
import type { AppSettings } from "../shared/types";
|
import type { AppSettings } from "../shared/types";
|
||||||
|
|
||||||
const isMac = process.platform === "darwin";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The preload globals every window gets. Single composition point: the UI
|
* The preload globals every window gets. Single composition point: the UI
|
||||||
* reads them in ui/src/app/platform.ts before first paint (window chrome and
|
* reads them in ui/src/app/core/platform.ts before first paint (window chrome and
|
||||||
* root-component selection both depend on them).
|
* root-component selection both depend on them).
|
||||||
*
|
*
|
||||||
* The persisted theme attributes are baked in here — mirroring the
|
* The persisted theme attributes are baked in here — mirroring the
|
||||||
@@ -15,7 +13,7 @@ const isMac = process.platform === "darwin";
|
|||||||
* windows flash the wrong background.
|
* windows flash the wrong background.
|
||||||
*/
|
*/
|
||||||
export function buildPreload(
|
export function buildPreload(
|
||||||
mode: "main" | "editor" | "manager",
|
mode: "main" | "editor" | "manager" | "settings",
|
||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
ctx?: unknown,
|
ctx?: unknown,
|
||||||
): string {
|
): string {
|
||||||
@@ -27,14 +25,37 @@ export function buildPreload(
|
|||||||
const dataset: Record<string, string> = {};
|
const dataset: Record<string, string> = {};
|
||||||
if (settings.theme !== "system") dataset["theme"] = settings.theme;
|
if (settings.theme !== "system") dataset["theme"] = settings.theme;
|
||||||
if (settings.darkTone !== "dark") dataset["darkTone"] = settings.darkTone;
|
if (settings.darkTone !== "dark") dataset["darkTone"] = settings.darkTone;
|
||||||
if (settings.accent !== "graphite") dataset["accent"] = settings.accent;
|
// interface scale: mirrors SettingsService.applyUiScale so the FIRST paint
|
||||||
if (settings.textSize !== "default") dataset["text"] = settings.textSize;
|
// is already scaled — without this every window opens at 100% and snaps.
|
||||||
|
// Written as an inline style, not a data attribute: the scale is continuous.
|
||||||
|
if (settings.uiScale !== 1) {
|
||||||
|
script += `document.documentElement.style.fontSize=${JSON.stringify(`${16 * settings.uiScale}px`)};`;
|
||||||
|
}
|
||||||
for (const [key, value] of Object.entries(dataset)) {
|
for (const [key, value] of Object.entries(dataset)) {
|
||||||
script += `document.documentElement.dataset[${JSON.stringify(key)}]=${JSON.stringify(value)};`;
|
script += `document.documentElement.dataset[${JSON.stringify(key)}]=${JSON.stringify(value)};`;
|
||||||
}
|
}
|
||||||
return script;
|
return script;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native chrome for every window — the ONE place the frame is decided; the main
|
||||||
|
* window (src/bun/index.ts) and openSecondaryWindow both spread this. macOS
|
||||||
|
* fuses the titlebar with the app's own header row (hiddenInset: the traffic
|
||||||
|
* lights overlay it, which is what IS_MAC_CHROME reserves pl-24 for); other
|
||||||
|
* platforms keep the native titlebar. Constructor-only: there is no API to
|
||||||
|
* re-decorate a live window.
|
||||||
|
*
|
||||||
|
* electrobun 2.x adds trafficLightOffset to macOS's default button position
|
||||||
|
* (9/9 in the 32pt macOS 26 titlebar) and grows the titlebar to fit — 1.18.1
|
||||||
|
* clamped the shift inside the stock titlebar instead. {12, 4} puts the 14pt
|
||||||
|
* buttons at left 21 / top 13: vertically centred in the 40px h-10 header.
|
||||||
|
*/
|
||||||
|
export function chromeOptions() {
|
||||||
|
return process.platform === "darwin"
|
||||||
|
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 4 } }
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Windows are created hidden and revealed on the webview's first dom-ready —
|
* Windows are created hidden and revealed on the webview's first dom-ready —
|
||||||
* showing them earlier paints the native (white) surface before the themed
|
* showing them earlier paints the native (white) surface before the themed
|
||||||
@@ -59,7 +80,7 @@ export interface SecondaryWindowOptions {
|
|||||||
title: string;
|
title: string;
|
||||||
/** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */
|
/** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */
|
||||||
url: string;
|
url: string;
|
||||||
mode: "editor" | "manager";
|
mode: "editor" | "manager" | "settings";
|
||||||
/** Current persisted settings — baked into the preload for flash-free paint. */
|
/** Current persisted settings — baked into the preload for flash-free paint. */
|
||||||
settings: AppSettings;
|
settings: AppSettings;
|
||||||
ctx?: unknown;
|
ctx?: unknown;
|
||||||
@@ -69,22 +90,13 @@ export interface SecondaryWindowOptions {
|
|||||||
rpc: NonNullable<ConstructorParameters<typeof BrowserWindow>[0]>["rpc"];
|
rpc: NonNullable<ConstructorParameters<typeof BrowserWindow>[0]>["rpc"];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** App-chromed secondary window; its frame comes from chromeOptions. */
|
||||||
* App-chromed secondary window.
|
|
||||||
*
|
|
||||||
* PORTABILITY: the only spot that branches on platform for secondary-window
|
|
||||||
* chrome. macOS fuses the titlebar (traffic lights overlay the UI's h-10
|
|
||||||
* header row, same treatment as the main window); Windows/Linux keep the
|
|
||||||
* native titlebar and the UI renders a compact non-drag header instead.
|
|
||||||
*/
|
|
||||||
export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow {
|
export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow {
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
title: opts.title,
|
title: opts.title,
|
||||||
url: opts.url,
|
url: opts.url,
|
||||||
frame: opts.frame,
|
frame: opts.frame,
|
||||||
...(isMac
|
...chromeOptions(),
|
||||||
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
|
||||||
: {}),
|
|
||||||
preload: buildPreload(opts.mode, opts.settings, opts.ctx),
|
preload: buildPreload(opts.mode, opts.settings, opts.ctx),
|
||||||
rpc: opts.rpc,
|
rpc: opts.rpc,
|
||||||
hidden: true,
|
hidden: true,
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Translations
|
||||||
|
|
||||||
|
`en.ts` is the **source of truth**. Every other locale mirrors its key shape;
|
||||||
|
any missing key falls back to English at lookup time (see `index.ts` +
|
||||||
|
`I18nService`/backend `i18n.ts`), so partial translations are safe.
|
||||||
|
|
||||||
|
## Adding / editing strings
|
||||||
|
|
||||||
|
1. Add the key to `en.ts` (nested namespace, `{param}` interpolation). For
|
||||||
|
plurals, use a `{ one, other }` map instead of a string — the runtime picks
|
||||||
|
the CLDR category via `Intl.PluralRules` (languages without plural, e.g. zh
|
||||||
|
and ja, only need `other`).
|
||||||
|
2. Reference it as `i18n.t('ns.key', { param })` in the UI (or backend `t(...)`
|
||||||
|
for native menus / thrown errors).
|
||||||
|
3. Add the translation to the other locale files as convenient — until then
|
||||||
|
English shows through.
|
||||||
|
|
||||||
|
The format is kept **Transloco-compatible** (nested namespaces, `{param}`) so
|
||||||
|
swapping in a real i18n library later is a drop-in of these dictionaries.
|
||||||
|
|
||||||
|
## Review status
|
||||||
|
|
||||||
|
| Locale | File | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| English | `en.ts` | Source |
|
||||||
|
| Spanish | `es.ts` | Full draft |
|
||||||
|
| French | `fr.ts` | Full draft |
|
||||||
|
| German | `de.ts` | Full draft |
|
||||||
|
| Chinese (Simplified) | `zh.ts` | **AI draft — needs native review** |
|
||||||
|
| Japanese | `ja.ts` | **AI draft — needs native review** |
|
||||||
|
| Basque | `eu.ts` | **AI draft — needs native review (highest priority; machine/AI Basque is unreliable)** |
|
||||||
|
|
||||||
|
Native-speaker review is especially important for Basque and the CJK locales:
|
||||||
|
grammatical agreement, counters/measure words, and UI-context word choice are
|
||||||
|
easy to get subtly wrong.
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
// German translations. Missing keys fall back to English (see en.ts / index.ts).
|
||||||
|
export const de = {
|
||||||
|
common: {
|
||||||
|
cancel: "Abbrechen",
|
||||||
|
save: "Speichern",
|
||||||
|
close: "Schließen",
|
||||||
|
delete: "Löschen",
|
||||||
|
rename: "Umbenennen",
|
||||||
|
create: "Erstellen",
|
||||||
|
apply: "Anwenden",
|
||||||
|
choose: "Wählen…",
|
||||||
|
connect: "Verbinden",
|
||||||
|
local: "Lokal",
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
notConnectedTitle: "Nicht mit der App verbunden",
|
||||||
|
notConnectedBody: "Diese Seite läuft in einem normalen Browser. Starte die Desktop-App, um sie zu verwenden.",
|
||||||
|
managerOpen: "Der Verbindungsmanager ist geöffnet.",
|
||||||
|
showConnections: "Verbindungen anzeigen",
|
||||||
|
},
|
||||||
|
tabBar: {
|
||||||
|
connections: "Verbindungen",
|
||||||
|
manageConnections: "Verbindungen verwalten",
|
||||||
|
newTab: "Neuer Tab",
|
||||||
|
closeTab: "Tab schließen",
|
||||||
|
options: "Einstellungen",
|
||||||
|
update: "Update verfügbar",
|
||||||
|
updating: "Aktualisiere",
|
||||||
|
restartToUpdate: "Zum Anwenden neu starten",
|
||||||
|
updateTooltip: "Update verfügbar: miniSCP {version}",
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
title: "Einstellungen",
|
||||||
|
sections: {
|
||||||
|
appearance: "Darstellung",
|
||||||
|
transfers: "Übertragungen",
|
||||||
|
editor: "Editor",
|
||||||
|
fileList: "Dateiliste",
|
||||||
|
keyboard: "Tastatur",
|
||||||
|
about: "Über",
|
||||||
|
},
|
||||||
|
columns: "Sichtbare Spalten",
|
||||||
|
language: "Sprache",
|
||||||
|
system: "System",
|
||||||
|
theme: { label: "Design", system: "System", light: "Hell", dark: "Dunkel" },
|
||||||
|
tone: { label: "Ton", dark: "Dunkel", darker: "AMOLED" },
|
||||||
|
interface: { label: "Oberfläche", smaller: "Kleiner", larger: "Größer", reset: "Auf 100% zurücksetzen" },
|
||||||
|
speedUnit: { label: "Geschwindigkeitseinheit" },
|
||||||
|
transfers: { label: "Übertragungen", single: "Einzeln", multi: "Mehrere" },
|
||||||
|
atOnce: "Gleichzeitig",
|
||||||
|
editor: { label: "Editor", builtin: "Integriert", custom: "Benutzerdefiniert" },
|
||||||
|
systemDefault: "Systemstandard",
|
||||||
|
resetDefault: "Auf Standard zurücksetzen",
|
||||||
|
finishedTransfers: { label: "Abgeschlossene Übertragungen", autoClear: "Auto-löschen", keep: "Behalten" },
|
||||||
|
openFiles: { label: "Eingabe / Doppelklick auf eine Datei", nothing: "Nichts tun", open: "Öffnen", hint: "Lokale Dateien öffnen sich in ihrer Standard-App; Ordner öffnen sich immer." },
|
||||||
|
},
|
||||||
|
kinds: {
|
||||||
|
folder: "Ordner",
|
||||||
|
symlink: "Symbolischer Link",
|
||||||
|
file: "Datei",
|
||||||
|
image: "Bild",
|
||||||
|
audio: "Audio",
|
||||||
|
video: "Video",
|
||||||
|
archive: "Archiv",
|
||||||
|
code: "Code",
|
||||||
|
document: "Dokument",
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
name: "Name",
|
||||||
|
size: "Größe",
|
||||||
|
mtime: "Geändert",
|
||||||
|
kind: "Typ",
|
||||||
|
rights: "Rechte",
|
||||||
|
owner: "Eigentümer",
|
||||||
|
group: "Gruppe",
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
choosePanel: "Wählen, was dieses Panel anzeigt",
|
||||||
|
copy: "Senden",
|
||||||
|
copyToRight: "An rechtes Panel senden",
|
||||||
|
copyToLeft: "An linkes Panel senden",
|
||||||
|
edit: "Bearbeiten",
|
||||||
|
editFile: "Datei bearbeiten",
|
||||||
|
rename: "Umbenennen",
|
||||||
|
newFolder: "Neuer Ordner",
|
||||||
|
newFile: "Neue Datei",
|
||||||
|
permissions: "Berechtigungen",
|
||||||
|
delete: "Löschen",
|
||||||
|
parentDir: "Übergeordneter Ordner",
|
||||||
|
refresh: "Aktualisieren",
|
||||||
|
clearFilter: "Filter löschen (Esc)",
|
||||||
|
sshConfig: "~/.ssh/config",
|
||||||
|
connectingTo: "Verbinde mit {name}…",
|
||||||
|
emptyDir: "Leerer Ordner",
|
||||||
|
noMatches: "Keine Treffer",
|
||||||
|
matches: { one: "{count} Treffer · Esc", other: "{count} Treffer · Esc" },
|
||||||
|
},
|
||||||
|
connMgr: {
|
||||||
|
title: "Verbindungen",
|
||||||
|
importWinscp: "Aus WinSCP importieren…",
|
||||||
|
importWinscpHint: "Sitzungen aus einem WinSCP.ini-Export importieren",
|
||||||
|
newConnection: "Verbindung",
|
||||||
|
newConnectionHint: "Neue Verbindung",
|
||||||
|
newStack: "Gruppe",
|
||||||
|
newStackHint: "Neue Gruppe",
|
||||||
|
expand: "Aufklappen",
|
||||||
|
collapse: "Zuklappen",
|
||||||
|
renameStack: "Gruppe umbenennen",
|
||||||
|
deleteStack: "Gruppe löschen",
|
||||||
|
openStack: "Gruppe in Tabs öffnen",
|
||||||
|
empty: "leer",
|
||||||
|
connectionsHeader: "Verbindungen",
|
||||||
|
noneTitle: "Noch keine gespeicherten Verbindungen.",
|
||||||
|
noneBody: "Erstelle eine oder importiere deine WinSCP-Sitzungen.",
|
||||||
|
name: "Name",
|
||||||
|
namePlaceholder: "mein Server",
|
||||||
|
host: "Host",
|
||||||
|
port: "Port",
|
||||||
|
stack: "Gruppe",
|
||||||
|
stackNone: "(keine)",
|
||||||
|
username: "Benutzername",
|
||||||
|
remotePath: "Entfernter Pfad (optional)",
|
||||||
|
remotePathPlaceholder: "Home-Verzeichnis",
|
||||||
|
auth: "Authentifizierung",
|
||||||
|
authPassword: "Passwort",
|
||||||
|
authKey: "Privater Schlüssel",
|
||||||
|
password: "Passwort",
|
||||||
|
privateKeyFile: "Datei mit privatem Schlüssel",
|
||||||
|
passphrase: "Passphrase (optional)",
|
||||||
|
localDir: "Lokales Verzeichnis (optional)",
|
||||||
|
localDirPlaceholder: "~/projekte/mein-server",
|
||||||
|
localDirHint: "Das andere Panel öffnet sich hier, wenn es auf Lokal steht und nicht navigiert wurde.",
|
||||||
|
more: "Mehr…",
|
||||||
|
less: "Weniger…",
|
||||||
|
selectPrompt: "Wähle eine Verbindung oder erstelle eine neue.",
|
||||||
|
newStackTitle: "Neue Gruppe",
|
||||||
|
newStackPrompt: "Gruppenname",
|
||||||
|
renameStackTitle: "Gruppe umbenennen",
|
||||||
|
deleteStackTitle: "Gruppe löschen",
|
||||||
|
deleteStackBody: 'Gruppe „{name}" löschen? Ihre Verbindungen bleiben, ohne Gruppe.',
|
||||||
|
deleteConnectionTitle: "Verbindung löschen",
|
||||||
|
deleteConnectionBody: '„{name}" löschen?',
|
||||||
|
},
|
||||||
|
editor: {
|
||||||
|
noFileTitle: "Keine Datei zum Bearbeiten",
|
||||||
|
noFileBody: "Dieses Fenster wurde ohne Editor-Kontext geöffnet. Öffne Dateien aus der App.",
|
||||||
|
wrap: "Umbruch",
|
||||||
|
wrapHint: "Lange Zeilen umbrechen — blendet Zeilennummern aus",
|
||||||
|
findHint: "Suchen",
|
||||||
|
findPlaceholder: "In Datei suchen",
|
||||||
|
prevMatch: "Vorheriger Treffer",
|
||||||
|
nextMatch: "Nächster Treffer",
|
||||||
|
closeHint: "Schließen (Esc)",
|
||||||
|
disconnected: "Verbindung zu {name} getrennt — Speichern schlägt fehl, aber dein Text bleibt in diesem Fenster erhalten.",
|
||||||
|
cantOpen: "{name} kann nicht geöffnet werden",
|
||||||
|
closeWindow: "Fenster schließen",
|
||||||
|
verbs: { save: "speichern", find: "suchen", close: "schließen" },
|
||||||
|
lines: { one: "{count} Zeile", other: "{count} Zeilen" },
|
||||||
|
close: "Schließen",
|
||||||
|
save: "Speichern",
|
||||||
|
saveChangesTitle: 'Änderungen an „{name}" speichern?',
|
||||||
|
saveChangesBody: "Deine Änderungen gehen verloren, wenn du sie nicht speicherst.",
|
||||||
|
dontSave: "Nicht speichern",
|
||||||
|
countLabel: "{current} von {total}",
|
||||||
|
noMatches: "Keine Treffer",
|
||||||
|
},
|
||||||
|
transfer: {
|
||||||
|
summary: "{active} aktiv · {finished} abgeschlossen",
|
||||||
|
clearFinished: "Abgeschlossene löschen",
|
||||||
|
cancel: "Abbrechen",
|
||||||
|
dismiss: "Verwerfen",
|
||||||
|
showDetails: "Übertragungsdetails anzeigen",
|
||||||
|
copying: "Kopiere {name} · {percent}%{speed}",
|
||||||
|
queued: "{count} in Warteschlange",
|
||||||
|
failed: { one: "{count} Übertragung fehlgeschlagen", other: "{count} Übertragungen fehlgeschlagen" },
|
||||||
|
finished: "Übertragungen abgeschlossen",
|
||||||
|
ready: "Bereit",
|
||||||
|
items: "{count} Elemente",
|
||||||
|
selected: "{count} ausgewählt",
|
||||||
|
queuedState: "In Warteschlange",
|
||||||
|
scanning: "Analysiere…",
|
||||||
|
failedState: "Fehlgeschlagen",
|
||||||
|
cancelled: "Abgebrochen",
|
||||||
|
completedFiles: { one: "{count} Datei · {size}", other: "{count} Dateien · {size}" },
|
||||||
|
left: "{eta} übrig",
|
||||||
|
},
|
||||||
|
dialogs: {
|
||||||
|
chmodTitle: "Berechtigungen",
|
||||||
|
chmodItems: "{count} Elemente",
|
||||||
|
read: "Lesen",
|
||||||
|
write: "Schreiben",
|
||||||
|
execute: "Ausführen",
|
||||||
|
roleOwner: "Eigentümer",
|
||||||
|
roleGroup: "Gruppe",
|
||||||
|
roleOthers: "Andere",
|
||||||
|
octal: "Oktal",
|
||||||
|
mergeFolder: "Ordner zusammenführen",
|
||||||
|
replace: "Ersetzen",
|
||||||
|
merge: "Zusammenführen",
|
||||||
|
mergeAll: "Alle zusammenführen",
|
||||||
|
replaceAll: "Alle ersetzen",
|
||||||
|
skip: "Überspringen",
|
||||||
|
skipAll: "Alle überspringen",
|
||||||
|
ofTotal: "{index} von {total}",
|
||||||
|
overwriteDirBody: "Am Ziel existiert bereits ein Ordner mit diesem Namen. Zusammenführen kann Dateien darin ersetzen.",
|
||||||
|
overwriteFileBody: "Am Ziel existiert bereits eine Datei mit diesem Namen.",
|
||||||
|
source: "Quelle",
|
||||||
|
destination: "Ziel",
|
||||||
|
newer: "neuer",
|
||||||
|
},
|
||||||
|
toasts: {
|
||||||
|
movedToTrash: '„{name}" in den Papierkorb verschoben',
|
||||||
|
movedManyToTrash: { one: "{count} Objekt in den Papierkorb verschoben", other: "{count} Objekte in den Papierkorb verschoben" },
|
||||||
|
editingExternal: "Bearbeite {name} — Speichern lädt automatisch hoch",
|
||||||
|
nothingToCopy: "Nichts zu kopieren (symbolische Links werden übersprungen)",
|
||||||
|
nothingToCopyAllSkipped: "Nichts zu kopieren — alle Konflikte wurden übersprungen",
|
||||||
|
sameFolder: "Beide Panels zeigen denselben Ordner",
|
||||||
|
sameFile: '„{name}" übersprungen — es ist dieselbe Datei',
|
||||||
|
typeMismatch: '„{name}" übersprungen — ein anderer Elementtyp ist im Weg',
|
||||||
|
transferFailed: 'Übertragung „{name}" fehlgeschlagen: {error}',
|
||||||
|
unknownError: "unbekannter Fehler",
|
||||||
|
saved: "{name} gespeichert",
|
||||||
|
disconnectedReason: "{title}: getrennt ({reason})",
|
||||||
|
imported: { one: "{count} Verbindung importiert", other: "{count} Verbindungen importiert" },
|
||||||
|
importedIntoStacks: { one: " in {count} neue Gruppe", other: " in {count} neue Gruppen" },
|
||||||
|
importedSkipped: " ({skipped} übersprungen)",
|
||||||
|
leftoverTitle: "Übrig gebliebene bearbeitete Dateien",
|
||||||
|
leftoverMore: ", +{count} weitere",
|
||||||
|
leftoverBody: {
|
||||||
|
one: "Eine frühere Sitzung hinterließ {count} temporäre bearbeitete Datei ({names}). Löschen?",
|
||||||
|
other: "Eine frühere Sitzung hinterließ {count} temporäre bearbeitete Dateien ({names}). Löschen?",
|
||||||
|
},
|
||||||
|
leftoverClear: "Löschen",
|
||||||
|
},
|
||||||
|
fileActions: {
|
||||||
|
deleteTitle: "Löschen",
|
||||||
|
deleteBody: "{what} löschen? Ordner werden rekursiv gelöscht. Dies kann nicht rückgängig gemacht werden.",
|
||||||
|
deleteConfirm: "Löschen",
|
||||||
|
renameTitle: '„{name}" umbenennen',
|
||||||
|
renameConfirm: "Umbenennen",
|
||||||
|
newFolderTitle: "Neuer Ordner",
|
||||||
|
newFolderPlaceholder: "Ordnername",
|
||||||
|
newFileTitle: "Neue Datei",
|
||||||
|
newFilePlaceholder: "Dateiname",
|
||||||
|
createConfirm: "Erstellen",
|
||||||
|
itemsCount: "{count} Elemente",
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
about: "Über {app}",
|
||||||
|
settings: "Einstellungen…",
|
||||||
|
checkForUpdates: "Nach Updates suchen…",
|
||||||
|
quit: "Beenden",
|
||||||
|
file: "Datei",
|
||||||
|
newTab: "Neuer Tab",
|
||||||
|
closeTab: "Tab schließen",
|
||||||
|
newFolder: "Neuer Ordner",
|
||||||
|
newFile: "Neue Datei",
|
||||||
|
editFile: "Datei bearbeiten",
|
||||||
|
connect: "Verbinden",
|
||||||
|
connectionManager: "Verbindungsmanager…",
|
||||||
|
edit: "Bearbeiten",
|
||||||
|
view: "Ansicht",
|
||||||
|
refresh: "Aktualisieren",
|
||||||
|
window: "Fenster",
|
||||||
|
},
|
||||||
|
ctx: {
|
||||||
|
size: "Größe",
|
||||||
|
modified: "Geändert",
|
||||||
|
kind: "Typ",
|
||||||
|
rights: "Rechte",
|
||||||
|
owner: "Eigentümer",
|
||||||
|
group: "Gruppe",
|
||||||
|
revealFinder: "Im Finder anzeigen",
|
||||||
|
revealExplorer: "Im Explorer anzeigen",
|
||||||
|
revealFileManager: "Im Dateimanager anzeigen",
|
||||||
|
moveToTrash: "In den Papierkorb",
|
||||||
|
moveToRecycleBin: "In den Papierkorb",
|
||||||
|
newFolder: "Neuer Ordner",
|
||||||
|
newFile: "Neue Datei",
|
||||||
|
refresh: "Aktualisieren",
|
||||||
|
open: "Öffnen",
|
||||||
|
openDefault: "Mit Standard-App öffnen",
|
||||||
|
edit: "Bearbeiten",
|
||||||
|
copyToOther: "An anderes Panel senden",
|
||||||
|
rename: "Umbenennen",
|
||||||
|
permissions: "Berechtigungen",
|
||||||
|
copyPath: "Pfad kopieren",
|
||||||
|
delete: "Löschen",
|
||||||
|
columns: "Spalten",
|
||||||
|
tabNew: "Neuer Tab",
|
||||||
|
tabClose: "Tab schließen",
|
||||||
|
tabCloseOthers: "Andere Tabs schließen",
|
||||||
|
tabCloseRight: "Tabs rechts schließen",
|
||||||
|
tabDisconnect: "Trennen",
|
||||||
|
trashTooltip: "Finders Wiederherstellen ist für von hier gelöschte Elemente nicht verfügbar",
|
||||||
|
},
|
||||||
|
keys: {
|
||||||
|
"cursor-up": "Nach oben",
|
||||||
|
"cursor-down": "Nach unten",
|
||||||
|
"page-up": "Seite nach oben",
|
||||||
|
"page-down": "Seite nach unten",
|
||||||
|
"cursor-first": "Erster Eintrag",
|
||||||
|
"cursor-last": "Letzter Eintrag",
|
||||||
|
"toggle-select": "Auswählen / abwählen",
|
||||||
|
"select-all": "Alles auswählen",
|
||||||
|
open: "Öffnen",
|
||||||
|
parent: "Übergeordneter Ordner",
|
||||||
|
"switch-panel": "Panel wechseln",
|
||||||
|
"row-menu": "Menü des Eintrags zeigen",
|
||||||
|
send: "An anderes Panel senden",
|
||||||
|
edit: "Bearbeiten",
|
||||||
|
rename: "Umbenennen",
|
||||||
|
"new-folder": "Neuer Ordner",
|
||||||
|
"new-file": "Neue Datei",
|
||||||
|
chmod: "Berechtigungen",
|
||||||
|
delete: "Löschen",
|
||||||
|
trash: "In den Papierkorb",
|
||||||
|
"copy-path": "Pfad kopieren",
|
||||||
|
reveal: "Im Dateimanager zeigen",
|
||||||
|
"open-default": "Mit Standard-App öffnen",
|
||||||
|
refresh: "Aktualisieren",
|
||||||
|
"focus-path": "Zum Pfad",
|
||||||
|
"source-picker": "Panelinhalt wählen",
|
||||||
|
"sort-name": "Nach Name sortieren",
|
||||||
|
"sort-size": "Nach Größe sortieren",
|
||||||
|
"sort-mtime": "Nach Änderungsdatum sortieren",
|
||||||
|
"sort-kind": "Nach Art sortieren",
|
||||||
|
"sort-rights": "Nach Rechten sortieren",
|
||||||
|
"sort-owner": "Nach Eigentümer sortieren",
|
||||||
|
"sort-group": "Nach Gruppe sortieren",
|
||||||
|
"new-tab": "Neuer Tab",
|
||||||
|
"close-tab": "Tab schließen",
|
||||||
|
"close-other-tabs": "Andere Tabs schließen",
|
||||||
|
"close-tabs-right": "Tabs rechts schließen",
|
||||||
|
"next-tab": "Nächster Tab",
|
||||||
|
"prev-tab": "Vorheriger Tab",
|
||||||
|
"go-to-tab": "Zu Tab {n}",
|
||||||
|
"go-to-last-tab": "Zum letzten Tab",
|
||||||
|
"move-tab-left": "Tab nach links",
|
||||||
|
"move-tab-right": "Tab nach rechts",
|
||||||
|
"disconnect-tab": "Tab trennen",
|
||||||
|
"open-settings": "Einstellungen",
|
||||||
|
"connection-manager": "Verbindungsverwaltung",
|
||||||
|
"toggle-transfers": "Übertragungen ein-/ausblenden",
|
||||||
|
"install-update": "Update installieren",
|
||||||
|
"scale-up": "Oberfläche größer",
|
||||||
|
"scale-down": "Oberfläche kleiner",
|
||||||
|
"scale-reset": "Oberfläche auf 100 %",
|
||||||
|
"close-window": "Fenster schließen",
|
||||||
|
save: "Speichern",
|
||||||
|
find: "Suchen",
|
||||||
|
"find-next": "Weitersuchen",
|
||||||
|
"find-prev": "Rückwärts suchen",
|
||||||
|
"toggle-wrap": "Zeilen umbrechen",
|
||||||
|
"conn-new": "Neue Verbindung",
|
||||||
|
"conn-new-stack": "Neue Gruppe",
|
||||||
|
"conn-save": "Verbindung speichern",
|
||||||
|
"conn-connect": "Verbinden / Gruppe öffnen",
|
||||||
|
"conn-delete": "Verbindung oder Gruppe löschen",
|
||||||
|
"conn-rename-stack": "Gruppe umbenennen",
|
||||||
|
"conn-import": "Aus WinSCP importieren",
|
||||||
|
groups: {
|
||||||
|
panels: "Dateiliste",
|
||||||
|
files: "Dateien",
|
||||||
|
sort: "Sortierung",
|
||||||
|
tabs: "Tabs",
|
||||||
|
window: "Fenster",
|
||||||
|
editor: "Editor",
|
||||||
|
connections: "Verbindungsverwaltung",
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
filter: "Tastenkürzel suchen",
|
||||||
|
intro: "Auf + klicken und die Tasten drücken, um ein Kürzel hinzuzufügen. Das erste zeigen Menüs und Tooltips an.",
|
||||||
|
add: "Kürzel hinzufügen",
|
||||||
|
remove: "{key} entfernen",
|
||||||
|
reset: "Auf Standard zurücksetzen",
|
||||||
|
resetAll: "Alle zurücksetzen",
|
||||||
|
resetAllConfirm: "Alle Kürzel zurücksetzen",
|
||||||
|
press: "Tasten drücken… ({esc} bricht ab)",
|
||||||
|
unbound: "Kein Kürzel",
|
||||||
|
reserved: "{key} ist vom System reserviert",
|
||||||
|
needsModifier: "Im Editor würde {key} nur tippen – eine Sondertaste hinzufügen oder eine F-Taste nehmen",
|
||||||
|
usedBy: "{key} wird schon von „{action}“ benutzt",
|
||||||
|
replace: "Ersetzen",
|
||||||
|
conflict: "Gleiche Taste wie {action}",
|
||||||
|
plainLetter: "{key} in der Dateiliste tippen filtert sie nicht mehr",
|
||||||
|
noResults: "Kein Kürzel passt",
|
||||||
|
fixed: "Feste Tasten",
|
||||||
|
},
|
||||||
|
fixed: {
|
||||||
|
escape: "{esc} schließt Menüs, Dialoge und die Suchleiste",
|
||||||
|
escList: "{esc} in der Dateiliste leert den Filter, bricht dann eine Verbindung ab und hebt dann die Auswahl auf",
|
||||||
|
dialogs: "{enter} und {esc} bestätigen oder verwerfen einen Dialog; {tab} und {shiftTab} wechseln zwischen seinen Elementen",
|
||||||
|
menus: "↑ ↓ {home} {end} und {enter} funktionieren in jedem Menü",
|
||||||
|
filter: "Tippen in der Dateiliste filtert sie; {backspace} löscht das letzte Zeichen des Filters",
|
||||||
|
shift: "Umschalt mit einer Bewegungstaste wählt die überfahrenen Zeilen aus",
|
||||||
|
find: "{enter} und {shiftEnter} springen durch die Treffer der Suche",
|
||||||
|
manager: "↑ ↓ ← → und {enter} bewegen sich durch die Verbindungsliste",
|
||||||
|
sidebar: "↑ ↓ wechseln hier zwischen den Bereichen",
|
||||||
|
},
|
||||||
|
names: {
|
||||||
|
ctrl: "Strg",
|
||||||
|
alt: "Alt",
|
||||||
|
shift: "Umschalt",
|
||||||
|
enter: "Eingabe",
|
||||||
|
backspace: "Rücktaste",
|
||||||
|
del: "Entf",
|
||||||
|
esc: "Esc",
|
||||||
|
space: "Leertaste",
|
||||||
|
tab: "Tab",
|
||||||
|
pgup: "Bild↑",
|
||||||
|
pgdn: "Bild↓",
|
||||||
|
home: "Pos1",
|
||||||
|
end: "Ende",
|
||||||
|
ins: "Einfg",
|
||||||
|
menu: "Menü",
|
||||||
|
num: "Num",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
sshAgentUnavailable: "ssh-agent nicht verfügbar (SSH_AUTH_SOCK ist nicht gesetzt)",
|
||||||
|
identityFileNotFound: "Identitätsdatei nicht gefunden: {path}",
|
||||||
|
sessionNotConnected: 'Sitzung „{id}" ist nicht verbunden',
|
||||||
|
localCannotDisconnect: "Die lokale Sitzung kann nicht getrennt werden",
|
||||||
|
notADirectory: '„{path}" existiert und ist kein Ordner',
|
||||||
|
sameFileTransfer: '„{name}": Quelle und Ziel sind dieselbe Datei',
|
||||||
|
sameFileNoName: "Quelle und Ziel sind dieselbe Datei",
|
||||||
|
folderOntoItself: "Ein Ordner kann nicht auf sich selbst kopiert werden",
|
||||||
|
folderIntoItself: "Ein Ordner kann nicht in sich selbst kopiert werden",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
// English is the source of truth. Other locales mirror this shape; missing keys
|
||||||
|
// fall back to English at lookup time. Placeholders use {name} interpolation.
|
||||||
|
// Keep Transloco-compatible (nested namespaces, {param}) so a later migration
|
||||||
|
// to a real i18n library is a drop-in of these dictionaries.
|
||||||
|
|
||||||
|
export const en = {
|
||||||
|
common: {
|
||||||
|
cancel: "Cancel",
|
||||||
|
save: "Save",
|
||||||
|
close: "Close",
|
||||||
|
delete: "Delete",
|
||||||
|
rename: "Rename",
|
||||||
|
create: "Create",
|
||||||
|
apply: "Apply",
|
||||||
|
choose: "Choose…",
|
||||||
|
connect: "Connect",
|
||||||
|
local: "Local",
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
notConnectedTitle: "Not connected to the app",
|
||||||
|
notConnectedBody: "This page is running in a plain browser. Launch the desktop app to use it.",
|
||||||
|
managerOpen: "The connection manager is open.",
|
||||||
|
showConnections: "Show Connections",
|
||||||
|
},
|
||||||
|
tabBar: {
|
||||||
|
connections: "Connections",
|
||||||
|
manageConnections: "Manage connections",
|
||||||
|
newTab: "New tab",
|
||||||
|
closeTab: "Close tab",
|
||||||
|
options: "Settings",
|
||||||
|
update: "Update available",
|
||||||
|
updating: "Updating",
|
||||||
|
restartToUpdate: "Restart to apply",
|
||||||
|
updateTooltip: "Update available: miniSCP {version}",
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
title: "Settings",
|
||||||
|
sections: {
|
||||||
|
appearance: "Appearance",
|
||||||
|
transfers: "Transfers",
|
||||||
|
editor: "Editor",
|
||||||
|
fileList: "File list",
|
||||||
|
keyboard: "Keyboard",
|
||||||
|
about: "About",
|
||||||
|
},
|
||||||
|
columns: "Visible columns",
|
||||||
|
language: "Language",
|
||||||
|
system: "System",
|
||||||
|
theme: { label: "Theme", system: "System", light: "Light", dark: "Dark" },
|
||||||
|
tone: { label: "Tone", dark: "Dark", darker: "AMOLED" },
|
||||||
|
interface: { label: "Interface", smaller: "Smaller", larger: "Larger", reset: "Reset to 100%" },
|
||||||
|
speedUnit: { label: "Speed unit" },
|
||||||
|
transfers: { label: "Transfers", single: "Single", multi: "Multi" },
|
||||||
|
atOnce: "At once",
|
||||||
|
editor: { label: "Editor", builtin: "Built-in", custom: "Custom" },
|
||||||
|
systemDefault: "System default",
|
||||||
|
resetDefault: "Reset to system default",
|
||||||
|
finishedTransfers: { label: "Finished transfers", autoClear: "Auto-clear", keep: "Keep" },
|
||||||
|
openFiles: { label: "Enter / double-click on a file", nothing: "Do nothing", open: "Open", hint: "Local files open in their default app; folders always open." },
|
||||||
|
},
|
||||||
|
kinds: {
|
||||||
|
folder: "Folder",
|
||||||
|
symlink: "Symlink",
|
||||||
|
file: "File",
|
||||||
|
image: "Image",
|
||||||
|
audio: "Audio",
|
||||||
|
video: "Video",
|
||||||
|
archive: "Archive",
|
||||||
|
code: "Code",
|
||||||
|
document: "Document",
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
name: "Name",
|
||||||
|
size: "Size",
|
||||||
|
mtime: "Modified",
|
||||||
|
kind: "Kind",
|
||||||
|
rights: "Rights",
|
||||||
|
owner: "Owner",
|
||||||
|
group: "Group",
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
choosePanel: "Choose what this panel shows",
|
||||||
|
copy: "Send",
|
||||||
|
copyToRight: "Send to right panel",
|
||||||
|
copyToLeft: "Send to left panel",
|
||||||
|
edit: "Edit",
|
||||||
|
editFile: "Edit file",
|
||||||
|
rename: "Rename",
|
||||||
|
newFolder: "New folder",
|
||||||
|
newFile: "New file",
|
||||||
|
permissions: "Permissions",
|
||||||
|
delete: "Delete",
|
||||||
|
parentDir: "Parent directory",
|
||||||
|
refresh: "Refresh",
|
||||||
|
clearFilter: "Clear filter (Esc)",
|
||||||
|
sshConfig: "~/.ssh/config",
|
||||||
|
connectingTo: "Connecting to {name}…",
|
||||||
|
emptyDir: "Empty directory",
|
||||||
|
noMatches: "No matches",
|
||||||
|
matches: { one: "{count} match · Esc", other: "{count} matches · Esc" },
|
||||||
|
},
|
||||||
|
connMgr: {
|
||||||
|
title: "Connections",
|
||||||
|
importWinscp: "Import from WinSCP…",
|
||||||
|
importWinscpHint: "Import sessions from a WinSCP.ini export",
|
||||||
|
newConnection: "Connection",
|
||||||
|
newConnectionHint: "New connection",
|
||||||
|
newStack: "Stack",
|
||||||
|
newStackHint: "New stack",
|
||||||
|
expand: "Expand",
|
||||||
|
collapse: "Collapse",
|
||||||
|
renameStack: "Rename stack",
|
||||||
|
deleteStack: "Delete stack",
|
||||||
|
openStack: "Open stack in tabs",
|
||||||
|
empty: "empty",
|
||||||
|
connectionsHeader: "Connections",
|
||||||
|
noneTitle: "No saved connections yet.",
|
||||||
|
noneBody: "Create one, or import your WinSCP sessions.",
|
||||||
|
name: "Name",
|
||||||
|
namePlaceholder: "my server",
|
||||||
|
host: "Host",
|
||||||
|
port: "Port",
|
||||||
|
stack: "Stack",
|
||||||
|
stackNone: "(none)",
|
||||||
|
username: "Username",
|
||||||
|
remotePath: "Remote path (optional)",
|
||||||
|
remotePathPlaceholder: "home directory",
|
||||||
|
auth: "Auth",
|
||||||
|
authPassword: "Password",
|
||||||
|
authKey: "Private key",
|
||||||
|
password: "Password",
|
||||||
|
privateKeyFile: "Private key file",
|
||||||
|
passphrase: "Passphrase (optional)",
|
||||||
|
localDir: "Local directory (optional)",
|
||||||
|
localDirPlaceholder: "~/projects/my-server",
|
||||||
|
localDirHint: "The other panel opens here when it's on Local and hasn't been navigated.",
|
||||||
|
more: "More…",
|
||||||
|
less: "Less…",
|
||||||
|
selectPrompt: "Select a connection or create a new one.",
|
||||||
|
newStackTitle: "New stack",
|
||||||
|
newStackPrompt: "stack name",
|
||||||
|
renameStackTitle: "Rename stack",
|
||||||
|
deleteStackTitle: "Delete stack",
|
||||||
|
deleteStackBody: 'Delete stack "{name}"? Its connections stay, ungrouped.',
|
||||||
|
deleteConnectionTitle: "Delete connection",
|
||||||
|
deleteConnectionBody: 'Delete "{name}"?',
|
||||||
|
},
|
||||||
|
editor: {
|
||||||
|
noFileTitle: "No file to edit",
|
||||||
|
noFileBody: "This window was opened without an editor context. Launch files from the app.",
|
||||||
|
wrap: "Wrap",
|
||||||
|
wrapHint: "Wrap long lines — hides line numbers",
|
||||||
|
findHint: "Find",
|
||||||
|
findPlaceholder: "Find in file",
|
||||||
|
prevMatch: "Previous match",
|
||||||
|
nextMatch: "Next match",
|
||||||
|
closeHint: "Close (Esc)",
|
||||||
|
disconnected: "Disconnected from {name} — saving will fail, but your text is preserved in this window.",
|
||||||
|
cantOpen: "Can't open {name}",
|
||||||
|
closeWindow: "Close Window",
|
||||||
|
// footer hint: "⌘S save · ⌘F find · ⌘W close", keys from the keymap
|
||||||
|
verbs: { save: "save", find: "find", close: "close" },
|
||||||
|
lines: { one: "{count} line", other: "{count} lines" },
|
||||||
|
close: "Close",
|
||||||
|
save: "Save",
|
||||||
|
saveChangesTitle: 'Save changes to "{name}"?',
|
||||||
|
saveChangesBody: "Your changes will be lost if you don't save them.",
|
||||||
|
dontSave: "Don't Save",
|
||||||
|
countLabel: "{current} of {total}",
|
||||||
|
noMatches: "No matches",
|
||||||
|
},
|
||||||
|
transfer: {
|
||||||
|
summary: "{active} active · {finished} finished",
|
||||||
|
clearFinished: "Clear finished",
|
||||||
|
cancel: "Cancel",
|
||||||
|
dismiss: "Dismiss",
|
||||||
|
showDetails: "Show transfer details",
|
||||||
|
copying: "Copying {name} · {percent}%{speed}",
|
||||||
|
queued: "{count} queued",
|
||||||
|
failed: { one: "{count} transfer failed", other: "{count} transfers failed" },
|
||||||
|
finished: "Transfers finished",
|
||||||
|
ready: "Ready",
|
||||||
|
items: "{count} items",
|
||||||
|
selected: "{count} selected",
|
||||||
|
queuedState: "Queued",
|
||||||
|
scanning: "Scanning…",
|
||||||
|
failedState: "Failed",
|
||||||
|
cancelled: "Cancelled",
|
||||||
|
completedFiles: { one: "{count} file · {size}", other: "{count} files · {size}" },
|
||||||
|
left: "{eta} left",
|
||||||
|
},
|
||||||
|
dialogs: {
|
||||||
|
chmodTitle: "Permissions",
|
||||||
|
chmodItems: "{count} items",
|
||||||
|
read: "Read",
|
||||||
|
write: "Write",
|
||||||
|
execute: "Execute",
|
||||||
|
roleOwner: "Owner",
|
||||||
|
roleGroup: "Group",
|
||||||
|
roleOthers: "Others",
|
||||||
|
octal: "Octal",
|
||||||
|
mergeFolder: "Merge folder",
|
||||||
|
replace: "Replace",
|
||||||
|
merge: "Merge",
|
||||||
|
mergeAll: "Merge all",
|
||||||
|
replaceAll: "Replace all",
|
||||||
|
skip: "Skip",
|
||||||
|
skipAll: "Skip all",
|
||||||
|
ofTotal: "{index} of {total}",
|
||||||
|
overwriteDirBody: "The destination already has a folder with this name. Merging may replace files inside it.",
|
||||||
|
overwriteFileBody: "The destination already has a file with this name.",
|
||||||
|
source: "Source",
|
||||||
|
destination: "Destination",
|
||||||
|
newer: "newer",
|
||||||
|
},
|
||||||
|
toasts: {
|
||||||
|
movedToTrash: 'Moved "{name}" to Trash',
|
||||||
|
movedManyToTrash: { one: "Moved {count} item to Trash", other: "Moved {count} items to Trash" },
|
||||||
|
editingExternal: "Editing {name} — saves upload automatically",
|
||||||
|
nothingToCopy: "Nothing to copy (symlinks are skipped)",
|
||||||
|
nothingToCopyAllSkipped: "Nothing to copy — all conflicting items were skipped",
|
||||||
|
sameFolder: "Both panels show the same folder",
|
||||||
|
sameFile: 'Skipped "{name}" — it is the same file',
|
||||||
|
typeMismatch: 'Skipped "{name}" — a different kind of item is in the way',
|
||||||
|
transferFailed: 'Transfer "{name}" failed: {error}',
|
||||||
|
unknownError: "unknown error",
|
||||||
|
saved: "Saved {name}",
|
||||||
|
disconnectedReason: "{title}: disconnected ({reason})",
|
||||||
|
imported: { one: "Imported {count} connection", other: "Imported {count} connections" },
|
||||||
|
importedIntoStacks: { one: " into {count} new stack", other: " into {count} new stacks" },
|
||||||
|
importedSkipped: " ({skipped} skipped)",
|
||||||
|
leftoverTitle: "Leftover edited files",
|
||||||
|
leftoverBody: {
|
||||||
|
one: "A previous session left {count} temporary edited file behind ({names}). Clear them?",
|
||||||
|
other: "A previous session left {count} temporary edited files behind ({names}). Clear them?",
|
||||||
|
},
|
||||||
|
leftoverMore: ", +{count} more",
|
||||||
|
leftoverClear: "Clear",
|
||||||
|
},
|
||||||
|
fileActions: {
|
||||||
|
deleteTitle: "Delete",
|
||||||
|
deleteBody: "Delete {what}? Folders are deleted recursively. This cannot be undone.",
|
||||||
|
deleteConfirm: "Delete",
|
||||||
|
renameTitle: 'Rename "{name}"',
|
||||||
|
renameConfirm: "Rename",
|
||||||
|
newFolderTitle: "New folder",
|
||||||
|
newFolderPlaceholder: "folder name",
|
||||||
|
newFileTitle: "New file",
|
||||||
|
newFilePlaceholder: "file name",
|
||||||
|
createConfirm: "Create",
|
||||||
|
itemsCount: "{count} items",
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
about: "About {app}",
|
||||||
|
settings: "Settings…",
|
||||||
|
checkForUpdates: "Check for Updates…",
|
||||||
|
quit: "Quit",
|
||||||
|
file: "File",
|
||||||
|
newTab: "New Tab",
|
||||||
|
closeTab: "Close Tab",
|
||||||
|
newFolder: "New Folder",
|
||||||
|
newFile: "New File",
|
||||||
|
editFile: "Edit File",
|
||||||
|
connect: "Connect",
|
||||||
|
connectionManager: "Connection Manager…",
|
||||||
|
edit: "Edit",
|
||||||
|
view: "View",
|
||||||
|
refresh: "Refresh",
|
||||||
|
window: "Window",
|
||||||
|
},
|
||||||
|
ctx: {
|
||||||
|
size: "Size",
|
||||||
|
modified: "Modified",
|
||||||
|
kind: "Kind",
|
||||||
|
rights: "Rights",
|
||||||
|
owner: "Owner",
|
||||||
|
group: "Group",
|
||||||
|
revealFinder: "Reveal in Finder",
|
||||||
|
revealExplorer: "Show in Explorer",
|
||||||
|
revealFileManager: "Show in File Manager",
|
||||||
|
moveToTrash: "Move to Trash",
|
||||||
|
moveToRecycleBin: "Move to Recycle Bin",
|
||||||
|
newFolder: "New Folder",
|
||||||
|
newFile: "New File",
|
||||||
|
refresh: "Refresh",
|
||||||
|
open: "Open",
|
||||||
|
openDefault: "Open with the default application",
|
||||||
|
edit: "Edit",
|
||||||
|
copyToOther: "Send to Other Panel",
|
||||||
|
rename: "Rename",
|
||||||
|
permissions: "Permissions",
|
||||||
|
copyPath: "Copy Path",
|
||||||
|
delete: "Delete",
|
||||||
|
columns: "Columns",
|
||||||
|
tabNew: "New Tab",
|
||||||
|
tabClose: "Close Tab",
|
||||||
|
tabCloseOthers: "Close Other Tabs",
|
||||||
|
tabCloseRight: "Close Tabs to the Right",
|
||||||
|
tabDisconnect: "Disconnect",
|
||||||
|
trashTooltip: "Finder's Restore is not available for items trashed from here",
|
||||||
|
},
|
||||||
|
// Keyboard commands — one label per src/shared/keymap.ts BINDINGS id
|
||||||
|
keys: {
|
||||||
|
"cursor-up": "Move up",
|
||||||
|
"cursor-down": "Move down",
|
||||||
|
"page-up": "Page up",
|
||||||
|
"page-down": "Page down",
|
||||||
|
"cursor-first": "First item",
|
||||||
|
"cursor-last": "Last item",
|
||||||
|
"toggle-select": "Select / deselect",
|
||||||
|
"select-all": "Select all",
|
||||||
|
open: "Open",
|
||||||
|
parent: "Parent folder",
|
||||||
|
"switch-panel": "Switch panel",
|
||||||
|
"row-menu": "Show the item's menu",
|
||||||
|
send: "Send to the other panel",
|
||||||
|
edit: "Edit",
|
||||||
|
rename: "Rename",
|
||||||
|
"new-folder": "New folder",
|
||||||
|
"new-file": "New file",
|
||||||
|
chmod: "Permissions",
|
||||||
|
delete: "Delete",
|
||||||
|
trash: "Move to Trash",
|
||||||
|
"copy-path": "Copy path",
|
||||||
|
reveal: "Reveal in file manager",
|
||||||
|
"open-default": "Open in default app",
|
||||||
|
refresh: "Refresh",
|
||||||
|
"focus-path": "Go to path",
|
||||||
|
"source-picker": "Choose what the panel shows",
|
||||||
|
"sort-name": "Sort by name",
|
||||||
|
"sort-size": "Sort by size",
|
||||||
|
"sort-mtime": "Sort by modified",
|
||||||
|
"sort-kind": "Sort by kind",
|
||||||
|
"sort-rights": "Sort by rights",
|
||||||
|
"sort-owner": "Sort by owner",
|
||||||
|
"sort-group": "Sort by group",
|
||||||
|
"new-tab": "New tab",
|
||||||
|
"close-tab": "Close tab",
|
||||||
|
"close-other-tabs": "Close other tabs",
|
||||||
|
"close-tabs-right": "Close tabs to the right",
|
||||||
|
"next-tab": "Next tab",
|
||||||
|
"prev-tab": "Previous tab",
|
||||||
|
"go-to-tab": "Go to tab {n}",
|
||||||
|
"go-to-last-tab": "Go to last tab",
|
||||||
|
"move-tab-left": "Move tab left",
|
||||||
|
"move-tab-right": "Move tab right",
|
||||||
|
"disconnect-tab": "Disconnect tab",
|
||||||
|
"open-settings": "Settings",
|
||||||
|
"connection-manager": "Connection manager",
|
||||||
|
"toggle-transfers": "Show / hide transfers",
|
||||||
|
"install-update": "Install update",
|
||||||
|
"scale-up": "Bigger interface",
|
||||||
|
"scale-down": "Smaller interface",
|
||||||
|
"scale-reset": "Interface at 100%",
|
||||||
|
"close-window": "Close window",
|
||||||
|
save: "Save",
|
||||||
|
find: "Find",
|
||||||
|
"find-next": "Find next",
|
||||||
|
"find-prev": "Find previous",
|
||||||
|
"toggle-wrap": "Wrap lines",
|
||||||
|
"conn-new": "New connection",
|
||||||
|
"conn-new-stack": "New stack",
|
||||||
|
"conn-save": "Save connection",
|
||||||
|
"conn-connect": "Connect / open stack",
|
||||||
|
"conn-delete": "Delete connection or stack",
|
||||||
|
"conn-rename-stack": "Rename stack",
|
||||||
|
"conn-import": "Import from WinSCP",
|
||||||
|
groups: {
|
||||||
|
panels: "File list",
|
||||||
|
files: "Files",
|
||||||
|
sort: "Sorting",
|
||||||
|
tabs: "Tabs",
|
||||||
|
window: "Window",
|
||||||
|
editor: "Editor",
|
||||||
|
connections: "Connection manager",
|
||||||
|
},
|
||||||
|
// Settings → Keyboard
|
||||||
|
ui: {
|
||||||
|
filter: "Search shortcuts",
|
||||||
|
intro: "Press + and type the keys to add a shortcut. The first one is the hint menus and tooltips show.",
|
||||||
|
add: "Add a shortcut",
|
||||||
|
remove: "Remove {key}",
|
||||||
|
reset: "Reset to default",
|
||||||
|
resetAll: "Reset all",
|
||||||
|
resetAllConfirm: "Reset every shortcut",
|
||||||
|
press: "Press keys… ({esc} cancels)",
|
||||||
|
unbound: "No shortcut",
|
||||||
|
reserved: "{key} is reserved by the system",
|
||||||
|
needsModifier: "In the editor {key} would only type — add a modifier or use an F-key",
|
||||||
|
usedBy: "{key} is already used by “{action}”",
|
||||||
|
replace: "Replace",
|
||||||
|
conflict: "Same key as {action}",
|
||||||
|
plainLetter: "Typing {key} in the file list no longer filters it",
|
||||||
|
noResults: "No shortcut matches",
|
||||||
|
fixed: "Fixed keys",
|
||||||
|
},
|
||||||
|
// fixed keys, listed read-only there (key params come from the platform)
|
||||||
|
fixed: {
|
||||||
|
escape: "{esc} closes menus, dialogs and the find bar",
|
||||||
|
escList: "{esc} in the file list clears the filter, then cancels a connect, then clears the selection",
|
||||||
|
dialogs: "{enter} and {esc} confirm or cancel a dialog; {tab} and {shiftTab} move between its controls",
|
||||||
|
menus: "↑ ↓ {home} {end} and {enter} work in every menu",
|
||||||
|
filter: "Typing in the file list filters it; {backspace} trims the filter",
|
||||||
|
shift: "Shift with a movement key selects the rows the cursor passes",
|
||||||
|
find: "{enter} and {shiftEnter} step through the matches in the find box",
|
||||||
|
manager: "↑ ↓ ← → and {enter} move through the connection list",
|
||||||
|
sidebar: "↑ ↓ move between these sections",
|
||||||
|
},
|
||||||
|
// key names for "Ctrl+Shift+Del"-style hints (not used on macOS, which shows ⌘⇧⌫)
|
||||||
|
names: {
|
||||||
|
ctrl: "Ctrl",
|
||||||
|
alt: "Alt",
|
||||||
|
shift: "Shift",
|
||||||
|
enter: "Enter",
|
||||||
|
backspace: "Backspace",
|
||||||
|
del: "Del",
|
||||||
|
esc: "Esc",
|
||||||
|
space: "Space",
|
||||||
|
tab: "Tab",
|
||||||
|
pgup: "PgUp",
|
||||||
|
pgdn: "PgDn",
|
||||||
|
home: "Home",
|
||||||
|
end: "End",
|
||||||
|
ins: "Ins",
|
||||||
|
menu: "Menu",
|
||||||
|
num: "Num",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
sshAgentUnavailable: "ssh-agent not available (SSH_AUTH_SOCK is not set)",
|
||||||
|
identityFileNotFound: "identity file not found: {path}",
|
||||||
|
sessionNotConnected: 'Session "{id}" is not connected',
|
||||||
|
localCannotDisconnect: "The local session cannot be disconnected",
|
||||||
|
notADirectory: '"{path}" exists and is not a directory',
|
||||||
|
sameFileTransfer: '"{name}": source and destination are the same file',
|
||||||
|
sameFileNoName: "Source and destination are the same file",
|
||||||
|
folderOntoItself: "Cannot copy a folder onto itself",
|
||||||
|
folderIntoItself: "Cannot copy a folder into itself",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
// Spanish translations. Missing keys fall back to English (see en.ts / index.ts).
|
||||||
|
export const es = {
|
||||||
|
common: {
|
||||||
|
cancel: "Cancelar",
|
||||||
|
save: "Guardar",
|
||||||
|
close: "Cerrar",
|
||||||
|
delete: "Eliminar",
|
||||||
|
rename: "Renombrar",
|
||||||
|
create: "Crear",
|
||||||
|
apply: "Aplicar",
|
||||||
|
choose: "Elegir…",
|
||||||
|
connect: "Conectar",
|
||||||
|
local: "Local",
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
notConnectedTitle: "No conectado a la aplicación",
|
||||||
|
notConnectedBody: "Esta página se ejecuta en un navegador normal. Abre la aplicación de escritorio para usarla.",
|
||||||
|
managerOpen: "El gestor de conexiones está abierto.",
|
||||||
|
showConnections: "Mostrar conexiones",
|
||||||
|
},
|
||||||
|
tabBar: {
|
||||||
|
connections: "Conexiones",
|
||||||
|
manageConnections: "Gestionar conexiones",
|
||||||
|
newTab: "Nueva pestaña",
|
||||||
|
closeTab: "Cerrar pestaña",
|
||||||
|
options: "Ajustes",
|
||||||
|
update: "Actualización disponible",
|
||||||
|
updating: "Actualizando",
|
||||||
|
restartToUpdate: "Reiniciar para aplicar",
|
||||||
|
updateTooltip: "Actualización disponible: miniSCP {version}",
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
title: "Ajustes",
|
||||||
|
sections: {
|
||||||
|
appearance: "Apariencia",
|
||||||
|
transfers: "Transferencias",
|
||||||
|
editor: "Editor",
|
||||||
|
fileList: "Lista de archivos",
|
||||||
|
keyboard: "Teclado",
|
||||||
|
about: "Acerca de",
|
||||||
|
},
|
||||||
|
columns: "Columnas visibles",
|
||||||
|
language: "Idioma",
|
||||||
|
system: "Sistema",
|
||||||
|
theme: { label: "Tema", system: "Sistema", light: "Claro", dark: "Oscuro" },
|
||||||
|
tone: { label: "Tono", dark: "Oscuro", darker: "AMOLED" },
|
||||||
|
interface: { label: "Interfaz", smaller: "Más pequeña", larger: "Más grande", reset: "Restablecer al 100%" },
|
||||||
|
speedUnit: { label: "Unidad de velocidad" },
|
||||||
|
transfers: { label: "Transferencias", single: "Una", multi: "Varias" },
|
||||||
|
atOnce: "A la vez",
|
||||||
|
editor: { label: "Editor", builtin: "Integrado", custom: "Personalizado" },
|
||||||
|
systemDefault: "Predeterminado del sistema",
|
||||||
|
resetDefault: "Restablecer al predeterminado",
|
||||||
|
finishedTransfers: { label: "Transferencias completadas", autoClear: "Auto-limpiar", keep: "Mantener" },
|
||||||
|
openFiles: { label: "Intro / doble clic en un archivo", nothing: "No hacer nada", open: "Abrir", hint: "Los archivos locales se abren con su app predeterminada; las carpetas siempre se abren." },
|
||||||
|
},
|
||||||
|
kinds: {
|
||||||
|
folder: "Carpeta",
|
||||||
|
symlink: "Enlace simbólico",
|
||||||
|
file: "Archivo",
|
||||||
|
image: "Imagen",
|
||||||
|
audio: "Audio",
|
||||||
|
video: "Vídeo",
|
||||||
|
archive: "Archivo comprimido",
|
||||||
|
code: "Código",
|
||||||
|
document: "Documento",
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
name: "Nombre",
|
||||||
|
size: "Tamaño",
|
||||||
|
mtime: "Modificado",
|
||||||
|
kind: "Tipo",
|
||||||
|
rights: "Permisos",
|
||||||
|
owner: "Propietario",
|
||||||
|
group: "Grupo",
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
choosePanel: "Elige qué muestra este panel",
|
||||||
|
copy: "Enviar",
|
||||||
|
copyToRight: "Enviar al panel derecho",
|
||||||
|
copyToLeft: "Enviar al panel izquierdo",
|
||||||
|
edit: "Editar",
|
||||||
|
editFile: "Editar archivo",
|
||||||
|
rename: "Renombrar",
|
||||||
|
newFolder: "Nueva carpeta",
|
||||||
|
newFile: "Nuevo archivo",
|
||||||
|
permissions: "Permisos",
|
||||||
|
delete: "Eliminar",
|
||||||
|
parentDir: "Carpeta superior",
|
||||||
|
refresh: "Actualizar",
|
||||||
|
clearFilter: "Borrar filtro (Esc)",
|
||||||
|
sshConfig: "~/.ssh/config",
|
||||||
|
connectingTo: "Conectando con {name}…",
|
||||||
|
emptyDir: "Carpeta vacía",
|
||||||
|
noMatches: "Sin coincidencias",
|
||||||
|
matches: { one: "{count} coincidencia · Esc", other: "{count} coincidencias · Esc" },
|
||||||
|
},
|
||||||
|
connMgr: {
|
||||||
|
title: "Conexiones",
|
||||||
|
importWinscp: "Importar de WinSCP…",
|
||||||
|
importWinscpHint: "Importar sesiones de una exportación WinSCP.ini",
|
||||||
|
newConnection: "Conexión",
|
||||||
|
newConnectionHint: "Nueva conexión",
|
||||||
|
newStack: "Grupo",
|
||||||
|
newStackHint: "Nuevo grupo",
|
||||||
|
expand: "Expandir",
|
||||||
|
collapse: "Contraer",
|
||||||
|
renameStack: "Renombrar grupo",
|
||||||
|
deleteStack: "Eliminar grupo",
|
||||||
|
openStack: "Abrir grupo en pestañas",
|
||||||
|
empty: "vacío",
|
||||||
|
connectionsHeader: "Conexiones",
|
||||||
|
noneTitle: "Aún no hay conexiones guardadas.",
|
||||||
|
noneBody: "Crea una o importa tus sesiones de WinSCP.",
|
||||||
|
name: "Nombre",
|
||||||
|
namePlaceholder: "mi servidor",
|
||||||
|
host: "Servidor",
|
||||||
|
port: "Puerto",
|
||||||
|
stack: "Grupo",
|
||||||
|
stackNone: "(ninguno)",
|
||||||
|
username: "Usuario",
|
||||||
|
remotePath: "Ruta remota (opcional)",
|
||||||
|
remotePathPlaceholder: "carpeta de inicio",
|
||||||
|
auth: "Autenticación",
|
||||||
|
authPassword: "Contraseña",
|
||||||
|
authKey: "Clave privada",
|
||||||
|
password: "Contraseña",
|
||||||
|
privateKeyFile: "Archivo de clave privada",
|
||||||
|
passphrase: "Frase de contraseña (opcional)",
|
||||||
|
localDir: "Carpeta local (opcional)",
|
||||||
|
localDirPlaceholder: "~/proyectos/mi-servidor",
|
||||||
|
localDirHint: "El otro panel se abre aquí cuando está en Local y no se ha navegado.",
|
||||||
|
more: "Más…",
|
||||||
|
less: "Menos…",
|
||||||
|
selectPrompt: "Selecciona una conexión o crea una nueva.",
|
||||||
|
newStackTitle: "Nuevo grupo",
|
||||||
|
newStackPrompt: "nombre del grupo",
|
||||||
|
renameStackTitle: "Renombrar grupo",
|
||||||
|
deleteStackTitle: "Eliminar grupo",
|
||||||
|
deleteStackBody: '¿Eliminar el grupo "{name}"? Sus conexiones se mantienen, sin agrupar.',
|
||||||
|
deleteConnectionTitle: "Eliminar conexión",
|
||||||
|
deleteConnectionBody: '¿Eliminar "{name}"?',
|
||||||
|
},
|
||||||
|
editor: {
|
||||||
|
noFileTitle: "Ningún archivo que editar",
|
||||||
|
noFileBody: "Esta ventana se abrió sin contexto de editor. Abre archivos desde la aplicación.",
|
||||||
|
wrap: "Ajustar",
|
||||||
|
wrapHint: "Ajustar líneas largas — oculta los números de línea",
|
||||||
|
findHint: "Buscar",
|
||||||
|
findPlaceholder: "Buscar en el archivo",
|
||||||
|
prevMatch: "Coincidencia anterior",
|
||||||
|
nextMatch: "Coincidencia siguiente",
|
||||||
|
closeHint: "Cerrar (Esc)",
|
||||||
|
disconnected: "Desconectado de {name} — no se podrá guardar, pero tu texto se conserva en esta ventana.",
|
||||||
|
cantOpen: "No se puede abrir {name}",
|
||||||
|
closeWindow: "Cerrar ventana",
|
||||||
|
verbs: { save: "guardar", find: "buscar", close: "cerrar" },
|
||||||
|
lines: { one: "{count} línea", other: "{count} líneas" },
|
||||||
|
close: "Cerrar",
|
||||||
|
save: "Guardar",
|
||||||
|
saveChangesTitle: '¿Guardar los cambios en "{name}"?',
|
||||||
|
saveChangesBody: "Perderás tus cambios si no los guardas.",
|
||||||
|
dontSave: "No guardar",
|
||||||
|
countLabel: "{current} de {total}",
|
||||||
|
noMatches: "Sin coincidencias",
|
||||||
|
},
|
||||||
|
transfer: {
|
||||||
|
summary: "{active} activas · {finished} completadas",
|
||||||
|
clearFinished: "Limpiar completadas",
|
||||||
|
cancel: "Cancelar",
|
||||||
|
dismiss: "Descartar",
|
||||||
|
showDetails: "Ver detalles de transferencias",
|
||||||
|
copying: "Copiando {name} · {percent}%{speed}",
|
||||||
|
queued: "{count} en cola",
|
||||||
|
failed: { one: "{count} transferencia fallida", other: "{count} transferencias fallidas" },
|
||||||
|
finished: "Transferencias completadas",
|
||||||
|
ready: "Listo",
|
||||||
|
items: "{count} elementos",
|
||||||
|
selected: "{count} seleccionados",
|
||||||
|
queuedState: "En cola",
|
||||||
|
scanning: "Analizando…",
|
||||||
|
failedState: "Fallida",
|
||||||
|
cancelled: "Cancelada",
|
||||||
|
completedFiles: { one: "{count} archivo · {size}", other: "{count} archivos · {size}" },
|
||||||
|
left: "{eta} restante",
|
||||||
|
},
|
||||||
|
dialogs: {
|
||||||
|
chmodTitle: "Permisos",
|
||||||
|
chmodItems: "{count} elementos",
|
||||||
|
read: "Lectura",
|
||||||
|
write: "Escritura",
|
||||||
|
execute: "Ejecución",
|
||||||
|
roleOwner: "Propietario",
|
||||||
|
roleGroup: "Grupo",
|
||||||
|
roleOthers: "Otros",
|
||||||
|
octal: "Octal",
|
||||||
|
mergeFolder: "Combinar carpeta",
|
||||||
|
replace: "Reemplazar",
|
||||||
|
merge: "Combinar",
|
||||||
|
mergeAll: "Combinar todo",
|
||||||
|
replaceAll: "Reemplazar todo",
|
||||||
|
skip: "Omitir",
|
||||||
|
skipAll: "Omitir todo",
|
||||||
|
ofTotal: "{index} de {total}",
|
||||||
|
overwriteDirBody: "El destino ya tiene una carpeta con este nombre. Combinar puede reemplazar archivos dentro.",
|
||||||
|
overwriteFileBody: "El destino ya tiene un archivo con este nombre.",
|
||||||
|
source: "Origen",
|
||||||
|
destination: "Destino",
|
||||||
|
newer: "más reciente",
|
||||||
|
},
|
||||||
|
toasts: {
|
||||||
|
movedToTrash: 'Se movió "{name}" a la papelera',
|
||||||
|
movedManyToTrash: { one: "Se movió {count} elemento a la papelera", other: "Se movieron {count} elementos a la papelera" },
|
||||||
|
editingExternal: "Editando {name} — los cambios se suben automáticamente",
|
||||||
|
nothingToCopy: "Nada que copiar (los enlaces simbólicos se omiten)",
|
||||||
|
nothingToCopyAllSkipped: "Nada que copiar — se omitieron todos los elementos en conflicto",
|
||||||
|
sameFolder: "Ambos paneles muestran la misma carpeta",
|
||||||
|
sameFile: 'Se omitió "{name}" — es el mismo archivo',
|
||||||
|
typeMismatch: 'Se omitió "{name}" — hay otro tipo de elemento en el camino',
|
||||||
|
transferFailed: 'La transferencia "{name}" falló: {error}',
|
||||||
|
unknownError: "error desconocido",
|
||||||
|
saved: "Se guardó {name}",
|
||||||
|
disconnectedReason: "{title}: desconectado ({reason})",
|
||||||
|
imported: { one: "Se importó {count} conexión", other: "Se importaron {count} conexiones" },
|
||||||
|
importedIntoStacks: { one: " en {count} grupo nuevo", other: " en {count} grupos nuevos" },
|
||||||
|
importedSkipped: " ({skipped} omitidas)",
|
||||||
|
leftoverTitle: "Archivos editados sobrantes",
|
||||||
|
leftoverMore: ", +{count} más",
|
||||||
|
leftoverBody: {
|
||||||
|
one: "Una sesión anterior dejó {count} archivo editado temporal ({names}). ¿Eliminarlo?",
|
||||||
|
other: "Una sesión anterior dejó {count} archivos editados temporales ({names}). ¿Eliminarlos?",
|
||||||
|
},
|
||||||
|
leftoverClear: "Eliminar",
|
||||||
|
},
|
||||||
|
fileActions: {
|
||||||
|
deleteTitle: "Eliminar",
|
||||||
|
deleteBody: "¿Eliminar {what}? Las carpetas se eliminan de forma recursiva. Esto no se puede deshacer.",
|
||||||
|
deleteConfirm: "Eliminar",
|
||||||
|
renameTitle: 'Renombrar "{name}"',
|
||||||
|
renameConfirm: "Renombrar",
|
||||||
|
newFolderTitle: "Nueva carpeta",
|
||||||
|
newFolderPlaceholder: "nombre de la carpeta",
|
||||||
|
newFileTitle: "Nuevo archivo",
|
||||||
|
newFilePlaceholder: "nombre del archivo",
|
||||||
|
createConfirm: "Crear",
|
||||||
|
itemsCount: "{count} elementos",
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
about: "Acerca de {app}",
|
||||||
|
settings: "Ajustes…",
|
||||||
|
checkForUpdates: "Buscar actualizaciones…",
|
||||||
|
quit: "Salir",
|
||||||
|
file: "Archivo",
|
||||||
|
newTab: "Nueva pestaña",
|
||||||
|
closeTab: "Cerrar pestaña",
|
||||||
|
newFolder: "Nueva carpeta",
|
||||||
|
newFile: "Nuevo archivo",
|
||||||
|
editFile: "Editar archivo",
|
||||||
|
connect: "Conectar",
|
||||||
|
connectionManager: "Gestor de conexiones…",
|
||||||
|
edit: "Edición",
|
||||||
|
view: "Ver",
|
||||||
|
refresh: "Actualizar",
|
||||||
|
window: "Ventana",
|
||||||
|
},
|
||||||
|
ctx: {
|
||||||
|
size: "Tamaño",
|
||||||
|
modified: "Modificado",
|
||||||
|
kind: "Tipo",
|
||||||
|
rights: "Permisos",
|
||||||
|
owner: "Propietario",
|
||||||
|
group: "Grupo",
|
||||||
|
revealFinder: "Mostrar en Finder",
|
||||||
|
revealExplorer: "Mostrar en el Explorador",
|
||||||
|
revealFileManager: "Mostrar en el gestor de archivos",
|
||||||
|
moveToTrash: "Mover a la papelera",
|
||||||
|
moveToRecycleBin: "Mover a la papelera de reciclaje",
|
||||||
|
newFolder: "Nueva carpeta",
|
||||||
|
newFile: "Nuevo archivo",
|
||||||
|
refresh: "Actualizar",
|
||||||
|
open: "Abrir",
|
||||||
|
openDefault: "Abrir con la aplicación predeterminada",
|
||||||
|
edit: "Editar",
|
||||||
|
copyToOther: "Enviar al otro panel",
|
||||||
|
rename: "Renombrar",
|
||||||
|
permissions: "Permisos",
|
||||||
|
copyPath: "Copiar ruta",
|
||||||
|
delete: "Eliminar",
|
||||||
|
columns: "Columnas",
|
||||||
|
tabNew: "Nueva pestaña",
|
||||||
|
tabClose: "Cerrar pestaña",
|
||||||
|
tabCloseOthers: "Cerrar las demás pestañas",
|
||||||
|
tabCloseRight: "Cerrar pestañas a la derecha",
|
||||||
|
tabDisconnect: "Desconectar",
|
||||||
|
trashTooltip: "La opción Restaurar de Finder no está disponible para elementos enviados a la papelera desde aquí",
|
||||||
|
},
|
||||||
|
keys: {
|
||||||
|
"cursor-up": "Subir",
|
||||||
|
"cursor-down": "Bajar",
|
||||||
|
"page-up": "Página arriba",
|
||||||
|
"page-down": "Página abajo",
|
||||||
|
"cursor-first": "Primer elemento",
|
||||||
|
"cursor-last": "Último elemento",
|
||||||
|
"toggle-select": "Seleccionar / deseleccionar",
|
||||||
|
"select-all": "Seleccionar todo",
|
||||||
|
open: "Abrir",
|
||||||
|
parent: "Carpeta superior",
|
||||||
|
"switch-panel": "Cambiar de panel",
|
||||||
|
"row-menu": "Mostrar el menú del elemento",
|
||||||
|
send: "Enviar al otro panel",
|
||||||
|
edit: "Editar",
|
||||||
|
rename: "Renombrar",
|
||||||
|
"new-folder": "Nueva carpeta",
|
||||||
|
"new-file": "Nuevo archivo",
|
||||||
|
chmod: "Permisos",
|
||||||
|
delete: "Eliminar",
|
||||||
|
trash: "Mover a la papelera",
|
||||||
|
"copy-path": "Copiar ruta",
|
||||||
|
reveal: "Mostrar en el gestor de archivos",
|
||||||
|
"open-default": "Abrir con la app predeterminada",
|
||||||
|
refresh: "Actualizar",
|
||||||
|
"focus-path": "Ir a la ruta",
|
||||||
|
"source-picker": "Elegir qué muestra el panel",
|
||||||
|
"sort-name": "Ordenar por nombre",
|
||||||
|
"sort-size": "Ordenar por tamaño",
|
||||||
|
"sort-mtime": "Ordenar por fecha de modificación",
|
||||||
|
"sort-kind": "Ordenar por tipo",
|
||||||
|
"sort-rights": "Ordenar por permisos",
|
||||||
|
"sort-owner": "Ordenar por propietario",
|
||||||
|
"sort-group": "Ordenar por grupo",
|
||||||
|
"new-tab": "Nueva pestaña",
|
||||||
|
"close-tab": "Cerrar pestaña",
|
||||||
|
"close-other-tabs": "Cerrar las demás pestañas",
|
||||||
|
"close-tabs-right": "Cerrar las pestañas de la derecha",
|
||||||
|
"next-tab": "Pestaña siguiente",
|
||||||
|
"prev-tab": "Pestaña anterior",
|
||||||
|
"go-to-tab": "Ir a la pestaña {n}",
|
||||||
|
"go-to-last-tab": "Ir a la última pestaña",
|
||||||
|
"move-tab-left": "Mover la pestaña a la izquierda",
|
||||||
|
"move-tab-right": "Mover la pestaña a la derecha",
|
||||||
|
"disconnect-tab": "Desconectar la pestaña",
|
||||||
|
"open-settings": "Ajustes",
|
||||||
|
"connection-manager": "Gestor de conexiones",
|
||||||
|
"toggle-transfers": "Mostrar / ocultar transferencias",
|
||||||
|
"install-update": "Instalar la actualización",
|
||||||
|
"scale-up": "Interfaz más grande",
|
||||||
|
"scale-down": "Interfaz más pequeña",
|
||||||
|
"scale-reset": "Interfaz al 100 %",
|
||||||
|
"close-window": "Cerrar la ventana",
|
||||||
|
save: "Guardar",
|
||||||
|
find: "Buscar",
|
||||||
|
"find-next": "Buscar siguiente",
|
||||||
|
"find-prev": "Buscar anterior",
|
||||||
|
"toggle-wrap": "Ajustar líneas",
|
||||||
|
"conn-new": "Nueva conexión",
|
||||||
|
"conn-new-stack": "Nuevo grupo",
|
||||||
|
"conn-save": "Guardar la conexión",
|
||||||
|
"conn-connect": "Conectar / abrir grupo",
|
||||||
|
"conn-delete": "Eliminar conexión o grupo",
|
||||||
|
"conn-rename-stack": "Renombrar grupo",
|
||||||
|
"conn-import": "Importar de WinSCP",
|
||||||
|
groups: {
|
||||||
|
panels: "Lista de archivos",
|
||||||
|
files: "Archivos",
|
||||||
|
sort: "Orden",
|
||||||
|
tabs: "Pestañas",
|
||||||
|
window: "Ventana",
|
||||||
|
editor: "Editor",
|
||||||
|
connections: "Gestor de conexiones",
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
filter: "Buscar atajos",
|
||||||
|
intro: "Pulsa + y teclea las teclas para añadir un atajo. El primero es el que muestran los menús y las pistas.",
|
||||||
|
add: "Añadir un atajo",
|
||||||
|
remove: "Quitar {key}",
|
||||||
|
reset: "Restablecer el predeterminado",
|
||||||
|
resetAll: "Restablecer todo",
|
||||||
|
resetAllConfirm: "Restablecer todos los atajos",
|
||||||
|
press: "Pulsa las teclas… ({esc} cancela)",
|
||||||
|
unbound: "Sin atajo",
|
||||||
|
reserved: "{key} está reservado por el sistema",
|
||||||
|
needsModifier: "En el editor {key} solo escribiría: añade un modificador o usa una tecla F",
|
||||||
|
usedBy: "{key} ya lo usa «{action}»",
|
||||||
|
replace: "Reemplazar",
|
||||||
|
conflict: "Misma tecla que {action}",
|
||||||
|
plainLetter: "Teclear {key} en la lista de archivos ya no la filtra",
|
||||||
|
noResults: "Ningún atajo coincide",
|
||||||
|
fixed: "Teclas fijas",
|
||||||
|
},
|
||||||
|
fixed: {
|
||||||
|
escape: "{esc} cierra menús, diálogos y la barra de búsqueda",
|
||||||
|
escList: "{esc} en la lista de archivos borra el filtro, luego cancela una conexión y luego quita la selección",
|
||||||
|
dialogs: "{enter} y {esc} confirman o cancelan un diálogo; {tab} y {shiftTab} recorren sus controles",
|
||||||
|
menus: "↑ ↓ {home} {end} y {enter} funcionan en todos los menús",
|
||||||
|
filter: "Teclear en la lista de archivos la filtra; {backspace} borra el último carácter del filtro",
|
||||||
|
shift: "Mayús con una tecla de movimiento selecciona las filas por las que pasa el cursor",
|
||||||
|
find: "{enter} y {shiftEnter} recorren las coincidencias de la búsqueda",
|
||||||
|
manager: "↑ ↓ ← → y {enter} recorren la lista de conexiones",
|
||||||
|
sidebar: "↑ ↓ cambian de sección aquí",
|
||||||
|
},
|
||||||
|
names: {
|
||||||
|
ctrl: "Ctrl",
|
||||||
|
alt: "Alt",
|
||||||
|
shift: "Mayús",
|
||||||
|
enter: "Intro",
|
||||||
|
backspace: "Retroceso",
|
||||||
|
del: "Supr",
|
||||||
|
esc: "Esc",
|
||||||
|
space: "Espacio",
|
||||||
|
tab: "Tab",
|
||||||
|
pgup: "RePág",
|
||||||
|
pgdn: "AvPág",
|
||||||
|
home: "Inicio",
|
||||||
|
end: "Fin",
|
||||||
|
ins: "Insert",
|
||||||
|
menu: "Menú",
|
||||||
|
num: "Num",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
sshAgentUnavailable: "ssh-agent no disponible (SSH_AUTH_SOCK no está definido)",
|
||||||
|
identityFileNotFound: "archivo de identidad no encontrado: {path}",
|
||||||
|
sessionNotConnected: 'La sesión "{id}" no está conectada',
|
||||||
|
localCannotDisconnect: "La sesión local no se puede desconectar",
|
||||||
|
notADirectory: '"{path}" existe y no es una carpeta',
|
||||||
|
sameFileTransfer: '"{name}": el origen y el destino son el mismo archivo',
|
||||||
|
sameFileNoName: "El origen y el destino son el mismo archivo",
|
||||||
|
folderOntoItself: "No se puede copiar una carpeta sobre sí misma",
|
||||||
|
folderIntoItself: "No se puede copiar una carpeta dentro de sí misma",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
// Basque (Euskara) translations. Fall back to English for missing keys.
|
||||||
|
// TODO(i18n): AI draft — REQUIRES native-speaker review (Basque is error-prone
|
||||||
|
// for machine/AI translation; treat every string as provisional).
|
||||||
|
export const eu = {
|
||||||
|
common: {
|
||||||
|
cancel: "Utzi",
|
||||||
|
save: "Gorde",
|
||||||
|
close: "Itxi",
|
||||||
|
delete: "Ezabatu",
|
||||||
|
rename: "Berrizendatu",
|
||||||
|
create: "Sortu",
|
||||||
|
apply: "Aplikatu",
|
||||||
|
choose: "Aukeratu…",
|
||||||
|
connect: "Konektatu",
|
||||||
|
local: "Lokala",
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
notConnectedTitle: "Ez dago aplikaziora konektatuta",
|
||||||
|
notConnectedBody: "Orri hau nabigatzaile arrunt batean exekutatzen ari da. Ireki mahaigaineko aplikazioa erabiltzeko.",
|
||||||
|
managerOpen: "Konexio-kudeatzailea irekita dago.",
|
||||||
|
showConnections: "Erakutsi konexioak",
|
||||||
|
},
|
||||||
|
tabBar: {
|
||||||
|
connections: "Konexioak",
|
||||||
|
manageConnections: "Kudeatu konexioak",
|
||||||
|
newTab: "Fitxa berria",
|
||||||
|
closeTab: "Itxi fitxa",
|
||||||
|
options: "Ezarpenak",
|
||||||
|
update: "Eguneratzea eskuragarri",
|
||||||
|
updating: "Eguneratzen",
|
||||||
|
restartToUpdate: "Berrabiarazi aplikatzeko",
|
||||||
|
updateTooltip: "Eguneratzea eskuragarri: miniSCP {version}",
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
title: "Ezarpenak",
|
||||||
|
sections: {
|
||||||
|
appearance: "Itxura",
|
||||||
|
transfers: "Transferentziak",
|
||||||
|
editor: "Editorea",
|
||||||
|
fileList: "Fitxategi-zerrenda",
|
||||||
|
keyboard: "Teklatua",
|
||||||
|
about: "Honi buruz",
|
||||||
|
},
|
||||||
|
columns: "Zutabe ikusgaiak",
|
||||||
|
language: "Hizkuntza",
|
||||||
|
system: "Sistema",
|
||||||
|
theme: { label: "Gaia", system: "Sistema", light: "Argia", dark: "Iluna" },
|
||||||
|
tone: { label: "Tonua", dark: "Iluna", darker: "AMOLED" },
|
||||||
|
interface: { label: "Interfazea", smaller: "Txikiagoa", larger: "Handiagoa", reset: "Berrezarri %100era" },
|
||||||
|
speedUnit: { label: "Abiadura-unitatea" },
|
||||||
|
transfers: { label: "Transferentziak", single: "Banaka", multi: "Anitz" },
|
||||||
|
atOnce: "Aldi berean",
|
||||||
|
editor: { label: "Editorea", builtin: "Barnekoa", custom: "Pertsonalizatua" },
|
||||||
|
systemDefault: "Sistemaren lehenetsia",
|
||||||
|
resetDefault: "Berrezarri lehenetsira",
|
||||||
|
finishedTransfers: { label: "Amaitutako transferentziak", autoClear: "Garbitu", keep: "Mantendu" },
|
||||||
|
openFiles: { label: "Sartu / klik bikoitza fitxategi batean", nothing: "Ezer ez", open: "Ireki", hint: "Fitxategi lokalak beren aplikazio lehenetsiarekin irekitzen dira; karpetak beti irekitzen dira." },
|
||||||
|
},
|
||||||
|
kinds: {
|
||||||
|
folder: "Karpeta",
|
||||||
|
symlink: "Esteka sinbolikoa",
|
||||||
|
file: "Fitxategia",
|
||||||
|
image: "Irudia",
|
||||||
|
audio: "Audioa",
|
||||||
|
video: "Bideoa",
|
||||||
|
archive: "Artxiboa",
|
||||||
|
code: "Kodea",
|
||||||
|
document: "Dokumentua",
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
name: "Izena",
|
||||||
|
size: "Tamaina",
|
||||||
|
mtime: "Aldatze-data",
|
||||||
|
kind: "Mota",
|
||||||
|
rights: "Baimenak",
|
||||||
|
owner: "Jabea",
|
||||||
|
group: "Taldea",
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
choosePanel: "Aukeratu panel honek zer erakusten duen",
|
||||||
|
copy: "Bidali",
|
||||||
|
copyToRight: "Bidali eskuineko panelera",
|
||||||
|
copyToLeft: "Bidali ezkerreko panelera",
|
||||||
|
edit: "Editatu",
|
||||||
|
editFile: "Editatu fitxategia",
|
||||||
|
rename: "Berrizendatu",
|
||||||
|
newFolder: "Karpeta berria",
|
||||||
|
newFile: "Fitxategi berria",
|
||||||
|
permissions: "Baimenak",
|
||||||
|
delete: "Ezabatu",
|
||||||
|
parentDir: "Karpeta gurasoa",
|
||||||
|
refresh: "Freskatu",
|
||||||
|
clearFilter: "Garbitu iragazkia (Esc)",
|
||||||
|
sshConfig: "~/.ssh/config",
|
||||||
|
connectingTo: "{name}(e)ra konektatzen…",
|
||||||
|
emptyDir: "Karpeta hutsa",
|
||||||
|
noMatches: "Bat-etortzerik ez",
|
||||||
|
matches: { one: "bat-etortze {count} · Esc", other: "{count} bat-etortze · Esc" },
|
||||||
|
},
|
||||||
|
connMgr: {
|
||||||
|
title: "Konexioak",
|
||||||
|
importWinscp: "Inportatu WinSCPtik…",
|
||||||
|
importWinscpHint: "Inportatu saioak WinSCP.ini esportazio batetik",
|
||||||
|
newConnection: "Konexioa",
|
||||||
|
newConnectionHint: "Konexio berria",
|
||||||
|
newStack: "Taldea",
|
||||||
|
newStackHint: "Talde berria",
|
||||||
|
expand: "Zabaldu",
|
||||||
|
collapse: "Tolestu",
|
||||||
|
renameStack: "Berrizendatu taldea",
|
||||||
|
deleteStack: "Ezabatu taldea",
|
||||||
|
openStack: "Ireki taldea fitxetan",
|
||||||
|
empty: "hutsik",
|
||||||
|
connectionsHeader: "Konexioak",
|
||||||
|
noneTitle: "Oraindik ez dago gordetako konexiorik.",
|
||||||
|
noneBody: "Sortu bat edo inportatu zure WinSCP saioak.",
|
||||||
|
name: "Izena",
|
||||||
|
namePlaceholder: "nire zerbitzaria",
|
||||||
|
host: "Ostalaria",
|
||||||
|
port: "Ataka",
|
||||||
|
stack: "Taldea",
|
||||||
|
stackNone: "(bat ere ez)",
|
||||||
|
username: "Erabiltzailea",
|
||||||
|
remotePath: "Urruneko bidea (aukerakoa)",
|
||||||
|
remotePathPlaceholder: "hasierako karpeta",
|
||||||
|
auth: "Autentifikazioa",
|
||||||
|
authPassword: "Pasahitza",
|
||||||
|
authKey: "Gako pribatua",
|
||||||
|
password: "Pasahitza",
|
||||||
|
privateKeyFile: "Gako pribatuaren fitxategia",
|
||||||
|
passphrase: "Pasaesaldia (aukerakoa)",
|
||||||
|
localDir: "Karpeta lokala (aukerakoa)",
|
||||||
|
localDirPlaceholder: "~/proiektuak/nire-zerbitzaria",
|
||||||
|
localDirHint: "Beste panela hemen irekiko da Lokalean dagoenean eta nabigatu ez denean.",
|
||||||
|
more: "Gehiago…",
|
||||||
|
less: "Gutxiago…",
|
||||||
|
selectPrompt: "Hautatu konexio bat edo sortu berri bat.",
|
||||||
|
newStackTitle: "Talde berria",
|
||||||
|
newStackPrompt: "taldearen izena",
|
||||||
|
renameStackTitle: "Berrizendatu taldea",
|
||||||
|
deleteStackTitle: "Ezabatu taldea",
|
||||||
|
deleteStackBody: 'Ezabatu "{name}" taldea? Bere konexioak mantendu egingo dira, taldekatu gabe.',
|
||||||
|
deleteConnectionTitle: "Ezabatu konexioa",
|
||||||
|
deleteConnectionBody: 'Ezabatu "{name}"?',
|
||||||
|
},
|
||||||
|
editor: {
|
||||||
|
noFileTitle: "Ez dago editatzeko fitxategirik",
|
||||||
|
noFileBody: "Leiho hau editore-testuingururik gabe ireki da. Ireki fitxategiak aplikaziotik.",
|
||||||
|
wrap: "Doitu",
|
||||||
|
wrapHint: "Doitu lerro luzeak — lerro-zenbakiak ezkutatzen ditu",
|
||||||
|
findHint: "Bilatu",
|
||||||
|
findPlaceholder: "Bilatu fitxategian",
|
||||||
|
prevMatch: "Aurreko bat-etortzea",
|
||||||
|
nextMatch: "Hurrengo bat-etortzea",
|
||||||
|
closeHint: "Itxi (Esc)",
|
||||||
|
disconnected: "{name}(e)tik deskonektatuta — gordetzeak huts egingo du, baina zure testua leiho honetan gordeta dago.",
|
||||||
|
cantOpen: "Ezin da {name} ireki",
|
||||||
|
closeWindow: "Itxi leihoa",
|
||||||
|
verbs: { save: "gorde", find: "bilatu", close: "itxi" },
|
||||||
|
lines: { one: "lerro {count}", other: "{count} lerro" },
|
||||||
|
close: "Itxi",
|
||||||
|
save: "Gorde",
|
||||||
|
saveChangesTitle: 'Gorde "{name}"(r)en aldaketak?',
|
||||||
|
saveChangesBody: "Zure aldaketak galduko dira gordetzen ez badituzu.",
|
||||||
|
dontSave: "Ez gorde",
|
||||||
|
countLabel: "{total}(e)tik {current}",
|
||||||
|
noMatches: "Bat-etortzerik ez",
|
||||||
|
},
|
||||||
|
transfer: {
|
||||||
|
summary: "{active} aktibo · {finished} amaituta",
|
||||||
|
clearFinished: "Garbitu amaituak",
|
||||||
|
cancel: "Utzi",
|
||||||
|
dismiss: "Baztertu",
|
||||||
|
showDetails: "Erakutsi transferentzien xehetasunak",
|
||||||
|
copying: "{name} kopiatzen · %{percent}{speed}",
|
||||||
|
queued: "{count} ilaran",
|
||||||
|
failed: { one: "transferentzia {count}(e)k huts egin du", other: "{count} transferentziak huts egin dute" },
|
||||||
|
finished: "Transferentziak amaituta",
|
||||||
|
ready: "Prest",
|
||||||
|
items: "{count} elementu",
|
||||||
|
selected: "{count} hautatuta",
|
||||||
|
queuedState: "Ilaran",
|
||||||
|
scanning: "Aztertzen…",
|
||||||
|
failedState: "Huts egin du",
|
||||||
|
cancelled: "Bertan behera utzita",
|
||||||
|
completedFiles: { one: "fitxategi {count} · {size}", other: "{count} fitxategi · {size}" },
|
||||||
|
left: "{eta} geratzen da",
|
||||||
|
},
|
||||||
|
dialogs: {
|
||||||
|
chmodTitle: "Baimenak",
|
||||||
|
chmodItems: "{count} elementu",
|
||||||
|
read: "Irakurri",
|
||||||
|
write: "Idatzi",
|
||||||
|
execute: "Exekutatu",
|
||||||
|
roleOwner: "Jabea",
|
||||||
|
roleGroup: "Taldea",
|
||||||
|
roleOthers: "Besteak",
|
||||||
|
octal: "Zortzitarra",
|
||||||
|
mergeFolder: "Batu karpeta",
|
||||||
|
replace: "Ordeztu",
|
||||||
|
merge: "Batu",
|
||||||
|
mergeAll: "Batu guztiak",
|
||||||
|
replaceAll: "Ordeztu guztiak",
|
||||||
|
skip: "Saltatu",
|
||||||
|
skipAll: "Saltatu guztiak",
|
||||||
|
ofTotal: "{total}(e)tik {index}",
|
||||||
|
overwriteDirBody: "Helmugan izen bereko karpeta bat dago jada. Batzeak barneko fitxategiak ordeztu ditzake.",
|
||||||
|
overwriteFileBody: "Helmugan izen bereko fitxategi bat dago jada.",
|
||||||
|
source: "Iturburua",
|
||||||
|
destination: "Helmuga",
|
||||||
|
newer: "berriagoa",
|
||||||
|
},
|
||||||
|
toasts: {
|
||||||
|
movedToTrash: '"{name}" zakarrontzira eraman da',
|
||||||
|
movedManyToTrash: { one: "{count} elementu zakarrontzira eraman da", other: "{count} elementu zakarrontzira eraman dira" },
|
||||||
|
editingExternal: "{name} editatzen — gordetzean automatikoki igotzen da",
|
||||||
|
nothingToCopy: "Ez dago kopiatzeko ezer (esteka sinbolikoak saltatu egiten dira)",
|
||||||
|
nothingToCopyAllSkipped: "Ez dago kopiatzeko ezer — gatazkan zeuden elementu guztiak saltatu dira",
|
||||||
|
sameFolder: "Bi panelek karpeta bera erakusten dute",
|
||||||
|
sameFile: '"{name}" saltatu da — fitxategi bera da',
|
||||||
|
typeMismatch: '"{name}" saltatu da — beste mota bateko elementu bat dago bidean',
|
||||||
|
transferFailed: '"{name}" transferentziak huts egin du: {error}',
|
||||||
|
unknownError: "errore ezezaguna",
|
||||||
|
saved: "{name} gorde da",
|
||||||
|
disconnectedReason: "{title}: deskonektatuta ({reason})",
|
||||||
|
imported: { one: "konexio {count} inportatu da", other: "{count} konexio inportatu dira" },
|
||||||
|
importedIntoStacks: { one: " talde berri {count}ean", other: " {count} talde berritan" },
|
||||||
|
importedSkipped: " ({skipped} saltatuta)",
|
||||||
|
leftoverTitle: "Geratutako editatutako fitxategiak",
|
||||||
|
leftoverMore: ", +{count} gehiago",
|
||||||
|
leftoverBody: {
|
||||||
|
one: "Aurreko saio batek editatutako aldi baterako fitxategi {count} utzi zuen ({names}). Garbitu?",
|
||||||
|
other: "Aurreko saio batek editatutako aldi baterako {count} fitxategi utzi zituen ({names}). Garbitu?",
|
||||||
|
},
|
||||||
|
leftoverClear: "Garbitu",
|
||||||
|
},
|
||||||
|
fileActions: {
|
||||||
|
deleteTitle: "Ezabatu",
|
||||||
|
deleteBody: "Ezabatu {what}? Karpetak errekurtsiboki ezabatzen dira. Ezin da desegin.",
|
||||||
|
deleteConfirm: "Ezabatu",
|
||||||
|
renameTitle: 'Berrizendatu "{name}"',
|
||||||
|
renameConfirm: "Berrizendatu",
|
||||||
|
newFolderTitle: "Karpeta berria",
|
||||||
|
newFolderPlaceholder: "karpetaren izena",
|
||||||
|
newFileTitle: "Fitxategi berria",
|
||||||
|
newFilePlaceholder: "fitxategiaren izena",
|
||||||
|
createConfirm: "Sortu",
|
||||||
|
itemsCount: "{count} elementu",
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
about: "{app} (r)i buruz",
|
||||||
|
settings: "Ezarpenak…",
|
||||||
|
checkForUpdates: "Bilatu eguneratzeak…",
|
||||||
|
quit: "Irten",
|
||||||
|
file: "Fitxategia",
|
||||||
|
newTab: "Fitxa berria",
|
||||||
|
closeTab: "Itxi fitxa",
|
||||||
|
newFolder: "Karpeta berria",
|
||||||
|
newFile: "Fitxategi berria",
|
||||||
|
editFile: "Editatu fitxategia",
|
||||||
|
connect: "Konektatu",
|
||||||
|
connectionManager: "Konexio-kudeatzailea…",
|
||||||
|
edit: "Editatu",
|
||||||
|
view: "Ikusi",
|
||||||
|
refresh: "Freskatu",
|
||||||
|
window: "Leihoa",
|
||||||
|
},
|
||||||
|
ctx: {
|
||||||
|
size: "Tamaina",
|
||||||
|
modified: "Aldatze-data",
|
||||||
|
kind: "Mota",
|
||||||
|
rights: "Baimenak",
|
||||||
|
owner: "Jabea",
|
||||||
|
group: "Taldea",
|
||||||
|
revealFinder: "Erakutsi Finder-en",
|
||||||
|
revealExplorer: "Erakutsi Explorer-en",
|
||||||
|
revealFileManager: "Erakutsi fitxategi-kudeatzailean",
|
||||||
|
moveToTrash: "Eraman zakarrontzira",
|
||||||
|
moveToRecycleBin: "Eraman birziklatze-ontzira",
|
||||||
|
newFolder: "Karpeta berria",
|
||||||
|
newFile: "Fitxategi berria",
|
||||||
|
refresh: "Freskatu",
|
||||||
|
open: "Ireki",
|
||||||
|
openDefault: "Ireki lehenetsitako aplikazioarekin",
|
||||||
|
edit: "Editatu",
|
||||||
|
copyToOther: "Bidali beste panelera",
|
||||||
|
rename: "Berrizendatu",
|
||||||
|
permissions: "Baimenak",
|
||||||
|
copyPath: "Kopiatu bidea",
|
||||||
|
delete: "Ezabatu",
|
||||||
|
columns: "Zutabeak",
|
||||||
|
tabNew: "Fitxa berria",
|
||||||
|
tabClose: "Itxi fitxa",
|
||||||
|
tabCloseOthers: "Itxi beste fitxak",
|
||||||
|
tabCloseRight: "Itxi eskuineko fitxak",
|
||||||
|
tabDisconnect: "Deskonektatu",
|
||||||
|
trashTooltip: "Finder-en «Jarri berriro» ez dago erabilgarri hemendik zakarrontzira botatako elementuentzat",
|
||||||
|
},
|
||||||
|
keys: {
|
||||||
|
"cursor-up": "Gora",
|
||||||
|
"cursor-down": "Behera",
|
||||||
|
"page-up": "Orrialde bat gora",
|
||||||
|
"page-down": "Orrialde bat behera",
|
||||||
|
"cursor-first": "Lehen elementua",
|
||||||
|
"cursor-last": "Azken elementua",
|
||||||
|
"toggle-select": "Hautatu / desautatu",
|
||||||
|
"select-all": "Hautatu dena",
|
||||||
|
open: "Ireki",
|
||||||
|
parent: "Karpeta gurasoa",
|
||||||
|
"switch-panel": "Aldatu panela",
|
||||||
|
"row-menu": "Erakutsi elementuaren menua",
|
||||||
|
send: "Bidali beste panelera",
|
||||||
|
edit: "Editatu",
|
||||||
|
rename: "Berrizendatu",
|
||||||
|
"new-folder": "Karpeta berria",
|
||||||
|
"new-file": "Fitxategi berria",
|
||||||
|
chmod: "Baimenak",
|
||||||
|
delete: "Ezabatu",
|
||||||
|
trash: "Eraman zakarrontzira",
|
||||||
|
"copy-path": "Kopiatu bidea",
|
||||||
|
reveal: "Erakutsi fitxategi-kudeatzailean",
|
||||||
|
"open-default": "Ireki aplikazio lehenetsiarekin",
|
||||||
|
refresh: "Freskatu",
|
||||||
|
"focus-path": "Joan bidera",
|
||||||
|
"source-picker": "Aukeratu panelak zer erakusten duen",
|
||||||
|
"sort-name": "Ordenatu izenaren arabera",
|
||||||
|
"sort-size": "Ordenatu tamainaren arabera",
|
||||||
|
"sort-mtime": "Ordenatu aldaketa-dataren arabera",
|
||||||
|
"sort-kind": "Ordenatu motaren arabera",
|
||||||
|
"sort-rights": "Ordenatu baimenen arabera",
|
||||||
|
"sort-owner": "Ordenatu jabearen arabera",
|
||||||
|
"sort-group": "Ordenatu taldearen arabera",
|
||||||
|
"new-tab": "Fitxa berria",
|
||||||
|
"close-tab": "Itxi fitxa",
|
||||||
|
"close-other-tabs": "Itxi beste fitxak",
|
||||||
|
"close-tabs-right": "Itxi eskuineko fitxak",
|
||||||
|
"next-tab": "Hurrengo fitxa",
|
||||||
|
"prev-tab": "Aurreko fitxa",
|
||||||
|
"go-to-tab": "Joan {n}. fitxara",
|
||||||
|
"go-to-last-tab": "Joan azken fitxara",
|
||||||
|
"move-tab-left": "Eraman fitxa ezkerrera",
|
||||||
|
"move-tab-right": "Eraman fitxa eskuinera",
|
||||||
|
"disconnect-tab": "Deskonektatu fitxa",
|
||||||
|
"open-settings": "Ezarpenak",
|
||||||
|
"connection-manager": "Konexio-kudeatzailea",
|
||||||
|
"toggle-transfers": "Erakutsi / ezkutatu transferentziak",
|
||||||
|
"install-update": "Instalatu eguneraketa",
|
||||||
|
"scale-up": "Interfaze handiagoa",
|
||||||
|
"scale-down": "Interfaze txikiagoa",
|
||||||
|
"scale-reset": "Interfazea % 100ean",
|
||||||
|
"close-window": "Itxi leihoa",
|
||||||
|
save: "Gorde",
|
||||||
|
find: "Bilatu",
|
||||||
|
"find-next": "Bilatu hurrengoa",
|
||||||
|
"find-prev": "Bilatu aurrekoa",
|
||||||
|
"toggle-wrap": "Doitu lerroak",
|
||||||
|
"conn-new": "Konexio berria",
|
||||||
|
"conn-new-stack": "Talde berria",
|
||||||
|
"conn-save": "Gorde konexioa",
|
||||||
|
"conn-connect": "Konektatu / ireki taldea",
|
||||||
|
"conn-delete": "Ezabatu konexioa edo taldea",
|
||||||
|
"conn-rename-stack": "Berrizendatu taldea",
|
||||||
|
"conn-import": "Inportatu WinSCPtik",
|
||||||
|
groups: {
|
||||||
|
panels: "Fitxategi-zerrenda",
|
||||||
|
files: "Fitxategiak",
|
||||||
|
sort: "Ordena",
|
||||||
|
tabs: "Fitxak",
|
||||||
|
window: "Leihoa",
|
||||||
|
editor: "Editorea",
|
||||||
|
connections: "Konexio-kudeatzailea",
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
filter: "Bilatu lasterbideak",
|
||||||
|
intro: "Sakatu + eta idatzi teklak lasterbide bat gehitzeko. Lehenengoa da menuek eta argibideek erakusten dutena.",
|
||||||
|
add: "Gehitu lasterbide bat",
|
||||||
|
remove: "Kendu {key}",
|
||||||
|
reset: "Berrezarri lehenetsia",
|
||||||
|
resetAll: "Berrezarri dena",
|
||||||
|
resetAllConfirm: "Berrezarri lasterbide guztiak",
|
||||||
|
press: "Sakatu teklak… ({esc} utzi)",
|
||||||
|
unbound: "Lasterbiderik ez",
|
||||||
|
reserved: "{key} sistemak erreserbatuta dauka",
|
||||||
|
needsModifier: "Editorean {key}(e)k idatzi besterik ez luke egingo: gehitu aldatzaile bat edo erabili F tekla bat",
|
||||||
|
usedBy: "{key} «{action}»(e)k erabiltzen du dagoeneko",
|
||||||
|
replace: "Ordeztu",
|
||||||
|
conflict: "{action}(r)en tekla bera",
|
||||||
|
plainLetter: "Fitxategi-zerrendan {key} idazteak ez du jada iragazten",
|
||||||
|
noResults: "Ez dago bat datorren lasterbiderik",
|
||||||
|
fixed: "Tekla finkoak",
|
||||||
|
},
|
||||||
|
fixed: {
|
||||||
|
escape: "{esc}(e)k menuak, elkarrizketa-koadroak eta bilaketa-barra ixten ditu",
|
||||||
|
escList: "Fitxategi-zerrendan {esc}(e)k iragazkia garbitzen du, gero konexioa bertan behera uzten du eta gero hautapena kentzen du",
|
||||||
|
dialogs: "{enter} eta {esc} elkarrizketa-koadroa berresten edo uzten dute; {tab} eta {shiftTab} haren kontroletan mugitzen dira",
|
||||||
|
menus: "↑ ↓ {home} {end} eta {enter} menu guztietan dabiltza",
|
||||||
|
filter: "Fitxategi-zerrendan idazteak iragazi egiten du; {backspace}(e)k iragazkiaren azken karakterea ezabatzen du",
|
||||||
|
shift: "Maius mugimendu-tekla batekin kurtsoreak igarotako errenkadak hautatzen ditu",
|
||||||
|
find: "{enter} eta {shiftEnter} bilaketako bat-etortzeetan mugitzen dira",
|
||||||
|
manager: "↑ ↓ ← → eta {enter} konexio-zerrendan mugitzen dira",
|
||||||
|
sidebar: "↑ ↓ atal batetik bestera mugitzen dira hemen",
|
||||||
|
},
|
||||||
|
names: {
|
||||||
|
ctrl: "Ctrl",
|
||||||
|
alt: "Alt",
|
||||||
|
shift: "Maius",
|
||||||
|
enter: "Intro",
|
||||||
|
backspace: "Atzera",
|
||||||
|
del: "Supr",
|
||||||
|
esc: "Esc",
|
||||||
|
space: "Zuriunea",
|
||||||
|
tab: "Tab",
|
||||||
|
pgup: "RePág",
|
||||||
|
pgdn: "AvPág",
|
||||||
|
home: "Inicio",
|
||||||
|
end: "Fin",
|
||||||
|
ins: "Insert",
|
||||||
|
menu: "Menua",
|
||||||
|
num: "Num",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
sshAgentUnavailable: "ssh-agent ez dago erabilgarri (SSH_AUTH_SOCK ez dago ezarrita)",
|
||||||
|
identityFileNotFound: "identitate-fitxategia ez da aurkitu: {path}",
|
||||||
|
sessionNotConnected: '"{id}" saioa ez dago konektatuta',
|
||||||
|
localCannotDisconnect: "Saio lokala ezin da deskonektatu",
|
||||||
|
notADirectory: '"{path}" badago eta ez da karpeta bat',
|
||||||
|
sameFileTransfer: '"{name}": iturburua eta helmuga fitxategi bera dira',
|
||||||
|
sameFileNoName: "Iturburua eta helmuga fitxategi bera dira",
|
||||||
|
folderOntoItself: "Ezin da karpeta bat bere gainean kopiatu",
|
||||||
|
folderIntoItself: "Ezin da karpeta bat bere barnean kopiatu",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
// French translations. Missing keys fall back to English (see en.ts / index.ts).
|
||||||
|
export const fr = {
|
||||||
|
common: {
|
||||||
|
cancel: "Annuler",
|
||||||
|
save: "Enregistrer",
|
||||||
|
close: "Fermer",
|
||||||
|
delete: "Supprimer",
|
||||||
|
rename: "Renommer",
|
||||||
|
create: "Créer",
|
||||||
|
apply: "Appliquer",
|
||||||
|
choose: "Choisir…",
|
||||||
|
connect: "Connecter",
|
||||||
|
local: "Local",
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
notConnectedTitle: "Non connecté à l'application",
|
||||||
|
notConnectedBody: "Cette page s'exécute dans un navigateur classique. Lancez l'application de bureau pour l'utiliser.",
|
||||||
|
managerOpen: "Le gestionnaire de connexions est ouvert.",
|
||||||
|
showConnections: "Afficher les connexions",
|
||||||
|
},
|
||||||
|
tabBar: {
|
||||||
|
connections: "Connexions",
|
||||||
|
manageConnections: "Gérer les connexions",
|
||||||
|
newTab: "Nouvel onglet",
|
||||||
|
closeTab: "Fermer l'onglet",
|
||||||
|
options: "Réglages",
|
||||||
|
update: "Mise à jour disponible",
|
||||||
|
updating: "Mise à jour",
|
||||||
|
restartToUpdate: "Redémarrer pour appliquer",
|
||||||
|
updateTooltip: "Mise à jour disponible : miniSCP {version}",
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
title: "Réglages",
|
||||||
|
sections: {
|
||||||
|
appearance: "Apparence",
|
||||||
|
transfers: "Transferts",
|
||||||
|
editor: "Éditeur",
|
||||||
|
fileList: "Liste des fichiers",
|
||||||
|
keyboard: "Clavier",
|
||||||
|
about: "À propos",
|
||||||
|
},
|
||||||
|
columns: "Colonnes visibles",
|
||||||
|
language: "Langue",
|
||||||
|
system: "Système",
|
||||||
|
theme: { label: "Thème", system: "Système", light: "Clair", dark: "Sombre" },
|
||||||
|
tone: { label: "Ton", dark: "Sombre", darker: "AMOLED" },
|
||||||
|
interface: { label: "Interface", smaller: "Plus petite", larger: "Plus grande", reset: "Réinitialiser à 100%" },
|
||||||
|
speedUnit: { label: "Unité de vitesse" },
|
||||||
|
transfers: { label: "Transferts", single: "Un", multi: "Plusieurs" },
|
||||||
|
atOnce: "Simultanés",
|
||||||
|
editor: { label: "Éditeur", builtin: "Intégré", custom: "Personnalisé" },
|
||||||
|
systemDefault: "Valeur par défaut du système",
|
||||||
|
resetDefault: "Réinitialiser par défaut",
|
||||||
|
finishedTransfers: { label: "Transferts terminés", autoClear: "Effacer auto.", keep: "Garder" },
|
||||||
|
openFiles: { label: "Entrée / double-clic sur un fichier", nothing: "Ne rien faire", open: "Ouvrir", hint: "Les fichiers locaux s'ouvrent avec leur application par défaut ; les dossiers s'ouvrent toujours." },
|
||||||
|
},
|
||||||
|
kinds: {
|
||||||
|
folder: "Dossier",
|
||||||
|
symlink: "Lien symbolique",
|
||||||
|
file: "Fichier",
|
||||||
|
image: "Image",
|
||||||
|
audio: "Audio",
|
||||||
|
video: "Vidéo",
|
||||||
|
archive: "Archive",
|
||||||
|
code: "Code",
|
||||||
|
document: "Document",
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
name: "Nom",
|
||||||
|
size: "Taille",
|
||||||
|
mtime: "Modifié",
|
||||||
|
kind: "Type",
|
||||||
|
rights: "Droits",
|
||||||
|
owner: "Propriétaire",
|
||||||
|
group: "Groupe",
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
choosePanel: "Choisir ce qu'affiche ce panneau",
|
||||||
|
copy: "Envoyer",
|
||||||
|
copyToRight: "Envoyer vers le panneau droit",
|
||||||
|
copyToLeft: "Envoyer vers le panneau gauche",
|
||||||
|
edit: "Éditer",
|
||||||
|
editFile: "Éditer le fichier",
|
||||||
|
rename: "Renommer",
|
||||||
|
newFolder: "Nouveau dossier",
|
||||||
|
newFile: "Nouveau fichier",
|
||||||
|
permissions: "Autorisations",
|
||||||
|
delete: "Supprimer",
|
||||||
|
parentDir: "Dossier parent",
|
||||||
|
refresh: "Actualiser",
|
||||||
|
clearFilter: "Effacer le filtre (Échap)",
|
||||||
|
sshConfig: "~/.ssh/config",
|
||||||
|
connectingTo: "Connexion à {name}…",
|
||||||
|
emptyDir: "Dossier vide",
|
||||||
|
noMatches: "Aucun résultat",
|
||||||
|
matches: { one: "{count} résultat · Échap", other: "{count} résultats · Échap" },
|
||||||
|
},
|
||||||
|
connMgr: {
|
||||||
|
title: "Connexions",
|
||||||
|
importWinscp: "Importer depuis WinSCP…",
|
||||||
|
importWinscpHint: "Importer des sessions depuis un export WinSCP.ini",
|
||||||
|
newConnection: "Connexion",
|
||||||
|
newConnectionHint: "Nouvelle connexion",
|
||||||
|
newStack: "Groupe",
|
||||||
|
newStackHint: "Nouveau groupe",
|
||||||
|
expand: "Développer",
|
||||||
|
collapse: "Réduire",
|
||||||
|
renameStack: "Renommer le groupe",
|
||||||
|
deleteStack: "Supprimer le groupe",
|
||||||
|
openStack: "Ouvrir le groupe dans des onglets",
|
||||||
|
empty: "vide",
|
||||||
|
connectionsHeader: "Connexions",
|
||||||
|
noneTitle: "Aucune connexion enregistrée.",
|
||||||
|
noneBody: "Créez-en une ou importez vos sessions WinSCP.",
|
||||||
|
name: "Nom",
|
||||||
|
namePlaceholder: "mon serveur",
|
||||||
|
host: "Hôte",
|
||||||
|
port: "Port",
|
||||||
|
stack: "Groupe",
|
||||||
|
stackNone: "(aucun)",
|
||||||
|
username: "Utilisateur",
|
||||||
|
remotePath: "Chemin distant (facultatif)",
|
||||||
|
remotePathPlaceholder: "dossier personnel",
|
||||||
|
auth: "Authentification",
|
||||||
|
authPassword: "Mot de passe",
|
||||||
|
authKey: "Clé privée",
|
||||||
|
password: "Mot de passe",
|
||||||
|
privateKeyFile: "Fichier de clé privée",
|
||||||
|
passphrase: "Phrase secrète (facultative)",
|
||||||
|
localDir: "Dossier local (facultatif)",
|
||||||
|
localDirPlaceholder: "~/projets/mon-serveur",
|
||||||
|
localDirHint: "L'autre panneau s'ouvre ici lorsqu'il est sur Local et n'a pas été parcouru.",
|
||||||
|
more: "Plus…",
|
||||||
|
less: "Moins…",
|
||||||
|
selectPrompt: "Sélectionnez une connexion ou créez-en une nouvelle.",
|
||||||
|
newStackTitle: "Nouveau groupe",
|
||||||
|
newStackPrompt: "nom du groupe",
|
||||||
|
renameStackTitle: "Renommer le groupe",
|
||||||
|
deleteStackTitle: "Supprimer le groupe",
|
||||||
|
deleteStackBody: 'Supprimer le groupe « {name} » ? Ses connexions sont conservées, sans groupe.',
|
||||||
|
deleteConnectionTitle: "Supprimer la connexion",
|
||||||
|
deleteConnectionBody: 'Supprimer « {name} » ?',
|
||||||
|
},
|
||||||
|
editor: {
|
||||||
|
noFileTitle: "Aucun fichier à éditer",
|
||||||
|
noFileBody: "Cette fenêtre a été ouverte sans contexte d'éditeur. Ouvrez des fichiers depuis l'application.",
|
||||||
|
wrap: "Retour",
|
||||||
|
wrapHint: "Retour à la ligne — masque les numéros de ligne",
|
||||||
|
findHint: "Rechercher",
|
||||||
|
findPlaceholder: "Rechercher dans le fichier",
|
||||||
|
prevMatch: "Résultat précédent",
|
||||||
|
nextMatch: "Résultat suivant",
|
||||||
|
closeHint: "Fermer (Échap)",
|
||||||
|
disconnected: "Déconnecté de {name} — l'enregistrement échouera, mais votre texte est conservé dans cette fenêtre.",
|
||||||
|
cantOpen: "Impossible d'ouvrir {name}",
|
||||||
|
closeWindow: "Fermer la fenêtre",
|
||||||
|
verbs: { save: "enregistrer", find: "rechercher", close: "fermer" },
|
||||||
|
lines: { one: "{count} ligne", other: "{count} lignes" },
|
||||||
|
close: "Fermer",
|
||||||
|
save: "Enregistrer",
|
||||||
|
saveChangesTitle: 'Enregistrer les modifications de « {name} » ?',
|
||||||
|
saveChangesBody: "Vos modifications seront perdues si vous ne les enregistrez pas.",
|
||||||
|
dontSave: "Ne pas enregistrer",
|
||||||
|
countLabel: "{current} sur {total}",
|
||||||
|
noMatches: "Aucun résultat",
|
||||||
|
},
|
||||||
|
transfer: {
|
||||||
|
summary: "{active} en cours · {finished} terminés",
|
||||||
|
clearFinished: "Effacer les terminés",
|
||||||
|
cancel: "Annuler",
|
||||||
|
dismiss: "Ignorer",
|
||||||
|
showDetails: "Afficher les détails des transferts",
|
||||||
|
copying: "Copie de {name} · {percent}%{speed}",
|
||||||
|
queued: "{count} en file",
|
||||||
|
failed: { one: "{count} transfert échoué", other: "{count} transferts échoués" },
|
||||||
|
finished: "Transferts terminés",
|
||||||
|
ready: "Prêt",
|
||||||
|
items: "{count} éléments",
|
||||||
|
selected: "{count} sélectionnés",
|
||||||
|
queuedState: "En file",
|
||||||
|
scanning: "Analyse…",
|
||||||
|
failedState: "Échec",
|
||||||
|
cancelled: "Annulé",
|
||||||
|
completedFiles: { one: "{count} fichier · {size}", other: "{count} fichiers · {size}" },
|
||||||
|
left: "{eta} restant",
|
||||||
|
},
|
||||||
|
dialogs: {
|
||||||
|
chmodTitle: "Autorisations",
|
||||||
|
chmodItems: "{count} éléments",
|
||||||
|
read: "Lecture",
|
||||||
|
write: "Écriture",
|
||||||
|
execute: "Exécution",
|
||||||
|
roleOwner: "Propriétaire",
|
||||||
|
roleGroup: "Groupe",
|
||||||
|
roleOthers: "Autres",
|
||||||
|
octal: "Octal",
|
||||||
|
mergeFolder: "Fusionner le dossier",
|
||||||
|
replace: "Remplacer",
|
||||||
|
merge: "Fusionner",
|
||||||
|
mergeAll: "Tout fusionner",
|
||||||
|
replaceAll: "Tout remplacer",
|
||||||
|
skip: "Ignorer",
|
||||||
|
skipAll: "Tout ignorer",
|
||||||
|
ofTotal: "{index} sur {total}",
|
||||||
|
overwriteDirBody: "La destination contient déjà un dossier de ce nom. La fusion peut remplacer des fichiers.",
|
||||||
|
overwriteFileBody: "La destination contient déjà un fichier de ce nom.",
|
||||||
|
source: "Source",
|
||||||
|
destination: "Destination",
|
||||||
|
newer: "plus récent",
|
||||||
|
},
|
||||||
|
toasts: {
|
||||||
|
movedToTrash: '« {name} » mis à la corbeille',
|
||||||
|
movedManyToTrash: { one: "{count} élément mis à la corbeille", other: "{count} éléments mis à la corbeille" },
|
||||||
|
editingExternal: "Édition de {name} — les enregistrements sont envoyés automatiquement",
|
||||||
|
nothingToCopy: "Rien à copier (les liens symboliques sont ignorés)",
|
||||||
|
nothingToCopyAllSkipped: "Rien à copier — tous les éléments en conflit ont été ignorés",
|
||||||
|
sameFolder: "Les deux panneaux affichent le même dossier",
|
||||||
|
sameFile: "« {name} » ignoré — c'est le même fichier",
|
||||||
|
typeMismatch: "« {name} » ignoré — un autre type d'élément est déjà là",
|
||||||
|
transferFailed: 'Le transfert « {name} » a échoué : {error}',
|
||||||
|
unknownError: "erreur inconnue",
|
||||||
|
saved: "{name} enregistré",
|
||||||
|
disconnectedReason: "{title} : déconnecté ({reason})",
|
||||||
|
imported: { one: "{count} connexion importée", other: "{count} connexions importées" },
|
||||||
|
importedIntoStacks: { one: " dans {count} nouveau groupe", other: " dans {count} nouveaux groupes" },
|
||||||
|
importedSkipped: " ({skipped} ignorées)",
|
||||||
|
leftoverTitle: "Fichiers édités restants",
|
||||||
|
leftoverMore: ", +{count} de plus",
|
||||||
|
leftoverBody: {
|
||||||
|
one: "Une session précédente a laissé {count} fichier édité temporaire ({names}). Les supprimer ?",
|
||||||
|
other: "Une session précédente a laissé {count} fichiers édités temporaires ({names}). Les supprimer ?",
|
||||||
|
},
|
||||||
|
leftoverClear: "Supprimer",
|
||||||
|
},
|
||||||
|
fileActions: {
|
||||||
|
deleteTitle: "Supprimer",
|
||||||
|
deleteBody: "Supprimer {what} ? Les dossiers sont supprimés récursivement. Action irréversible.",
|
||||||
|
deleteConfirm: "Supprimer",
|
||||||
|
renameTitle: 'Renommer « {name} »',
|
||||||
|
renameConfirm: "Renommer",
|
||||||
|
newFolderTitle: "Nouveau dossier",
|
||||||
|
newFolderPlaceholder: "nom du dossier",
|
||||||
|
newFileTitle: "Nouveau fichier",
|
||||||
|
newFilePlaceholder: "nom du fichier",
|
||||||
|
createConfirm: "Créer",
|
||||||
|
itemsCount: "{count} éléments",
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
about: "À propos de {app}",
|
||||||
|
settings: "Réglages…",
|
||||||
|
checkForUpdates: "Rechercher des mises à jour…",
|
||||||
|
quit: "Quitter",
|
||||||
|
file: "Fichier",
|
||||||
|
newTab: "Nouvel onglet",
|
||||||
|
closeTab: "Fermer l'onglet",
|
||||||
|
newFolder: "Nouveau dossier",
|
||||||
|
newFile: "Nouveau fichier",
|
||||||
|
editFile: "Éditer le fichier",
|
||||||
|
connect: "Connecter",
|
||||||
|
connectionManager: "Gestionnaire de connexions…",
|
||||||
|
edit: "Édition",
|
||||||
|
view: "Affichage",
|
||||||
|
refresh: "Actualiser",
|
||||||
|
window: "Fenêtre",
|
||||||
|
},
|
||||||
|
ctx: {
|
||||||
|
size: "Taille",
|
||||||
|
modified: "Modifié",
|
||||||
|
kind: "Type",
|
||||||
|
rights: "Droits",
|
||||||
|
owner: "Propriétaire",
|
||||||
|
group: "Groupe",
|
||||||
|
revealFinder: "Afficher dans le Finder",
|
||||||
|
revealExplorer: "Afficher dans l'Explorateur",
|
||||||
|
revealFileManager: "Afficher dans le gestionnaire de fichiers",
|
||||||
|
moveToTrash: "Mettre à la corbeille",
|
||||||
|
moveToRecycleBin: "Mettre à la corbeille",
|
||||||
|
newFolder: "Nouveau dossier",
|
||||||
|
newFile: "Nouveau fichier",
|
||||||
|
refresh: "Actualiser",
|
||||||
|
open: "Ouvrir",
|
||||||
|
openDefault: "Ouvrir avec l'application par défaut",
|
||||||
|
edit: "Éditer",
|
||||||
|
copyToOther: "Envoyer vers l'autre panneau",
|
||||||
|
rename: "Renommer",
|
||||||
|
permissions: "Autorisations",
|
||||||
|
copyPath: "Copier le chemin",
|
||||||
|
delete: "Supprimer",
|
||||||
|
columns: "Colonnes",
|
||||||
|
tabNew: "Nouvel onglet",
|
||||||
|
tabClose: "Fermer l'onglet",
|
||||||
|
tabCloseOthers: "Fermer les autres onglets",
|
||||||
|
tabCloseRight: "Fermer les onglets à droite",
|
||||||
|
tabDisconnect: "Déconnecter",
|
||||||
|
trashTooltip: "La restauration du Finder n'est pas disponible pour les éléments mis à la corbeille d'ici",
|
||||||
|
},
|
||||||
|
keys: {
|
||||||
|
"cursor-up": "Monter",
|
||||||
|
"cursor-down": "Descendre",
|
||||||
|
"page-up": "Page précédente",
|
||||||
|
"page-down": "Page suivante",
|
||||||
|
"cursor-first": "Premier élément",
|
||||||
|
"cursor-last": "Dernier élément",
|
||||||
|
"toggle-select": "Sélectionner / désélectionner",
|
||||||
|
"select-all": "Tout sélectionner",
|
||||||
|
open: "Ouvrir",
|
||||||
|
parent: "Dossier parent",
|
||||||
|
"switch-panel": "Changer de panneau",
|
||||||
|
"row-menu": "Afficher le menu de l'élément",
|
||||||
|
send: "Envoyer vers l'autre panneau",
|
||||||
|
edit: "Éditer",
|
||||||
|
rename: "Renommer",
|
||||||
|
"new-folder": "Nouveau dossier",
|
||||||
|
"new-file": "Nouveau fichier",
|
||||||
|
chmod: "Permissions",
|
||||||
|
delete: "Supprimer",
|
||||||
|
trash: "Mettre à la corbeille",
|
||||||
|
"copy-path": "Copier le chemin",
|
||||||
|
reveal: "Afficher dans le gestionnaire de fichiers",
|
||||||
|
"open-default": "Ouvrir avec l'application par défaut",
|
||||||
|
refresh: "Actualiser",
|
||||||
|
"focus-path": "Aller au chemin",
|
||||||
|
"source-picker": "Choisir ce qu'affiche le panneau",
|
||||||
|
"sort-name": "Trier par nom",
|
||||||
|
"sort-size": "Trier par taille",
|
||||||
|
"sort-mtime": "Trier par date de modification",
|
||||||
|
"sort-kind": "Trier par type",
|
||||||
|
"sort-rights": "Trier par droits",
|
||||||
|
"sort-owner": "Trier par propriétaire",
|
||||||
|
"sort-group": "Trier par groupe",
|
||||||
|
"new-tab": "Nouvel onglet",
|
||||||
|
"close-tab": "Fermer l'onglet",
|
||||||
|
"close-other-tabs": "Fermer les autres onglets",
|
||||||
|
"close-tabs-right": "Fermer les onglets à droite",
|
||||||
|
"next-tab": "Onglet suivant",
|
||||||
|
"prev-tab": "Onglet précédent",
|
||||||
|
"go-to-tab": "Aller à l'onglet {n}",
|
||||||
|
"go-to-last-tab": "Aller au dernier onglet",
|
||||||
|
"move-tab-left": "Déplacer l'onglet à gauche",
|
||||||
|
"move-tab-right": "Déplacer l'onglet à droite",
|
||||||
|
"disconnect-tab": "Déconnecter l'onglet",
|
||||||
|
"open-settings": "Réglages",
|
||||||
|
"connection-manager": "Gestionnaire de connexions",
|
||||||
|
"toggle-transfers": "Afficher / masquer les transferts",
|
||||||
|
"install-update": "Installer la mise à jour",
|
||||||
|
"scale-up": "Interface plus grande",
|
||||||
|
"scale-down": "Interface plus petite",
|
||||||
|
"scale-reset": "Interface à 100 %",
|
||||||
|
"close-window": "Fermer la fenêtre",
|
||||||
|
save: "Enregistrer",
|
||||||
|
find: "Rechercher",
|
||||||
|
"find-next": "Résultat suivant",
|
||||||
|
"find-prev": "Résultat précédent",
|
||||||
|
"toggle-wrap": "Retour à la ligne",
|
||||||
|
"conn-new": "Nouvelle connexion",
|
||||||
|
"conn-new-stack": "Nouveau groupe",
|
||||||
|
"conn-save": "Enregistrer la connexion",
|
||||||
|
"conn-connect": "Se connecter / ouvrir le groupe",
|
||||||
|
"conn-delete": "Supprimer la connexion ou le groupe",
|
||||||
|
"conn-rename-stack": "Renommer le groupe",
|
||||||
|
"conn-import": "Importer depuis WinSCP",
|
||||||
|
groups: {
|
||||||
|
panels: "Liste des fichiers",
|
||||||
|
files: "Fichiers",
|
||||||
|
sort: "Tri",
|
||||||
|
tabs: "Onglets",
|
||||||
|
window: "Fenêtre",
|
||||||
|
editor: "Éditeur",
|
||||||
|
connections: "Gestionnaire de connexions",
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
filter: "Rechercher un raccourci",
|
||||||
|
intro: "Cliquez sur + et tapez les touches pour ajouter un raccourci. Le premier est celui qu'affichent les menus et les infobulles.",
|
||||||
|
add: "Ajouter un raccourci",
|
||||||
|
remove: "Retirer {key}",
|
||||||
|
reset: "Rétablir la valeur par défaut",
|
||||||
|
resetAll: "Tout rétablir",
|
||||||
|
resetAllConfirm: "Rétablir tous les raccourcis",
|
||||||
|
press: "Tapez les touches… ({esc} annule)",
|
||||||
|
unbound: "Aucun raccourci",
|
||||||
|
reserved: "{key} est réservé par le système",
|
||||||
|
needsModifier: "Dans l'éditeur, {key} ne ferait que taper : ajoutez un modificateur ou utilisez une touche F",
|
||||||
|
usedBy: "{key} est déjà utilisé par « {action} »",
|
||||||
|
replace: "Remplacer",
|
||||||
|
conflict: "Même touche que {action}",
|
||||||
|
plainLetter: "Taper {key} dans la liste des fichiers ne la filtre plus",
|
||||||
|
noResults: "Aucun raccourci ne correspond",
|
||||||
|
fixed: "Touches fixes",
|
||||||
|
},
|
||||||
|
fixed: {
|
||||||
|
escape: "{esc} ferme les menus, les dialogues et la barre de recherche",
|
||||||
|
escList: "{esc} dans la liste des fichiers efface le filtre, puis annule une connexion, puis vide la sélection",
|
||||||
|
dialogs: "{enter} et {esc} valident ou annulent un dialogue ; {tab} et {shiftTab} passent d'un contrôle à l'autre",
|
||||||
|
menus: "↑ ↓ {home} {end} et {enter} fonctionnent dans tous les menus",
|
||||||
|
filter: "Taper dans la liste des fichiers la filtre ; {backspace} efface le dernier caractère du filtre",
|
||||||
|
shift: "Maj avec une touche de déplacement sélectionne les lignes parcourues",
|
||||||
|
find: "{enter} et {shiftEnter} parcourent les résultats de la recherche",
|
||||||
|
manager: "↑ ↓ ← → et {enter} parcourent la liste des connexions",
|
||||||
|
sidebar: "↑ ↓ changent de section ici",
|
||||||
|
},
|
||||||
|
names: {
|
||||||
|
ctrl: "Ctrl",
|
||||||
|
alt: "Alt",
|
||||||
|
shift: "Maj",
|
||||||
|
enter: "Entrée",
|
||||||
|
backspace: "Retour arrière",
|
||||||
|
del: "Suppr",
|
||||||
|
esc: "Échap",
|
||||||
|
space: "Espace",
|
||||||
|
tab: "Tab",
|
||||||
|
pgup: "Pg préc",
|
||||||
|
pgdn: "Pg suiv",
|
||||||
|
home: "Début",
|
||||||
|
end: "Fin",
|
||||||
|
ins: "Inser",
|
||||||
|
menu: "Menu",
|
||||||
|
num: "Num",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
sshAgentUnavailable: "ssh-agent indisponible (SSH_AUTH_SOCK non défini)",
|
||||||
|
identityFileNotFound: "fichier d'identité introuvable : {path}",
|
||||||
|
sessionNotConnected: "La session « {id} » n'est pas connectée",
|
||||||
|
localCannotDisconnect: "La session locale ne peut pas être déconnectée",
|
||||||
|
notADirectory: "« {path} » existe et n'est pas un dossier",
|
||||||
|
sameFileTransfer: '« {name} » : la source et la destination sont le même fichier',
|
||||||
|
sameFileNoName: "La source et la destination sont le même fichier",
|
||||||
|
folderOntoItself: "Impossible de copier un dossier sur lui-même",
|
||||||
|
folderIntoItself: "Impossible de copier un dossier dans lui-même",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { SupportedLocale } from "../types";
|
||||||
|
import { de } from "./de";
|
||||||
|
import { en } from "./en";
|
||||||
|
import { es } from "./es";
|
||||||
|
import { eu } from "./eu";
|
||||||
|
import { fr } from "./fr";
|
||||||
|
import { ja } from "./ja";
|
||||||
|
import { zh } from "./zh";
|
||||||
|
|
||||||
|
/** A translation tree: leaves are strings; a leaf may be a plural map. */
|
||||||
|
export type Messages = { readonly [key: string]: string | Messages };
|
||||||
|
|
||||||
|
export const DICTS: Record<SupportedLocale, Messages> = { en, es, eu, fr, de, zh, ja };
|
||||||
|
|
||||||
|
/** Order shown in the language picker. */
|
||||||
|
export const SUPPORTED: readonly SupportedLocale[] = ["en", "es", "eu", "fr", "de", "zh", "ja"];
|
||||||
|
|
||||||
|
/** Each language named in itself (endonyms) for the picker. */
|
||||||
|
export const LOCALE_LABELS: Record<SupportedLocale, string> = {
|
||||||
|
en: "English",
|
||||||
|
es: "Español",
|
||||||
|
eu: "Euskara",
|
||||||
|
fr: "Français",
|
||||||
|
de: "Deutsch",
|
||||||
|
zh: "中文",
|
||||||
|
ja: "日本語",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the effective locale: an explicit choice wins; "system" maps the OS
|
||||||
|
* BCP-47 tag (e.g. "es-ES", "zh-Hans-CN") to a supported language, else English.
|
||||||
|
*/
|
||||||
|
export function resolveLocale(
|
||||||
|
setting: "system" | SupportedLocale,
|
||||||
|
osLocale: string | undefined,
|
||||||
|
): SupportedLocale {
|
||||||
|
if (setting !== "system") return setting;
|
||||||
|
const tag = (osLocale ?? "en").toLowerCase();
|
||||||
|
const primary = tag.split("-")[0];
|
||||||
|
return (SUPPORTED as readonly string[]).includes(primary)
|
||||||
|
? (primary as SupportedLocale)
|
||||||
|
: "en";
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
// Japanese translations. Fall back to English for missing keys.
|
||||||
|
// TODO(i18n): AI draft — needs native-speaker review.
|
||||||
|
export const ja = {
|
||||||
|
common: {
|
||||||
|
cancel: "キャンセル",
|
||||||
|
save: "保存",
|
||||||
|
close: "閉じる",
|
||||||
|
delete: "削除",
|
||||||
|
rename: "名前を変更",
|
||||||
|
create: "作成",
|
||||||
|
apply: "適用",
|
||||||
|
choose: "選択…",
|
||||||
|
connect: "接続",
|
||||||
|
local: "ローカル",
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
notConnectedTitle: "アプリに接続されていません",
|
||||||
|
notConnectedBody: "このページは通常のブラウザで動作しています。デスクトップアプリを起動して使用してください。",
|
||||||
|
managerOpen: "接続マネージャーが開いています。",
|
||||||
|
showConnections: "接続を表示",
|
||||||
|
},
|
||||||
|
tabBar: {
|
||||||
|
connections: "接続",
|
||||||
|
manageConnections: "接続を管理",
|
||||||
|
newTab: "新しいタブ",
|
||||||
|
closeTab: "タブを閉じる",
|
||||||
|
options: "設定",
|
||||||
|
update: "アップデートあり",
|
||||||
|
updating: "更新中",
|
||||||
|
restartToUpdate: "再起動して適用",
|
||||||
|
updateTooltip: "アップデートがあります: miniSCP {version}",
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
title: "設定",
|
||||||
|
sections: {
|
||||||
|
appearance: "外観",
|
||||||
|
transfers: "転送",
|
||||||
|
editor: "エディタ",
|
||||||
|
fileList: "ファイル一覧",
|
||||||
|
keyboard: "キーボード",
|
||||||
|
about: "情報",
|
||||||
|
},
|
||||||
|
columns: "表示する列",
|
||||||
|
language: "言語",
|
||||||
|
system: "システム",
|
||||||
|
theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" },
|
||||||
|
tone: { label: "トーン", dark: "ダーク", darker: "AMOLED" },
|
||||||
|
interface: { label: "インターフェース", smaller: "縮小", larger: "拡大", reset: "100%に戻す" },
|
||||||
|
speedUnit: { label: "速度の単位" },
|
||||||
|
transfers: { label: "転送", single: "1件ずつ", multi: "複数" },
|
||||||
|
atOnce: "同時数",
|
||||||
|
editor: { label: "エディタ", builtin: "内蔵", custom: "カスタム" },
|
||||||
|
systemDefault: "システムの既定",
|
||||||
|
resetDefault: "既定に戻す",
|
||||||
|
finishedTransfers: { label: "完了した転送", autoClear: "自動クリア", keep: "保持" },
|
||||||
|
openFiles: { label: "Enter / ファイルをダブルクリック", nothing: "何もしない", open: "開く", hint: "ローカルファイルは既定のアプリで開きます。フォルダは常に開きます。" },
|
||||||
|
},
|
||||||
|
kinds: {
|
||||||
|
folder: "フォルダ",
|
||||||
|
symlink: "シンボリックリンク",
|
||||||
|
file: "ファイル",
|
||||||
|
image: "画像",
|
||||||
|
audio: "オーディオ",
|
||||||
|
video: "動画",
|
||||||
|
archive: "アーカイブ",
|
||||||
|
code: "コード",
|
||||||
|
document: "ドキュメント",
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
name: "名前",
|
||||||
|
size: "サイズ",
|
||||||
|
mtime: "更新日時",
|
||||||
|
kind: "種類",
|
||||||
|
rights: "権限",
|
||||||
|
owner: "所有者",
|
||||||
|
group: "グループ",
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
choosePanel: "このパネルに表示する内容を選択",
|
||||||
|
copy: "送信",
|
||||||
|
copyToRight: "右のパネルへ送信",
|
||||||
|
copyToLeft: "左のパネルへ送信",
|
||||||
|
edit: "編集",
|
||||||
|
editFile: "ファイルを編集",
|
||||||
|
rename: "名前を変更",
|
||||||
|
newFolder: "新規フォルダ",
|
||||||
|
newFile: "新規ファイル",
|
||||||
|
permissions: "アクセス権",
|
||||||
|
delete: "削除",
|
||||||
|
parentDir: "親フォルダ",
|
||||||
|
refresh: "更新",
|
||||||
|
clearFilter: "フィルターをクリア (Esc)",
|
||||||
|
sshConfig: "~/.ssh/config",
|
||||||
|
connectingTo: "{name} に接続中…",
|
||||||
|
emptyDir: "空のフォルダ",
|
||||||
|
noMatches: "一致なし",
|
||||||
|
matches: { other: "{count} 件一致 · Esc" },
|
||||||
|
},
|
||||||
|
connMgr: {
|
||||||
|
title: "接続",
|
||||||
|
importWinscp: "WinSCP からインポート…",
|
||||||
|
importWinscpHint: "WinSCP.ini エクスポートからセッションをインポート",
|
||||||
|
newConnection: "接続",
|
||||||
|
newConnectionHint: "新規接続",
|
||||||
|
newStack: "グループ",
|
||||||
|
newStackHint: "新規グループ",
|
||||||
|
expand: "展開",
|
||||||
|
collapse: "折りたたむ",
|
||||||
|
renameStack: "グループ名を変更",
|
||||||
|
deleteStack: "グループを削除",
|
||||||
|
openStack: "グループをタブで開く",
|
||||||
|
empty: "空",
|
||||||
|
connectionsHeader: "接続",
|
||||||
|
noneTitle: "保存された接続はまだありません。",
|
||||||
|
noneBody: "作成するか、WinSCP セッションをインポートしてください。",
|
||||||
|
name: "名前",
|
||||||
|
namePlaceholder: "マイサーバー",
|
||||||
|
host: "ホスト",
|
||||||
|
port: "ポート",
|
||||||
|
stack: "グループ",
|
||||||
|
stackNone: "(なし)",
|
||||||
|
username: "ユーザー名",
|
||||||
|
remotePath: "リモートパス(任意)",
|
||||||
|
remotePathPlaceholder: "ホームディレクトリ",
|
||||||
|
auth: "認証",
|
||||||
|
authPassword: "パスワード",
|
||||||
|
authKey: "秘密鍵",
|
||||||
|
password: "パスワード",
|
||||||
|
privateKeyFile: "秘密鍵ファイル",
|
||||||
|
passphrase: "パスフレーズ(任意)",
|
||||||
|
localDir: "ローカルディレクトリ(任意)",
|
||||||
|
localDirPlaceholder: "~/projects/my-server",
|
||||||
|
localDirHint: "もう一方のパネルがローカルで未操作のとき、ここが開きます。",
|
||||||
|
more: "詳細…",
|
||||||
|
less: "閉じる…",
|
||||||
|
selectPrompt: "接続を選択するか、新しく作成してください。",
|
||||||
|
newStackTitle: "新規グループ",
|
||||||
|
newStackPrompt: "グループ名",
|
||||||
|
renameStackTitle: "グループ名を変更",
|
||||||
|
deleteStackTitle: "グループを削除",
|
||||||
|
deleteStackBody: 'グループ "{name}" を削除しますか?含まれる接続は残り、グループ解除されます。',
|
||||||
|
deleteConnectionTitle: "接続を削除",
|
||||||
|
deleteConnectionBody: '"{name}" を削除しますか?',
|
||||||
|
},
|
||||||
|
editor: {
|
||||||
|
noFileTitle: "編集するファイルがありません",
|
||||||
|
noFileBody: "このウィンドウはエディタコンテキストなしで開かれました。アプリからファイルを開いてください。",
|
||||||
|
wrap: "折り返し",
|
||||||
|
wrapHint: "長い行を折り返す — 行番号を非表示",
|
||||||
|
findHint: "検索",
|
||||||
|
findPlaceholder: "ファイル内を検索",
|
||||||
|
prevMatch: "前の一致",
|
||||||
|
nextMatch: "次の一致",
|
||||||
|
closeHint: "閉じる (Esc)",
|
||||||
|
disconnected: "{name} から切断されました — 保存は失敗しますが、テキストはこのウィンドウに保持されます。",
|
||||||
|
cantOpen: "{name} を開けません",
|
||||||
|
closeWindow: "ウィンドウを閉じる",
|
||||||
|
verbs: { save: "保存", find: "検索", close: "閉じる" },
|
||||||
|
lines: { other: "{count} 行" },
|
||||||
|
close: "閉じる",
|
||||||
|
save: "保存",
|
||||||
|
saveChangesTitle: '"{name}" への変更を保存しますか?',
|
||||||
|
saveChangesBody: "保存しないと変更は失われます。",
|
||||||
|
dontSave: "保存しない",
|
||||||
|
countLabel: "{total} 件中 {current} 件目",
|
||||||
|
noMatches: "一致なし",
|
||||||
|
},
|
||||||
|
transfer: {
|
||||||
|
summary: "{active} 件実行中 · {finished} 件完了",
|
||||||
|
clearFinished: "完了分をクリア",
|
||||||
|
cancel: "キャンセル",
|
||||||
|
dismiss: "閉じる",
|
||||||
|
showDetails: "転送の詳細を表示",
|
||||||
|
copying: "{name} をコピー中 · {percent}%{speed}",
|
||||||
|
queued: "{count} 件待機中",
|
||||||
|
failed: { other: "{count} 件の転送が失敗" },
|
||||||
|
finished: "転送が完了しました",
|
||||||
|
ready: "準備完了",
|
||||||
|
items: "{count} 項目",
|
||||||
|
selected: "{count} 件選択中",
|
||||||
|
queuedState: "待機中",
|
||||||
|
scanning: "スキャン中…",
|
||||||
|
failedState: "失敗",
|
||||||
|
cancelled: "キャンセル済み",
|
||||||
|
completedFiles: { other: "{count} 個のファイル · {size}" },
|
||||||
|
left: "残り {eta}",
|
||||||
|
},
|
||||||
|
dialogs: {
|
||||||
|
chmodTitle: "アクセス権",
|
||||||
|
chmodItems: "{count} 項目",
|
||||||
|
read: "読み取り",
|
||||||
|
write: "書き込み",
|
||||||
|
execute: "実行",
|
||||||
|
roleOwner: "所有者",
|
||||||
|
roleGroup: "グループ",
|
||||||
|
roleOthers: "その他",
|
||||||
|
octal: "8進数",
|
||||||
|
mergeFolder: "フォルダを結合",
|
||||||
|
replace: "置き換える",
|
||||||
|
merge: "結合",
|
||||||
|
mergeAll: "すべて結合",
|
||||||
|
replaceAll: "すべて置き換える",
|
||||||
|
skip: "スキップ",
|
||||||
|
skipAll: "すべてスキップ",
|
||||||
|
ofTotal: "{total} 件中 {index} 件目",
|
||||||
|
overwriteDirBody: "コピー先に同名のフォルダが既にあります。結合すると中のファイルが置き換わる可能性があります。",
|
||||||
|
overwriteFileBody: "コピー先に同名のファイルが既にあります。",
|
||||||
|
source: "コピー元",
|
||||||
|
destination: "コピー先",
|
||||||
|
newer: "新しい",
|
||||||
|
},
|
||||||
|
toasts: {
|
||||||
|
movedToTrash: '"{name}" をゴミ箱に移動しました',
|
||||||
|
movedManyToTrash: { other: "{count} 個の項目をゴミ箱に移動しました" },
|
||||||
|
editingExternal: "{name} を編集中 — 保存すると自動でアップロードされます",
|
||||||
|
nothingToCopy: "コピーする項目がありません(シンボリックリンクはスキップされます)",
|
||||||
|
nothingToCopyAllSkipped: "コピーする項目がありません — 競合する項目はすべてスキップされました",
|
||||||
|
sameFolder: "両方のパネルが同じフォルダを表示しています",
|
||||||
|
sameFile: '"{name}" をスキップしました — 同じファイルです',
|
||||||
|
typeMismatch: '"{name}" をスキップしました — 別の種類の項目があります',
|
||||||
|
transferFailed: '転送 "{name}" が失敗しました: {error}',
|
||||||
|
unknownError: "不明なエラー",
|
||||||
|
saved: "{name} を保存しました",
|
||||||
|
disconnectedReason: "{title}: 切断されました ({reason})",
|
||||||
|
imported: { other: "{count} 件の接続をインポートしました" },
|
||||||
|
importedIntoStacks: { other: "({count} 件の新規グループに)" },
|
||||||
|
importedSkipped: "({skipped} 件スキップ)",
|
||||||
|
leftoverTitle: "残った編集済みファイル",
|
||||||
|
leftoverMore: "、他 {count} 件",
|
||||||
|
leftoverBody: {
|
||||||
|
other: "前回のセッションで {count} 件の一時編集ファイルが残っています({names})。クリアしますか?",
|
||||||
|
},
|
||||||
|
leftoverClear: "クリア",
|
||||||
|
},
|
||||||
|
fileActions: {
|
||||||
|
deleteTitle: "削除",
|
||||||
|
deleteBody: "{what} を削除しますか?フォルダは再帰的に削除されます。この操作は取り消せません。",
|
||||||
|
deleteConfirm: "削除",
|
||||||
|
renameTitle: '"{name}" の名前を変更',
|
||||||
|
renameConfirm: "名前を変更",
|
||||||
|
newFolderTitle: "新規フォルダ",
|
||||||
|
newFolderPlaceholder: "フォルダ名",
|
||||||
|
newFileTitle: "新規ファイル",
|
||||||
|
newFilePlaceholder: "ファイル名",
|
||||||
|
createConfirm: "作成",
|
||||||
|
itemsCount: "{count} 項目",
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
about: "{app} について",
|
||||||
|
settings: "設定…",
|
||||||
|
checkForUpdates: "アップデートを確認…",
|
||||||
|
quit: "終了",
|
||||||
|
file: "ファイル",
|
||||||
|
newTab: "新しいタブ",
|
||||||
|
closeTab: "タブを閉じる",
|
||||||
|
newFolder: "新規フォルダ",
|
||||||
|
newFile: "新規ファイル",
|
||||||
|
editFile: "ファイルを編集",
|
||||||
|
connect: "接続",
|
||||||
|
connectionManager: "接続マネージャー…",
|
||||||
|
edit: "編集",
|
||||||
|
view: "表示",
|
||||||
|
refresh: "更新",
|
||||||
|
window: "ウィンドウ",
|
||||||
|
},
|
||||||
|
ctx: {
|
||||||
|
size: "サイズ",
|
||||||
|
modified: "更新日時",
|
||||||
|
kind: "種類",
|
||||||
|
rights: "権限",
|
||||||
|
owner: "所有者",
|
||||||
|
group: "グループ",
|
||||||
|
revealFinder: "Finder で表示",
|
||||||
|
revealExplorer: "エクスプローラーで表示",
|
||||||
|
revealFileManager: "ファイルマネージャーで表示",
|
||||||
|
moveToTrash: "ゴミ箱に移動",
|
||||||
|
moveToRecycleBin: "ごみ箱に移動",
|
||||||
|
newFolder: "新規フォルダ",
|
||||||
|
newFile: "新規ファイル",
|
||||||
|
refresh: "更新",
|
||||||
|
open: "開く",
|
||||||
|
openDefault: "既定のアプリで開く",
|
||||||
|
edit: "編集",
|
||||||
|
copyToOther: "もう一方のパネルへ送信",
|
||||||
|
rename: "名前を変更",
|
||||||
|
permissions: "アクセス権",
|
||||||
|
copyPath: "パスをコピー",
|
||||||
|
delete: "削除",
|
||||||
|
columns: "列",
|
||||||
|
tabNew: "新しいタブ",
|
||||||
|
tabClose: "タブを閉じる",
|
||||||
|
tabCloseOthers: "他のタブを閉じる",
|
||||||
|
tabCloseRight: "右側のタブを閉じる",
|
||||||
|
tabDisconnect: "切断",
|
||||||
|
trashTooltip: "ここからゴミ箱に入れた項目は Finder の「戻す」を利用できません",
|
||||||
|
},
|
||||||
|
keys: {
|
||||||
|
"cursor-up": "上へ",
|
||||||
|
"cursor-down": "下へ",
|
||||||
|
"page-up": "前のページ",
|
||||||
|
"page-down": "次のページ",
|
||||||
|
"cursor-first": "先頭の項目",
|
||||||
|
"cursor-last": "最後の項目",
|
||||||
|
"toggle-select": "選択 / 選択解除",
|
||||||
|
"select-all": "すべて選択",
|
||||||
|
open: "開く",
|
||||||
|
parent: "親フォルダ",
|
||||||
|
"switch-panel": "パネルを切り替え",
|
||||||
|
"row-menu": "項目のメニューを表示",
|
||||||
|
send: "もう一方のパネルへ送信",
|
||||||
|
edit: "編集",
|
||||||
|
rename: "名前を変更",
|
||||||
|
"new-folder": "新規フォルダ",
|
||||||
|
"new-file": "新規ファイル",
|
||||||
|
chmod: "アクセス権",
|
||||||
|
delete: "削除",
|
||||||
|
trash: "ゴミ箱に移動",
|
||||||
|
"copy-path": "パスをコピー",
|
||||||
|
reveal: "ファイルマネージャーで表示",
|
||||||
|
"open-default": "既定のアプリで開く",
|
||||||
|
refresh: "更新",
|
||||||
|
"focus-path": "パスへ移動",
|
||||||
|
"source-picker": "パネルの表示内容を選択",
|
||||||
|
"sort-name": "名前で並べ替え",
|
||||||
|
"sort-size": "サイズで並べ替え",
|
||||||
|
"sort-mtime": "更新日時で並べ替え",
|
||||||
|
"sort-kind": "種類で並べ替え",
|
||||||
|
"sort-rights": "権限で並べ替え",
|
||||||
|
"sort-owner": "所有者で並べ替え",
|
||||||
|
"sort-group": "グループで並べ替え",
|
||||||
|
"new-tab": "新しいタブ",
|
||||||
|
"close-tab": "タブを閉じる",
|
||||||
|
"close-other-tabs": "他のタブを閉じる",
|
||||||
|
"close-tabs-right": "右側のタブを閉じる",
|
||||||
|
"next-tab": "次のタブ",
|
||||||
|
"prev-tab": "前のタブ",
|
||||||
|
"go-to-tab": "タブ {n} へ移動",
|
||||||
|
"go-to-last-tab": "最後のタブへ移動",
|
||||||
|
"move-tab-left": "タブを左へ移動",
|
||||||
|
"move-tab-right": "タブを右へ移動",
|
||||||
|
"disconnect-tab": "タブを切断",
|
||||||
|
"open-settings": "設定",
|
||||||
|
"connection-manager": "接続マネージャー",
|
||||||
|
"toggle-transfers": "転送の表示 / 非表示",
|
||||||
|
"install-update": "アップデートをインストール",
|
||||||
|
"scale-up": "インターフェースを拡大",
|
||||||
|
"scale-down": "インターフェースを縮小",
|
||||||
|
"scale-reset": "インターフェースを 100% に",
|
||||||
|
"close-window": "ウインドウを閉じる",
|
||||||
|
save: "保存",
|
||||||
|
find: "検索",
|
||||||
|
"find-next": "次を検索",
|
||||||
|
"find-prev": "前を検索",
|
||||||
|
"toggle-wrap": "行を折り返す",
|
||||||
|
"conn-new": "新規接続",
|
||||||
|
"conn-new-stack": "新規グループ",
|
||||||
|
"conn-save": "接続を保存",
|
||||||
|
"conn-connect": "接続 / グループを開く",
|
||||||
|
"conn-delete": "接続またはグループを削除",
|
||||||
|
"conn-rename-stack": "グループ名を変更",
|
||||||
|
"conn-import": "WinSCP からインポート",
|
||||||
|
groups: {
|
||||||
|
panels: "ファイル一覧",
|
||||||
|
files: "ファイル",
|
||||||
|
sort: "並べ替え",
|
||||||
|
tabs: "タブ",
|
||||||
|
window: "ウインドウ",
|
||||||
|
editor: "エディタ",
|
||||||
|
connections: "接続マネージャー",
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
filter: "ショートカットを検索",
|
||||||
|
intro: "+ を押してキーを入力するとショートカットを追加できます。最初のものがメニューとツールチップに表示されます。",
|
||||||
|
add: "ショートカットを追加",
|
||||||
|
remove: "{key} を削除",
|
||||||
|
reset: "既定に戻す",
|
||||||
|
resetAll: "すべて戻す",
|
||||||
|
resetAllConfirm: "すべてのショートカットを戻す",
|
||||||
|
press: "キーを押してください…({esc} でキャンセル)",
|
||||||
|
unbound: "ショートカットなし",
|
||||||
|
reserved: "{key} はシステムで予約されています",
|
||||||
|
needsModifier: "エディタでは {key} は入力されるだけです。修飾キーを加えるか F キーを使ってください",
|
||||||
|
usedBy: "{key} はすでに「{action}」で使われています",
|
||||||
|
replace: "置き換える",
|
||||||
|
conflict: "{action} と同じキー",
|
||||||
|
plainLetter: "ファイル一覧で {key} を入力しても絞り込まれなくなります",
|
||||||
|
noResults: "一致するショートカットはありません",
|
||||||
|
fixed: "固定キー",
|
||||||
|
},
|
||||||
|
fixed: {
|
||||||
|
escape: "{esc} でメニュー、ダイアログ、検索バーを閉じます",
|
||||||
|
escList: "ファイル一覧の {esc} は、絞り込みの解除、接続のキャンセル、選択の解除の順に働きます",
|
||||||
|
dialogs: "{enter} と {esc} でダイアログを確定 / キャンセルし、{tab} と {shiftTab} でコントロール間を移動します",
|
||||||
|
menus: "↑ ↓ {home} {end} と {enter} はすべてのメニューで使えます",
|
||||||
|
filter: "ファイル一覧で入力すると絞り込まれます。{backspace} で最後の文字を消します",
|
||||||
|
shift: "Shift と移動キーで、カーソルが通過した行を選択します",
|
||||||
|
find: "{enter} と {shiftEnter} で検索結果を移動します",
|
||||||
|
manager: "↑ ↓ ← → と {enter} で接続一覧を移動します",
|
||||||
|
sidebar: "↑ ↓ でここのセクションを切り替えます",
|
||||||
|
},
|
||||||
|
names: {
|
||||||
|
ctrl: "Ctrl",
|
||||||
|
alt: "Alt",
|
||||||
|
shift: "Shift",
|
||||||
|
enter: "Enter",
|
||||||
|
backspace: "Backspace",
|
||||||
|
del: "Del",
|
||||||
|
esc: "Esc",
|
||||||
|
space: "スペース",
|
||||||
|
tab: "Tab",
|
||||||
|
pgup: "PgUp",
|
||||||
|
pgdn: "PgDn",
|
||||||
|
home: "Home",
|
||||||
|
end: "End",
|
||||||
|
ins: "Ins",
|
||||||
|
menu: "メニュー",
|
||||||
|
num: "Num",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
sshAgentUnavailable: "ssh-agent が利用できません(SSH_AUTH_SOCK が未設定)",
|
||||||
|
identityFileNotFound: "認証鍵ファイルが見つかりません: {path}",
|
||||||
|
sessionNotConnected: 'セッション "{id}" は接続されていません',
|
||||||
|
localCannotDisconnect: "ローカルセッションは切断できません",
|
||||||
|
notADirectory: '"{path}" は存在し、フォルダではありません',
|
||||||
|
sameFileTransfer: '"{name}": コピー元とコピー先が同じファイルです',
|
||||||
|
sameFileNoName: "コピー元とコピー先が同じファイルです",
|
||||||
|
folderOntoItself: "フォルダをそれ自身の上にコピーすることはできません",
|
||||||
|
folderIntoItself: "フォルダをそれ自身の中にコピーすることはできません",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
// Simplified Chinese translations. Fall back to English for missing keys.
|
||||||
|
// TODO(i18n): AI draft — needs native-speaker review.
|
||||||
|
export const zh = {
|
||||||
|
common: {
|
||||||
|
cancel: "取消",
|
||||||
|
save: "保存",
|
||||||
|
close: "关闭",
|
||||||
|
delete: "删除",
|
||||||
|
rename: "重命名",
|
||||||
|
create: "创建",
|
||||||
|
apply: "应用",
|
||||||
|
choose: "选择…",
|
||||||
|
connect: "连接",
|
||||||
|
local: "本地",
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
notConnectedTitle: "未连接到应用",
|
||||||
|
notConnectedBody: "此页面运行在普通浏览器中。请启动桌面应用来使用。",
|
||||||
|
managerOpen: "连接管理器已打开。",
|
||||||
|
showConnections: "显示连接",
|
||||||
|
},
|
||||||
|
tabBar: {
|
||||||
|
connections: "连接",
|
||||||
|
manageConnections: "管理连接",
|
||||||
|
newTab: "新建标签页",
|
||||||
|
closeTab: "关闭标签页",
|
||||||
|
options: "设置",
|
||||||
|
update: "有可用更新",
|
||||||
|
updating: "正在更新",
|
||||||
|
restartToUpdate: "重启以应用",
|
||||||
|
updateTooltip: "有可用更新:miniSCP {version}",
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
title: "设置",
|
||||||
|
sections: {
|
||||||
|
appearance: "外观",
|
||||||
|
transfers: "传输",
|
||||||
|
editor: "编辑器",
|
||||||
|
fileList: "文件列表",
|
||||||
|
keyboard: "键盘",
|
||||||
|
about: "关于",
|
||||||
|
},
|
||||||
|
columns: "显示的列",
|
||||||
|
language: "语言",
|
||||||
|
system: "系统",
|
||||||
|
theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" },
|
||||||
|
tone: { label: "色调", dark: "深色", darker: "AMOLED" },
|
||||||
|
interface: { label: "界面", smaller: "缩小", larger: "放大", reset: "重置为100%" },
|
||||||
|
speedUnit: { label: "速度单位" },
|
||||||
|
transfers: { label: "传输", single: "单个", multi: "多个" },
|
||||||
|
atOnce: "同时",
|
||||||
|
editor: { label: "编辑器", builtin: "内置", custom: "自定义" },
|
||||||
|
systemDefault: "系统默认",
|
||||||
|
resetDefault: "恢复为系统默认",
|
||||||
|
finishedTransfers: { label: "已完成传输", autoClear: "自动清除", keep: "保留" },
|
||||||
|
openFiles: { label: "回车 / 双击文件", nothing: "不执行操作", open: "打开", hint: "本地文件用默认应用打开;文件夹始终打开。" },
|
||||||
|
},
|
||||||
|
kinds: {
|
||||||
|
folder: "文件夹",
|
||||||
|
symlink: "符号链接",
|
||||||
|
file: "文件",
|
||||||
|
image: "图像",
|
||||||
|
audio: "音频",
|
||||||
|
video: "视频",
|
||||||
|
archive: "压缩包",
|
||||||
|
code: "代码",
|
||||||
|
document: "文档",
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
name: "名称",
|
||||||
|
size: "大小",
|
||||||
|
mtime: "修改时间",
|
||||||
|
kind: "类型",
|
||||||
|
rights: "权限",
|
||||||
|
owner: "所有者",
|
||||||
|
group: "组",
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
choosePanel: "选择此面板显示的内容",
|
||||||
|
copy: "发送",
|
||||||
|
copyToRight: "发送到右侧面板",
|
||||||
|
copyToLeft: "发送到左侧面板",
|
||||||
|
edit: "编辑",
|
||||||
|
editFile: "编辑文件",
|
||||||
|
rename: "重命名",
|
||||||
|
newFolder: "新建文件夹",
|
||||||
|
newFile: "新建文件",
|
||||||
|
permissions: "权限",
|
||||||
|
delete: "删除",
|
||||||
|
parentDir: "上级目录",
|
||||||
|
refresh: "刷新",
|
||||||
|
clearFilter: "清除筛选 (Esc)",
|
||||||
|
sshConfig: "~/.ssh/config",
|
||||||
|
connectingTo: "正在连接 {name}…",
|
||||||
|
emptyDir: "空目录",
|
||||||
|
noMatches: "无匹配项",
|
||||||
|
matches: { other: "{count} 个匹配 · Esc" },
|
||||||
|
},
|
||||||
|
connMgr: {
|
||||||
|
title: "连接",
|
||||||
|
importWinscp: "从 WinSCP 导入…",
|
||||||
|
importWinscpHint: "从 WinSCP.ini 导出文件导入会话",
|
||||||
|
newConnection: "连接",
|
||||||
|
newConnectionHint: "新建连接",
|
||||||
|
newStack: "分组",
|
||||||
|
newStackHint: "新建分组",
|
||||||
|
expand: "展开",
|
||||||
|
collapse: "折叠",
|
||||||
|
renameStack: "重命名分组",
|
||||||
|
deleteStack: "删除分组",
|
||||||
|
openStack: "在标签页中打开分组",
|
||||||
|
empty: "空",
|
||||||
|
connectionsHeader: "连接",
|
||||||
|
noneTitle: "尚无已保存的连接。",
|
||||||
|
noneBody: "创建一个,或导入你的 WinSCP 会话。",
|
||||||
|
name: "名称",
|
||||||
|
namePlaceholder: "我的服务器",
|
||||||
|
host: "主机",
|
||||||
|
port: "端口",
|
||||||
|
stack: "分组",
|
||||||
|
stackNone: "(无)",
|
||||||
|
username: "用户名",
|
||||||
|
remotePath: "远程路径(可选)",
|
||||||
|
remotePathPlaceholder: "主目录",
|
||||||
|
auth: "认证",
|
||||||
|
authPassword: "密码",
|
||||||
|
authKey: "私钥",
|
||||||
|
password: "密码",
|
||||||
|
privateKeyFile: "私钥文件",
|
||||||
|
passphrase: "密钥口令(可选)",
|
||||||
|
localDir: "本地目录(可选)",
|
||||||
|
localDirPlaceholder: "~/projects/my-server",
|
||||||
|
localDirHint: "当另一个面板处于本地且未浏览过时,将在此打开。",
|
||||||
|
more: "更多…",
|
||||||
|
less: "收起…",
|
||||||
|
selectPrompt: "选择一个连接或新建一个。",
|
||||||
|
newStackTitle: "新建分组",
|
||||||
|
newStackPrompt: "分组名称",
|
||||||
|
renameStackTitle: "重命名分组",
|
||||||
|
deleteStackTitle: "删除分组",
|
||||||
|
deleteStackBody: '删除分组 "{name}"?其中的连接将保留但不再分组。',
|
||||||
|
deleteConnectionTitle: "删除连接",
|
||||||
|
deleteConnectionBody: '删除 "{name}"?',
|
||||||
|
},
|
||||||
|
editor: {
|
||||||
|
noFileTitle: "没有要编辑的文件",
|
||||||
|
noFileBody: "此窗口在没有编辑器上下文的情况下打开。请从应用中打开文件。",
|
||||||
|
wrap: "换行",
|
||||||
|
wrapHint: "长行自动换行 — 隐藏行号",
|
||||||
|
findHint: "查找",
|
||||||
|
findPlaceholder: "在文件中查找",
|
||||||
|
prevMatch: "上一个匹配",
|
||||||
|
nextMatch: "下一个匹配",
|
||||||
|
closeHint: "关闭 (Esc)",
|
||||||
|
disconnected: "已从 {name} 断开 — 保存将失败,但你的文本会保留在此窗口中。",
|
||||||
|
cantOpen: "无法打开 {name}",
|
||||||
|
closeWindow: "关闭窗口",
|
||||||
|
verbs: { save: "保存", find: "查找", close: "关闭" },
|
||||||
|
lines: { other: "{count} 行" },
|
||||||
|
close: "关闭",
|
||||||
|
save: "保存",
|
||||||
|
saveChangesTitle: '保存对 "{name}" 的更改?',
|
||||||
|
saveChangesBody: "如果不保存,你的更改将丢失。",
|
||||||
|
dontSave: "不保存",
|
||||||
|
countLabel: "第 {current} / {total}",
|
||||||
|
noMatches: "无匹配项",
|
||||||
|
},
|
||||||
|
transfer: {
|
||||||
|
summary: "{active} 进行中 · {finished} 已完成",
|
||||||
|
clearFinished: "清除已完成",
|
||||||
|
cancel: "取消",
|
||||||
|
dismiss: "忽略",
|
||||||
|
showDetails: "显示传输详情",
|
||||||
|
copying: "正在复制 {name} · {percent}%{speed}",
|
||||||
|
queued: "{count} 个排队中",
|
||||||
|
failed: { other: "{count} 个传输失败" },
|
||||||
|
finished: "传输已完成",
|
||||||
|
ready: "就绪",
|
||||||
|
items: "{count} 项",
|
||||||
|
selected: "已选 {count} 项",
|
||||||
|
queuedState: "排队中",
|
||||||
|
scanning: "扫描中…",
|
||||||
|
failedState: "失败",
|
||||||
|
cancelled: "已取消",
|
||||||
|
completedFiles: { other: "{count} 个文件 · {size}" },
|
||||||
|
left: "剩余 {eta}",
|
||||||
|
},
|
||||||
|
dialogs: {
|
||||||
|
chmodTitle: "权限",
|
||||||
|
chmodItems: "{count} 项",
|
||||||
|
read: "读",
|
||||||
|
write: "写",
|
||||||
|
execute: "执行",
|
||||||
|
roleOwner: "所有者",
|
||||||
|
roleGroup: "组",
|
||||||
|
roleOthers: "其他",
|
||||||
|
octal: "八进制",
|
||||||
|
mergeFolder: "合并文件夹",
|
||||||
|
replace: "替换",
|
||||||
|
merge: "合并",
|
||||||
|
mergeAll: "全部合并",
|
||||||
|
replaceAll: "全部替换",
|
||||||
|
skip: "跳过",
|
||||||
|
skipAll: "全部跳过",
|
||||||
|
ofTotal: "第 {index} / {total}",
|
||||||
|
overwriteDirBody: "目标位置已存在同名文件夹。合并可能会替换其中的文件。",
|
||||||
|
overwriteFileBody: "目标位置已存在同名文件。",
|
||||||
|
source: "源",
|
||||||
|
destination: "目标",
|
||||||
|
newer: "较新",
|
||||||
|
},
|
||||||
|
toasts: {
|
||||||
|
movedToTrash: '已将 "{name}" 移到废纸篓',
|
||||||
|
movedManyToTrash: { other: "已将 {count} 个项目移到废纸篓" },
|
||||||
|
editingExternal: "正在编辑 {name} — 保存后会自动上传",
|
||||||
|
nothingToCopy: "没有可复制的内容(符号链接将被跳过)",
|
||||||
|
nothingToCopyAllSkipped: "没有可复制的内容 — 所有冲突项都被跳过",
|
||||||
|
sameFolder: "两个面板显示的是同一个文件夹",
|
||||||
|
sameFile: '已跳过 "{name}" — 它是同一个文件',
|
||||||
|
typeMismatch: '已跳过 "{name}" — 目标处是不同类型的项目',
|
||||||
|
transferFailed: '传输 "{name}" 失败:{error}',
|
||||||
|
unknownError: "未知错误",
|
||||||
|
saved: "已保存 {name}",
|
||||||
|
disconnectedReason: "{title}:已断开({reason})",
|
||||||
|
imported: { other: "已导入 {count} 个连接" },
|
||||||
|
importedIntoStacks: { other: ",归入 {count} 个新分组" },
|
||||||
|
importedSkipped: "(跳过 {skipped} 个)",
|
||||||
|
leftoverTitle: "残留的已编辑文件",
|
||||||
|
leftoverMore: ",还有 {count} 个",
|
||||||
|
leftoverBody: {
|
||||||
|
other: "上一次会话遗留了 {count} 个临时编辑文件({names})。要清除吗?",
|
||||||
|
},
|
||||||
|
leftoverClear: "清除",
|
||||||
|
},
|
||||||
|
fileActions: {
|
||||||
|
deleteTitle: "删除",
|
||||||
|
deleteBody: "删除 {what}?文件夹将被递归删除。此操作无法撤销。",
|
||||||
|
deleteConfirm: "删除",
|
||||||
|
renameTitle: '重命名 "{name}"',
|
||||||
|
renameConfirm: "重命名",
|
||||||
|
newFolderTitle: "新建文件夹",
|
||||||
|
newFolderPlaceholder: "文件夹名称",
|
||||||
|
newFileTitle: "新建文件",
|
||||||
|
newFilePlaceholder: "文件名称",
|
||||||
|
createConfirm: "创建",
|
||||||
|
itemsCount: "{count} 项",
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
about: "关于 {app}",
|
||||||
|
settings: "设置…",
|
||||||
|
checkForUpdates: "检查更新…",
|
||||||
|
quit: "退出",
|
||||||
|
file: "文件",
|
||||||
|
newTab: "新建标签页",
|
||||||
|
closeTab: "关闭标签页",
|
||||||
|
newFolder: "新建文件夹",
|
||||||
|
newFile: "新建文件",
|
||||||
|
editFile: "编辑文件",
|
||||||
|
connect: "连接",
|
||||||
|
connectionManager: "连接管理器…",
|
||||||
|
edit: "编辑",
|
||||||
|
view: "查看",
|
||||||
|
refresh: "刷新",
|
||||||
|
window: "窗口",
|
||||||
|
},
|
||||||
|
ctx: {
|
||||||
|
size: "大小",
|
||||||
|
modified: "修改时间",
|
||||||
|
kind: "类型",
|
||||||
|
rights: "权限",
|
||||||
|
owner: "所有者",
|
||||||
|
group: "组",
|
||||||
|
revealFinder: "在访达中显示",
|
||||||
|
revealExplorer: "在资源管理器中显示",
|
||||||
|
revealFileManager: "在文件管理器中显示",
|
||||||
|
moveToTrash: "移到废纸篓",
|
||||||
|
moveToRecycleBin: "移到回收站",
|
||||||
|
newFolder: "新建文件夹",
|
||||||
|
newFile: "新建文件",
|
||||||
|
refresh: "刷新",
|
||||||
|
open: "打开",
|
||||||
|
openDefault: "用默认应用打开",
|
||||||
|
edit: "编辑",
|
||||||
|
copyToOther: "发送到另一个面板",
|
||||||
|
rename: "重命名",
|
||||||
|
permissions: "权限",
|
||||||
|
copyPath: "复制路径",
|
||||||
|
delete: "删除",
|
||||||
|
columns: "列",
|
||||||
|
tabNew: "新建标签页",
|
||||||
|
tabClose: "关闭标签页",
|
||||||
|
tabCloseOthers: "关闭其他标签页",
|
||||||
|
tabCloseRight: "关闭右侧标签页",
|
||||||
|
tabDisconnect: "断开连接",
|
||||||
|
trashTooltip: "从此处移入废纸篓的项目无法使用访达的放回原处功能",
|
||||||
|
},
|
||||||
|
keys: {
|
||||||
|
"cursor-up": "上移",
|
||||||
|
"cursor-down": "下移",
|
||||||
|
"page-up": "上一页",
|
||||||
|
"page-down": "下一页",
|
||||||
|
"cursor-first": "第一项",
|
||||||
|
"cursor-last": "最后一项",
|
||||||
|
"toggle-select": "选择 / 取消选择",
|
||||||
|
"select-all": "全选",
|
||||||
|
open: "打开",
|
||||||
|
parent: "上级目录",
|
||||||
|
"switch-panel": "切换面板",
|
||||||
|
"row-menu": "显示该项的菜单",
|
||||||
|
send: "发送到另一个面板",
|
||||||
|
edit: "编辑",
|
||||||
|
rename: "重命名",
|
||||||
|
"new-folder": "新建文件夹",
|
||||||
|
"new-file": "新建文件",
|
||||||
|
chmod: "权限",
|
||||||
|
delete: "删除",
|
||||||
|
trash: "移到废纸篓",
|
||||||
|
"copy-path": "复制路径",
|
||||||
|
reveal: "在文件管理器中显示",
|
||||||
|
"open-default": "用默认应用打开",
|
||||||
|
refresh: "刷新",
|
||||||
|
"focus-path": "转到路径",
|
||||||
|
"source-picker": "选择面板显示的内容",
|
||||||
|
"sort-name": "按名称排序",
|
||||||
|
"sort-size": "按大小排序",
|
||||||
|
"sort-mtime": "按修改时间排序",
|
||||||
|
"sort-kind": "按种类排序",
|
||||||
|
"sort-rights": "按权限排序",
|
||||||
|
"sort-owner": "按所有者排序",
|
||||||
|
"sort-group": "按组排序",
|
||||||
|
"new-tab": "新建标签页",
|
||||||
|
"close-tab": "关闭标签页",
|
||||||
|
"close-other-tabs": "关闭其他标签页",
|
||||||
|
"close-tabs-right": "关闭右侧标签页",
|
||||||
|
"next-tab": "下一个标签页",
|
||||||
|
"prev-tab": "上一个标签页",
|
||||||
|
"go-to-tab": "转到标签页 {n}",
|
||||||
|
"go-to-last-tab": "转到最后一个标签页",
|
||||||
|
"move-tab-left": "标签页左移",
|
||||||
|
"move-tab-right": "标签页右移",
|
||||||
|
"disconnect-tab": "断开标签页",
|
||||||
|
"open-settings": "设置",
|
||||||
|
"connection-manager": "连接管理器",
|
||||||
|
"toggle-transfers": "显示 / 隐藏传输",
|
||||||
|
"install-update": "安装更新",
|
||||||
|
"scale-up": "放大界面",
|
||||||
|
"scale-down": "缩小界面",
|
||||||
|
"scale-reset": "界面 100%",
|
||||||
|
"close-window": "关闭窗口",
|
||||||
|
save: "保存",
|
||||||
|
find: "查找",
|
||||||
|
"find-next": "查找下一个",
|
||||||
|
"find-prev": "查找上一个",
|
||||||
|
"toggle-wrap": "自动换行",
|
||||||
|
"conn-new": "新建连接",
|
||||||
|
"conn-new-stack": "新建分组",
|
||||||
|
"conn-save": "保存连接",
|
||||||
|
"conn-connect": "连接 / 打开分组",
|
||||||
|
"conn-delete": "删除连接或分组",
|
||||||
|
"conn-rename-stack": "重命名分组",
|
||||||
|
"conn-import": "从 WinSCP 导入",
|
||||||
|
groups: {
|
||||||
|
panels: "文件列表",
|
||||||
|
files: "文件",
|
||||||
|
sort: "排序",
|
||||||
|
tabs: "标签页",
|
||||||
|
window: "窗口",
|
||||||
|
editor: "编辑器",
|
||||||
|
connections: "连接管理器",
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
filter: "搜索快捷键",
|
||||||
|
intro: "点击 + 并按下按键即可添加快捷键。第一个会显示在菜单和提示中。",
|
||||||
|
add: "添加快捷键",
|
||||||
|
remove: "移除 {key}",
|
||||||
|
reset: "恢复默认",
|
||||||
|
resetAll: "全部恢复",
|
||||||
|
resetAllConfirm: "恢复所有快捷键",
|
||||||
|
press: "请按键…({esc} 取消)",
|
||||||
|
unbound: "无快捷键",
|
||||||
|
reserved: "{key} 已被系统保留",
|
||||||
|
needsModifier: "在编辑器中 {key} 只会输入字符:请加上修饰键或使用 F 键",
|
||||||
|
usedBy: "{key} 已被“{action}”使用",
|
||||||
|
replace: "替换",
|
||||||
|
conflict: "与 {action} 的按键相同",
|
||||||
|
plainLetter: "在文件列表中输入 {key} 将不再进行筛选",
|
||||||
|
noResults: "没有匹配的快捷键",
|
||||||
|
fixed: "固定按键",
|
||||||
|
},
|
||||||
|
fixed: {
|
||||||
|
escape: "{esc} 关闭菜单、对话框和查找栏",
|
||||||
|
escList: "在文件列表中按 {esc} 依次清除筛选、取消连接、清除选择",
|
||||||
|
dialogs: "{enter} 和 {esc} 确认或取消对话框;{tab} 和 {shiftTab} 在其控件间移动",
|
||||||
|
menus: "↑ ↓ {home} {end} 和 {enter} 适用于所有菜单",
|
||||||
|
filter: "在文件列表中输入即可筛选;{backspace} 删除筛选的最后一个字符",
|
||||||
|
shift: "Shift 加移动键会选中光标经过的行",
|
||||||
|
find: "{enter} 和 {shiftEnter} 在查找结果间跳转",
|
||||||
|
manager: "↑ ↓ ← → 和 {enter} 在连接列表中移动",
|
||||||
|
sidebar: "↑ ↓ 在这些分区间切换",
|
||||||
|
},
|
||||||
|
names: {
|
||||||
|
ctrl: "Ctrl",
|
||||||
|
alt: "Alt",
|
||||||
|
shift: "Shift",
|
||||||
|
enter: "Enter",
|
||||||
|
backspace: "退格",
|
||||||
|
del: "Del",
|
||||||
|
esc: "Esc",
|
||||||
|
space: "空格",
|
||||||
|
tab: "Tab",
|
||||||
|
pgup: "PgUp",
|
||||||
|
pgdn: "PgDn",
|
||||||
|
home: "Home",
|
||||||
|
end: "End",
|
||||||
|
ins: "Ins",
|
||||||
|
menu: "菜单",
|
||||||
|
num: "Num",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
sshAgentUnavailable: "ssh-agent 不可用(未设置 SSH_AUTH_SOCK)",
|
||||||
|
identityFileNotFound: "找不到身份文件:{path}",
|
||||||
|
sessionNotConnected: '会话 "{id}" 未连接',
|
||||||
|
localCannotDisconnect: "无法断开本地会话",
|
||||||
|
notADirectory: '"{path}" 已存在且不是文件夹',
|
||||||
|
sameFileTransfer: '"{name}":源和目标是同一个文件',
|
||||||
|
sameFileNoName: "源和目标是同一个文件",
|
||||||
|
folderOntoItself: "无法将文件夹复制到其自身之上",
|
||||||
|
folderIntoItself: "无法将文件夹复制到其自身内部",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { DICTS } from "./i18n";
|
||||||
|
import { en } from "./i18n/en";
|
||||||
|
import {
|
||||||
|
BINDINGS,
|
||||||
|
type Combo,
|
||||||
|
comboFromEvent,
|
||||||
|
conflicts,
|
||||||
|
EN_KEY_NAMES,
|
||||||
|
effectiveKeymap,
|
||||||
|
firesInTextField,
|
||||||
|
formatCombo,
|
||||||
|
GROUPS,
|
||||||
|
isReserved,
|
||||||
|
type KeyEventLike,
|
||||||
|
matchesEvent,
|
||||||
|
NATIVE_MENU_IDS,
|
||||||
|
parseCombo,
|
||||||
|
serializeCombo,
|
||||||
|
scopesOverlap,
|
||||||
|
toAccelerator,
|
||||||
|
} from "./keymap";
|
||||||
|
|
||||||
|
const US_CHARS: Record<string, string> = {
|
||||||
|
Comma: ",",
|
||||||
|
Period: ".",
|
||||||
|
Slash: "/",
|
||||||
|
Semicolon: ";",
|
||||||
|
Quote: "'",
|
||||||
|
BracketLeft: "[",
|
||||||
|
BracketRight: "]",
|
||||||
|
Backslash: "\\",
|
||||||
|
Backquote: "`",
|
||||||
|
Equal: "=",
|
||||||
|
Minus: "-",
|
||||||
|
NumpadAdd: "+",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The keydown a US keyboard produces for `c`. */
|
||||||
|
function eventFor(c: Combo, isMac: boolean): KeyEventLike {
|
||||||
|
let key = c.key;
|
||||||
|
let code = c.key;
|
||||||
|
if (/^[A-Z]$/.test(c.key)) {
|
||||||
|
key = c.shift ? c.key : c.key.toLowerCase();
|
||||||
|
code = `Key${c.key}`;
|
||||||
|
} else if (/^[0-9]$/.test(c.key)) {
|
||||||
|
code = `Digit${c.key}`;
|
||||||
|
} else if (c.key in US_CHARS) {
|
||||||
|
key = US_CHARS[c.key];
|
||||||
|
} else if (c.key === "Space") {
|
||||||
|
key = " ";
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
code,
|
||||||
|
metaKey: isMac && c.mod,
|
||||||
|
ctrlKey: isMac ? c.ctrl : c.mod,
|
||||||
|
altKey: c.alt,
|
||||||
|
shiftKey: c.shift,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ev = (over: Partial<KeyEventLike>): KeyEventLike => ({
|
||||||
|
key: "",
|
||||||
|
code: "",
|
||||||
|
metaKey: false,
|
||||||
|
ctrlKey: false,
|
||||||
|
altKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const combo = (text: string, isMac: boolean) => {
|
||||||
|
const c = parseCombo(text, isMac);
|
||||||
|
if (!c) throw new Error(`unparsable ${text}`);
|
||||||
|
return c;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const isMac of [true, false]) {
|
||||||
|
const os = isMac ? "macOS" : "Windows/Linux";
|
||||||
|
const keymap = effectiveKeymap({}, isMac);
|
||||||
|
|
||||||
|
test(`${os}: the defaults have no conflicts`, () => {
|
||||||
|
expect([...conflicts(keymap).entries()]).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${os}: every default round-trips and resolves to its own command`, () => {
|
||||||
|
for (const binding of keymap) {
|
||||||
|
for (const c of binding.combos) {
|
||||||
|
expect(parseCombo(serializeCombo(c), isMac)).toEqual(c);
|
||||||
|
const event = eventFor(c, isMac);
|
||||||
|
// table order is resolution order: nothing earlier may shadow it
|
||||||
|
const hit = keymap.find(
|
||||||
|
(other) => scopesOverlap(other.scopes, binding.scopes) && other.combos.some((oc) => matchesEvent(oc, event, isMac)),
|
||||||
|
);
|
||||||
|
expect(`${serializeCombo(c)} → ${hit?.id}`).toBe(`${serializeCombo(c)} → ${binding.id}`);
|
||||||
|
expect(comboFromEvent(event, isMac)).toEqual(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${os}: no default is a reserved combo`, () => {
|
||||||
|
for (const binding of keymap) for (const c of binding.combos) expect(isReserved(c, isMac)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${os}: every macOS menu command has an expressible accelerator`, () => {
|
||||||
|
if (!isMac) return;
|
||||||
|
for (const id of NATIVE_MENU_IDS) {
|
||||||
|
const binding = keymap.find((b) => b.id === id)!;
|
||||||
|
expect(binding.combos.some((c) => toAccelerator(c) !== null)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("every command and group is labelled — in every shipped language", () => {
|
||||||
|
for (const [locale, dict] of Object.entries(DICTS)) {
|
||||||
|
const keys = dict.keys as Record<string, unknown> & { groups: Record<string, unknown> };
|
||||||
|
for (const b of BINDINGS) {
|
||||||
|
expect(`${locale}:${b.id}:${typeof keys["n" in b ? "go-to-tab" : b.id]}`).toBe(`${locale}:${b.id}:string`);
|
||||||
|
}
|
||||||
|
for (const g of GROUPS) expect(`${locale}:${g}:${typeof keys.groups[g]}`).toBe(`${locale}:${g}:string`);
|
||||||
|
}
|
||||||
|
expect(typeof en.keys.ui.press).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("matching is exact about modifiers", () => {
|
||||||
|
// ⌃F is Cocoa's forward-one-character, not ⌘F
|
||||||
|
expect(matchesEvent(combo("Mod+F", true), ev({ key: "f", code: "KeyF", ctrlKey: true }), true)).toBe(false);
|
||||||
|
expect(matchesEvent(combo("Mod+F", true), ev({ key: "f", code: "KeyF", metaKey: true }), true)).toBe(true);
|
||||||
|
// Ctrl+Tab is not Tab (the old panel switch caught both)
|
||||||
|
expect(matchesEvent(combo("Tab", false), ev({ key: "Tab", code: "Tab", ctrlKey: true }), false)).toBe(false);
|
||||||
|
// the Windows key is never part of a binding
|
||||||
|
expect(matchesEvent(combo("Mod+T", false), ev({ key: "t", code: "KeyT", ctrlKey: true, metaKey: true }), false)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("letters follow the layout, Alt chords and other scripts use the physical key", () => {
|
||||||
|
// AZERTY: the comma sits on the M key, the A on the Q key
|
||||||
|
expect(comboFromEvent(ev({ key: ",", code: "KeyM", metaKey: true }), true)).toEqual(combo("Mod+Comma", true));
|
||||||
|
expect(comboFromEvent(ev({ key: "a", code: "KeyQ", ctrlKey: true }), false)).toEqual(combo("Mod+A", false));
|
||||||
|
// macOS Option turns ⌥Z into Ω
|
||||||
|
expect(comboFromEvent(ev({ key: "Ω", code: "KeyZ", altKey: true }), true)).toEqual(combo("Alt+Z", true));
|
||||||
|
// Cyrillic ⌘T arrives as "е" on the T key
|
||||||
|
expect(matchesEvent(combo("Mod+T", true), ev({ key: "е", code: "KeyT", metaKey: true }), true)).toBe(true);
|
||||||
|
// US ⌘+ is ⇧⌘= — matched by the physical key
|
||||||
|
expect(matchesEvent(combo("Mod+Shift+Equal", true), ev({ key: "+", code: "Equal", metaKey: true, shiftKey: true }), true)).toBe(true);
|
||||||
|
// AZERTY ⌘1 types "&" on the 1 key
|
||||||
|
expect(comboFromEvent(ev({ key: "&", code: "Digit1", metaKey: true }), true)).toEqual(combo("Mod+1", true));
|
||||||
|
// a bare modifier press is not a combo
|
||||||
|
expect(comboFromEvent(ev({ key: "Shift", code: "ShiftLeft", shiftKey: true }), true)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formatting per platform, with localized key names off macOS", () => {
|
||||||
|
expect(formatCombo(combo("Mod+Shift+N", true), true)).toBe("⇧⌘N");
|
||||||
|
expect(formatCombo(combo("Ctrl+Tab", true), true)).toBe("⌃⇥");
|
||||||
|
expect(formatCombo(combo("Mod+Alt+C", true), true)).toBe("⌥⌘C");
|
||||||
|
expect(formatCombo(combo("Mod+Comma", true), true)).toBe("⌘,");
|
||||||
|
expect(formatCombo(combo("Shift+F10", true), true)).toBe("⇧F10");
|
||||||
|
expect(formatCombo(combo("Mod+Backspace", true), true)).toBe("⌘⌫");
|
||||||
|
expect(formatCombo(combo("Mod+Shift+N", false), false)).toBe("Ctrl+Shift+N");
|
||||||
|
expect(formatCombo(combo("Alt+Shift+R", false), false)).toBe("Alt+Shift+R");
|
||||||
|
expect(formatCombo(combo("Delete", false), false)).toBe("Del");
|
||||||
|
const de = { ...EN_KEY_NAMES, ctrl: "Strg", del: "Entf" };
|
||||||
|
expect(formatCombo(combo("Mod+Delete", false), false, de)).toBe("Strg+Entf");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("overrides replace a row; garbage is dropped", () => {
|
||||||
|
const keymap = effectiveKeymap(
|
||||||
|
{ rename: ["Mod+Shift+R", "nonsense", 5, "Mod+Shift+R"], save: "F9", bogus: ["F1"], delete: [] },
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const by = (id: string) => keymap.find((b) => b.id === id)!;
|
||||||
|
expect(by("rename").combos.map(serializeCombo)).toEqual(["Mod+Shift+R"]);
|
||||||
|
expect(by("rename").custom).toBe(true);
|
||||||
|
// not an array: ignored, the defaults stand
|
||||||
|
expect(by("save").custom).toBe(false);
|
||||||
|
expect(by("save").combos.map(serializeCombo)).toEqual(["Mod+S"]);
|
||||||
|
// [] unbinds
|
||||||
|
expect(by("delete").combos).toEqual([]);
|
||||||
|
expect(keymap.some((b) => (b.id as string) === "bogus")).toBe(false);
|
||||||
|
// not an object at all
|
||||||
|
expect(effectiveKeymap("junk", true).every((b) => !b.custom)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("off macOS a stored literal Ctrl folds into Mod", () => {
|
||||||
|
expect(parseCombo("Ctrl+Tab", false)).toEqual(parseCombo("Mod+Tab", false));
|
||||||
|
expect(parseCombo("Ctrl+Tab", true)).not.toEqual(parseCombo("Mod+Tab", true));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("native menu accelerators", () => {
|
||||||
|
expect(toAccelerator(combo("Mod+Shift+N", true))).toBe("Command+Shift+n");
|
||||||
|
expect(toAccelerator(combo("Mod+Comma", true))).toBe("Command+,");
|
||||||
|
expect(toAccelerator(combo("Mod+Minus", true))).toBe("Command+minus");
|
||||||
|
expect(toAccelerator(combo("Ctrl+Shift+PageUp", true))).toBe("Control+Shift+pageup");
|
||||||
|
// a bare key would mean ⌘+key to the native parser
|
||||||
|
expect(toAccelerator(combo("F5", true))).toBeNull();
|
||||||
|
// ⌫ / ⌦, Shift+punctuation and modifier-less combos have no faithful form
|
||||||
|
expect(toAccelerator(combo("Mod+Delete", true))).toBeNull();
|
||||||
|
expect(toAccelerator(combo("Mod+Backspace", true))).toBeNull();
|
||||||
|
expect(toAccelerator(combo("Mod+Shift+BracketRight", true))).toBeNull();
|
||||||
|
expect(toAccelerator(combo("Shift+F4", true))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("text fields keep their own keys", () => {
|
||||||
|
// plain keys and text-editing chords type / edit
|
||||||
|
expect(firesInTextField(combo("Delete", true), true)).toBe(false);
|
||||||
|
expect(firesInTextField(combo("Mod+Backspace", true), true)).toBe(false);
|
||||||
|
expect(firesInTextField(combo("Mod+A", true), true)).toBe(false);
|
||||||
|
expect(firesInTextField(combo("Alt+ArrowLeft", true), true)).toBe(false);
|
||||||
|
// app chords and F-keys still fire (⌘S in the editor, ⌘↩ in the manager form)
|
||||||
|
expect(firesInTextField(combo("Mod+S", true), true)).toBe(true);
|
||||||
|
expect(firesInTextField(combo("Mod+Enter", false), false)).toBe(true);
|
||||||
|
expect(firesInTextField(combo("F3", false), false)).toBe(true);
|
||||||
|
// Ctrl+Alt off macOS is AltGr typing a character
|
||||||
|
expect(firesInTextField(combo("Mod+Alt+Q", false), false)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("AltGr is never a shortcut off macOS", () => {
|
||||||
|
const altGrQ = ev({ key: "@", code: "KeyQ", ctrlKey: true, altKey: true, getModifierState: (k) => k === "AltGraph" });
|
||||||
|
expect(matchesEvent(combo("Mod+Alt+Q", false), altGrQ, false)).toBe(false);
|
||||||
|
expect(comboFromEvent(altGrQ, false)).toBeNull();
|
||||||
|
});
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
// The app's one keymap: every keyboard command, where it fires and its default
|
||||||
|
// keys per platform, plus the pure helpers that parse, match, format and check
|
||||||
|
// key combos. Shared on purpose: the webviews resolve keys, show hints and edit
|
||||||
|
// remaps against this table, and bun builds the macOS menu-bar accelerators
|
||||||
|
// from it, so the two can never disagree. User remaps are stored as overrides
|
||||||
|
// in AppSettings.keybindings and applied by effectiveKeymap().
|
||||||
|
|
||||||
|
/** Where a binding fires. `panel` = the focused file list, `main` = anywhere
|
||||||
|
* in the main window, `any` = every window. `panel` sits inside `main`, so
|
||||||
|
* the two overlap for conflict purposes. */
|
||||||
|
export type Scope = "panel" | "main" | "editor" | "manager" | "settings" | "any";
|
||||||
|
|
||||||
|
/** Settings → Keyboard sections, in display order. */
|
||||||
|
export const GROUPS = ["panels", "files", "sort", "tabs", "window", "editor", "connections"] as const;
|
||||||
|
export type Group = (typeof GROUPS)[number];
|
||||||
|
|
||||||
|
interface BindingDef {
|
||||||
|
readonly id: string;
|
||||||
|
readonly group: Group;
|
||||||
|
readonly scopes: readonly Scope[];
|
||||||
|
/** Default combos, best first: the first is the one menus and tooltips show. */
|
||||||
|
readonly mac: readonly string[];
|
||||||
|
readonly other: readonly string[];
|
||||||
|
/** go-to-tab-N: the N its label interpolates. */
|
||||||
|
readonly n?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const both = (...combos: string[]) => ({ mac: combos, other: combos });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every command, in Settings display order within its group. Combo strings are
|
||||||
|
* platform-neutral (see parseCombo): `Mod` is ⌘ on macOS and Ctrl elsewhere,
|
||||||
|
* `Ctrl` a literal ⌃ that only macOS tells apart.
|
||||||
|
*/
|
||||||
|
export const BINDINGS = [
|
||||||
|
// ---- panels: the focused file list ----
|
||||||
|
{ id: "cursor-up", group: "panels", scopes: ["panel"], ...both("ArrowUp") },
|
||||||
|
{ id: "cursor-down", group: "panels", scopes: ["panel"], ...both("ArrowDown") },
|
||||||
|
{ id: "page-up", group: "panels", scopes: ["panel"], ...both("PageUp") },
|
||||||
|
{ id: "page-down", group: "panels", scopes: ["panel"], ...both("PageDown") },
|
||||||
|
{ id: "cursor-first", group: "panels", scopes: ["panel"], ...both("Home") },
|
||||||
|
{ id: "cursor-last", group: "panels", scopes: ["panel"], ...both("End") },
|
||||||
|
{ id: "toggle-select", group: "panels", scopes: ["panel"], mac: ["Space"], other: ["Space", "Insert"] },
|
||||||
|
{ id: "select-all", group: "panels", scopes: ["panel"], ...both("Mod+A") },
|
||||||
|
{ id: "open", group: "panels", scopes: ["panel"], mac: ["Enter", "Mod+ArrowDown"], other: ["Enter"] },
|
||||||
|
{ id: "parent", group: "panels", scopes: ["panel"], mac: ["Backspace", "Mod+ArrowUp"], other: ["Backspace", "Alt+ArrowUp"] },
|
||||||
|
{ id: "switch-panel", group: "panels", scopes: ["panel"], ...both("Tab", "Shift+Tab") },
|
||||||
|
{ id: "row-menu", group: "panels", scopes: ["panel"], mac: ["Shift+F10", "Ctrl+Enter"], other: ["Shift+F10", "ContextMenu"] },
|
||||||
|
// ---- files: act on the focused panel's selection, else its cursor row ----
|
||||||
|
{ id: "send", group: "files", scopes: ["panel"], ...both("F5") },
|
||||||
|
{ id: "edit", group: "files", scopes: ["panel"], mac: ["Mod+E", "F4"], other: ["F4", "Mod+E"] },
|
||||||
|
{ id: "rename", group: "files", scopes: ["panel"], ...both("F2") },
|
||||||
|
{ id: "new-folder", group: "files", scopes: ["panel"], mac: ["Mod+Shift+N", "F7"], other: ["F7", "Mod+Shift+N"] },
|
||||||
|
{ id: "new-file", group: "files", scopes: ["panel"], mac: ["Mod+N", "Shift+F4"], other: ["Shift+F4", "Mod+N"] },
|
||||||
|
{ id: "chmod", group: "files", scopes: ["panel"], ...both("Mod+I") },
|
||||||
|
{ id: "delete", group: "files", scopes: ["panel"], ...both("F8", "Delete") },
|
||||||
|
{ id: "trash", group: "files", scopes: ["panel"], mac: ["Mod+Backspace"], other: ["Mod+Delete"] },
|
||||||
|
{ id: "copy-path", group: "files", scopes: ["panel"], mac: ["Mod+Alt+C"], other: ["Mod+Shift+C"] },
|
||||||
|
{ id: "reveal", group: "files", scopes: ["panel"], mac: ["Mod+Alt+R"], other: ["Alt+Shift+R"] },
|
||||||
|
{ id: "open-default", group: "files", scopes: ["panel"], ...both("Mod+O") },
|
||||||
|
{ id: "refresh", group: "files", scopes: ["panel"], ...both("Mod+R") },
|
||||||
|
{ id: "focus-path", group: "files", scopes: ["panel"], ...both("Mod+L") },
|
||||||
|
{ id: "source-picker", group: "files", scopes: ["panel"], ...both("Mod+K") },
|
||||||
|
// ---- sort the focused panel (again = reverse) ----
|
||||||
|
{ id: "sort-name", group: "sort", scopes: ["panel"], ...both("Alt+1") },
|
||||||
|
{ id: "sort-size", group: "sort", scopes: ["panel"], ...both("Alt+2") },
|
||||||
|
{ id: "sort-mtime", group: "sort", scopes: ["panel"], ...both("Alt+3") },
|
||||||
|
{ id: "sort-kind", group: "sort", scopes: ["panel"], ...both("Alt+4") },
|
||||||
|
{ id: "sort-rights", group: "sort", scopes: ["panel"], ...both("Alt+5") },
|
||||||
|
{ id: "sort-owner", group: "sort", scopes: ["panel"], ...both("Alt+6") },
|
||||||
|
{ id: "sort-group", group: "sort", scopes: ["panel"], ...both("Alt+7") },
|
||||||
|
// ---- tabs (Ctrl+Tab is a literal ⌃ on macOS: ⌘Tab is the app switcher) ----
|
||||||
|
{ id: "new-tab", group: "tabs", scopes: ["main"], ...both("Mod+T") },
|
||||||
|
{ id: "close-tab", group: "tabs", scopes: ["main"], ...both("Mod+W") },
|
||||||
|
// Ctrl+Alt+letter is AltGr typing on many non-mac layouts — never a default there
|
||||||
|
{ id: "close-other-tabs", group: "tabs", scopes: ["main"], mac: ["Mod+Alt+W"], other: ["Mod+Shift+W"] },
|
||||||
|
{ id: "close-tabs-right", group: "tabs", scopes: ["main"], ...both() },
|
||||||
|
{ id: "next-tab", group: "tabs", scopes: ["main"], mac: ["Ctrl+Tab", "Mod+Shift+BracketRight"], other: ["Ctrl+Tab", "Mod+PageDown"] },
|
||||||
|
{ id: "prev-tab", group: "tabs", scopes: ["main"], mac: ["Ctrl+Shift+Tab", "Mod+Shift+BracketLeft"], other: ["Ctrl+Shift+Tab", "Mod+PageUp"] },
|
||||||
|
{ id: "go-to-tab-1", group: "tabs", scopes: ["main"], n: 1, ...both("Mod+1") },
|
||||||
|
{ id: "go-to-tab-2", group: "tabs", scopes: ["main"], n: 2, ...both("Mod+2") },
|
||||||
|
{ id: "go-to-tab-3", group: "tabs", scopes: ["main"], n: 3, ...both("Mod+3") },
|
||||||
|
{ id: "go-to-tab-4", group: "tabs", scopes: ["main"], n: 4, ...both("Mod+4") },
|
||||||
|
{ id: "go-to-tab-5", group: "tabs", scopes: ["main"], n: 5, ...both("Mod+5") },
|
||||||
|
{ id: "go-to-tab-6", group: "tabs", scopes: ["main"], n: 6, ...both("Mod+6") },
|
||||||
|
{ id: "go-to-tab-7", group: "tabs", scopes: ["main"], n: 7, ...both("Mod+7") },
|
||||||
|
{ id: "go-to-tab-8", group: "tabs", scopes: ["main"], n: 8, ...both("Mod+8") },
|
||||||
|
{ id: "go-to-last-tab", group: "tabs", scopes: ["main"], ...both("Mod+9") },
|
||||||
|
{ id: "move-tab-left", group: "tabs", scopes: ["main"], mac: ["Ctrl+Shift+PageUp"], other: ["Mod+Shift+PageUp"] },
|
||||||
|
{ id: "move-tab-right", group: "tabs", scopes: ["main"], mac: ["Ctrl+Shift+PageDown"], other: ["Mod+Shift+PageDown"] },
|
||||||
|
{ id: "disconnect-tab", group: "tabs", scopes: ["main"], ...both("Mod+D") },
|
||||||
|
// ---- window ----
|
||||||
|
{ id: "open-settings", group: "window", scopes: ["main"], ...both("Mod+Comma") },
|
||||||
|
{ id: "connection-manager", group: "window", scopes: ["main"], ...both("Mod+Shift+K") },
|
||||||
|
{ id: "toggle-transfers", group: "window", scopes: ["main"], ...both("Mod+J") },
|
||||||
|
{ id: "install-update", group: "window", scopes: ["main"], ...both() },
|
||||||
|
// ⌘+ is ⇧⌘= on US layouts — both spellings, plus the keypad
|
||||||
|
{ id: "scale-up", group: "window", scopes: ["any"], ...both("Mod+Equal", "Mod+Shift+Equal", "Mod+NumpadAdd") },
|
||||||
|
{ id: "scale-down", group: "window", scopes: ["any"], ...both("Mod+Minus") },
|
||||||
|
{ id: "scale-reset", group: "window", scopes: ["any"], ...both("Mod+0") },
|
||||||
|
{ id: "close-window", group: "window", scopes: ["editor", "manager", "settings"], ...both("Mod+W") },
|
||||||
|
// ---- editor window ----
|
||||||
|
{ id: "save", group: "editor", scopes: ["editor"], ...both("Mod+S") },
|
||||||
|
{ id: "find", group: "editor", scopes: ["editor"], ...both("Mod+F") },
|
||||||
|
{ id: "find-next", group: "editor", scopes: ["editor"], mac: ["Mod+G"], other: ["F3"] },
|
||||||
|
{ id: "find-prev", group: "editor", scopes: ["editor"], mac: ["Mod+Shift+G"], other: ["Shift+F3"] },
|
||||||
|
{ id: "toggle-wrap", group: "editor", scopes: ["editor"], ...both("Alt+Z") },
|
||||||
|
// ---- connection manager ----
|
||||||
|
{ id: "conn-new", group: "connections", scopes: ["manager"], ...both("Mod+N") },
|
||||||
|
{ id: "conn-new-stack", group: "connections", scopes: ["manager"], ...both("Mod+Shift+N") },
|
||||||
|
{ id: "conn-save", group: "connections", scopes: ["manager"], ...both("Mod+S") },
|
||||||
|
{ id: "conn-connect", group: "connections", scopes: ["manager"], ...both("Mod+Enter") },
|
||||||
|
{ id: "conn-delete", group: "connections", scopes: ["manager"], mac: ["Mod+Backspace"], other: ["Delete"] },
|
||||||
|
{ id: "conn-rename-stack", group: "connections", scopes: ["manager"], ...both("F2") },
|
||||||
|
{ id: "conn-import", group: "connections", scopes: ["manager"], ...both("Mod+O") },
|
||||||
|
] as const satisfies readonly BindingDef[];
|
||||||
|
|
||||||
|
export type ShortcutId = (typeof BINDINGS)[number]["id"];
|
||||||
|
|
||||||
|
/** Commands the macOS menu bar carries; their accelerators follow the keymap. */
|
||||||
|
export const NATIVE_MENU_IDS: readonly ShortcutId[] = [
|
||||||
|
"open-settings",
|
||||||
|
"new-tab",
|
||||||
|
"close-tab",
|
||||||
|
"new-folder",
|
||||||
|
"new-file",
|
||||||
|
"edit",
|
||||||
|
"connection-manager",
|
||||||
|
"refresh",
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface Combo {
|
||||||
|
/** A–Z, 0–9, a punctuation event.code (Comma, Equal…) or a key name (Enter, F2…). */
|
||||||
|
key: string;
|
||||||
|
/** ⌘ on macOS, Ctrl elsewhere. */
|
||||||
|
mod: boolean;
|
||||||
|
/** A literal ⌃, macOS only — elsewhere Ctrl IS Mod. */
|
||||||
|
ctrl: boolean;
|
||||||
|
alt: boolean;
|
||||||
|
shift: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The KeyboardEvent fields matching needs (tests build these by hand). */
|
||||||
|
export interface KeyEventLike {
|
||||||
|
key: string;
|
||||||
|
code: string;
|
||||||
|
metaKey: boolean;
|
||||||
|
ctrlKey: boolean;
|
||||||
|
altKey: boolean;
|
||||||
|
shiftKey: boolean;
|
||||||
|
getModifierState?(key: string): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedBinding {
|
||||||
|
id: ShortcutId;
|
||||||
|
group: Group;
|
||||||
|
scopes: readonly Scope[];
|
||||||
|
n?: number;
|
||||||
|
/** What fires: the user's override when there is one, else the defaults. */
|
||||||
|
combos: Combo[];
|
||||||
|
defaults: Combo[];
|
||||||
|
/** The user replaced this row's defaults (possibly with nothing). */
|
||||||
|
custom: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LETTER = /^[A-Z]$/;
|
||||||
|
const DIGIT = /^[0-9]$/;
|
||||||
|
const FKEY = /^F([1-9]|1[0-2])$/;
|
||||||
|
/** Punctuation tokens and the character each stands for. Matched by character
|
||||||
|
* first (AZERTY's comma is on the M key) and by physical key second (⇧] makes
|
||||||
|
* "}", the US ⌘+ is ⇧⌘=). */
|
||||||
|
const CHAR_OF: Record<string, string> = {
|
||||||
|
Comma: ",",
|
||||||
|
Period: ".",
|
||||||
|
Slash: "/",
|
||||||
|
Semicolon: ";",
|
||||||
|
Quote: "'",
|
||||||
|
BracketLeft: "[",
|
||||||
|
BracketRight: "]",
|
||||||
|
Backslash: "\\",
|
||||||
|
Backquote: "`",
|
||||||
|
Equal: "=",
|
||||||
|
Minus: "-",
|
||||||
|
};
|
||||||
|
const TOKEN_OF_CHAR: Record<string, string> = Object.fromEntries(
|
||||||
|
Object.entries(CHAR_OF).map(([token, char]) => [char, token]),
|
||||||
|
);
|
||||||
|
/** Physical-only keys: the keypad's + (its − already types "-", which is Minus). */
|
||||||
|
const KEYPAD = new Set(["NumpadAdd"]);
|
||||||
|
/** KeyboardEvent.key names, the same on every layout. */
|
||||||
|
const NAMED = new Set([
|
||||||
|
"Enter",
|
||||||
|
"Escape",
|
||||||
|
"Backspace",
|
||||||
|
"Delete",
|
||||||
|
"Tab",
|
||||||
|
"Space",
|
||||||
|
"ArrowUp",
|
||||||
|
"ArrowDown",
|
||||||
|
"ArrowLeft",
|
||||||
|
"ArrowRight",
|
||||||
|
"Home",
|
||||||
|
"End",
|
||||||
|
"PageUp",
|
||||||
|
"PageDown",
|
||||||
|
"Insert",
|
||||||
|
"ContextMenu",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isToken(key: string): boolean {
|
||||||
|
return (
|
||||||
|
LETTER.test(key) || DIGIT.test(key) || FKEY.test(key) || Object.hasOwn(CHAR_OF, key) || KEYPAD.has(key) || NAMED.has(key)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An event's key token, or null for modifier-only presses and keys the keymap
|
||||||
|
* doesn't cover. Letters follow the layout (AZERTY's A is A); under Alt
|
||||||
|
* (macOS Option turns ⌥Z into Ω) and on non-Latin layouts they fall back to the
|
||||||
|
* physical key. Digits are physical too: AZERTY types "&" on the 1 key.
|
||||||
|
*/
|
||||||
|
export function keyToken(e: Pick<KeyEventLike, "key" | "code" | "altKey">): string | null {
|
||||||
|
const { key, code } = e;
|
||||||
|
const upper = key.length === 1 ? key.toUpperCase() : "";
|
||||||
|
if (!e.altKey && LETTER.test(upper)) return upper;
|
||||||
|
if (/^Key[A-Z]$/.test(code) && (e.altKey || /\p{L}/u.test(key))) return code.slice(3);
|
||||||
|
if (/^Digit[0-9]$/.test(code)) return code.slice(5);
|
||||||
|
if (/^Numpad[0-9]$/.test(code)) return DIGIT.test(key) ? key : NAMED.has(key) ? key : null;
|
||||||
|
if (Object.hasOwn(TOKEN_OF_CHAR, key)) return TOKEN_OF_CHAR[key];
|
||||||
|
if (Object.hasOwn(CHAR_OF, code) || KEYPAD.has(code)) return code;
|
||||||
|
if (key === " " || code === "Space") return "Space";
|
||||||
|
if (FKEY.test(key) || NAMED.has(key)) return key;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventMods(e: KeyEventLike, isMac: boolean) {
|
||||||
|
return isMac
|
||||||
|
? { mod: e.metaKey, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, other: false }
|
||||||
|
: // never part of a binding: the Windows / Super key, and AltGr — which
|
||||||
|
// arrives as Ctrl+Alt while it types "@", "€", "{"…
|
||||||
|
{
|
||||||
|
mod: e.ctrlKey,
|
||||||
|
ctrl: false,
|
||||||
|
alt: e.altKey,
|
||||||
|
shift: e.shiftKey,
|
||||||
|
other: e.metaKey || !!e.getModifierState?.("AltGraph"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exact match: the same key AND exactly the same modifiers. ⌃ is not ⌘ on
|
||||||
|
* macOS (⌃F is Cocoa's forward-one-character), and Ctrl+Tab is not Tab. */
|
||||||
|
export function matchesEvent(combo: Combo, e: KeyEventLike, isMac: boolean): boolean {
|
||||||
|
const m = eventMods(e, isMac);
|
||||||
|
if (m.other || m.mod !== combo.mod || m.ctrl !== combo.ctrl || m.alt !== combo.alt || m.shift !== combo.shift) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return keyToken(e) === combo.key || ((Object.hasOwn(CHAR_OF, combo.key) || KEYPAD.has(combo.key)) && e.code === combo.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The combo a keydown spells (Settings → Keyboard recording), or null. */
|
||||||
|
export function comboFromEvent(e: KeyEventLike, isMac: boolean): Combo | null {
|
||||||
|
const key = keyToken(e);
|
||||||
|
const m = eventMods(e, isMac);
|
||||||
|
if (!key || m.other) return null;
|
||||||
|
return { key, mod: m.mod, ctrl: m.ctrl, alt: m.alt, shift: m.shift };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Mod+Shift+N" → Combo; null for anything malformed. Off macOS a stored
|
||||||
|
* literal Ctrl folds into Mod — on those keyboards they are the same key. */
|
||||||
|
export function parseCombo(text: unknown, isMac: boolean): Combo | null {
|
||||||
|
if (typeof text !== "string" || !text) return null;
|
||||||
|
const parts = text.split("+");
|
||||||
|
let key = parts.pop() ?? "";
|
||||||
|
if (key.length === 1) key = key.toUpperCase();
|
||||||
|
if (!isToken(key)) return null;
|
||||||
|
const combo: Combo = { key, mod: false, ctrl: false, alt: false, shift: false };
|
||||||
|
for (const part of parts) {
|
||||||
|
const name = part.toLowerCase();
|
||||||
|
if (name === "mod") combo.mod = true;
|
||||||
|
else if (name === "ctrl") combo.ctrl = true;
|
||||||
|
else if (name === "alt") combo.alt = true;
|
||||||
|
else if (name === "shift") combo.shift = true;
|
||||||
|
else return null;
|
||||||
|
}
|
||||||
|
if (!isMac && combo.ctrl) {
|
||||||
|
combo.ctrl = false;
|
||||||
|
combo.mod = true;
|
||||||
|
}
|
||||||
|
return combo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeCombo(c: Combo): string {
|
||||||
|
return [c.mod && "Mod", c.ctrl && "Ctrl", c.alt && "Alt", c.shift && "Shift", c.key].filter(Boolean).join("+");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameCombo(a: Combo, b: Combo): boolean {
|
||||||
|
return serializeCombo(a) === serializeCombo(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseList(list: readonly unknown[], isMac: boolean): Combo[] {
|
||||||
|
const out: Combo[] = [];
|
||||||
|
for (const text of list) {
|
||||||
|
const combo = parseCombo(text, isMac);
|
||||||
|
if (combo && !out.some((c) => sameCombo(c, combo))) out.push(combo);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The table with the user's overrides applied — the single place a hand-edited
|
||||||
|
* settings.json is sanitized: unknown ids, non-arrays and malformed combos are
|
||||||
|
* dropped. An override replaces a row's whole list; [] unbinds it.
|
||||||
|
*/
|
||||||
|
export function effectiveKeymap(overrides: unknown, isMac: boolean): ResolvedBinding[] {
|
||||||
|
const table: Record<string, unknown> =
|
||||||
|
overrides && typeof overrides === "object" && !Array.isArray(overrides)
|
||||||
|
? (overrides as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
return BINDINGS.map((b): ResolvedBinding => {
|
||||||
|
const defaults = parseList(isMac ? b.mac : b.other, isMac);
|
||||||
|
const raw = Object.hasOwn(table, b.id) ? table[b.id] : undefined;
|
||||||
|
const custom = Array.isArray(raw);
|
||||||
|
return {
|
||||||
|
id: b.id,
|
||||||
|
group: b.group,
|
||||||
|
scopes: b.scopes,
|
||||||
|
n: "n" in b ? b.n : undefined,
|
||||||
|
defaults,
|
||||||
|
custom,
|
||||||
|
combos: custom ? parseList(raw, isMac) : defaults,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stored shape, coerced to arrays of combo strings, for
|
||||||
|
* SettingsStore.load: a hand-edited settings.json can't break boot. Unknown
|
||||||
|
* ids are kept (a newer build's commands survive a downgrade round trip);
|
||||||
|
* effectiveKeymap ignores them. */
|
||||||
|
export function sanitizeKeybindings(raw: unknown): Record<string, string[]> {
|
||||||
|
const out: Record<string, string[]> = {};
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
|
||||||
|
for (const [id, list] of Object.entries(raw)) {
|
||||||
|
if (Array.isArray(list)) out[id] = list.filter((t): t is string => parseCombo(t, true) !== null);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a binding in `a` and one in `b` can fire on the same keystroke. */
|
||||||
|
export function scopesOverlap(a: readonly Scope[], b: readonly Scope[]): boolean {
|
||||||
|
return a.some((x) =>
|
||||||
|
b.some(
|
||||||
|
(y) =>
|
||||||
|
x === y ||
|
||||||
|
x === "any" ||
|
||||||
|
y === "any" ||
|
||||||
|
(x === "panel" && y === "main") ||
|
||||||
|
(x === "main" && y === "panel"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The binding that already owns `combo` where `scopes` fire, if any. */
|
||||||
|
export function ownerOf(
|
||||||
|
keymap: readonly ResolvedBinding[],
|
||||||
|
combo: Combo,
|
||||||
|
scopes: readonly Scope[],
|
||||||
|
except?: ShortcutId,
|
||||||
|
): ResolvedBinding | undefined {
|
||||||
|
return keymap.find(
|
||||||
|
(b) => b.id !== except && scopesOverlap(b.scopes, scopes) && b.combos.some((c) => sameCombo(c, combo)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every binding that shares a combo with another one it can collide with. */
|
||||||
|
export function conflicts(keymap: readonly ResolvedBinding[]): Map<ShortcutId, ShortcutId[]> {
|
||||||
|
const out = new Map<ShortcutId, ShortcutId[]>();
|
||||||
|
const link = (from: ShortcutId, to: ShortcutId) => out.set(from, [...(out.get(from) ?? []), to]);
|
||||||
|
for (let i = 0; i < keymap.length; i++) {
|
||||||
|
for (let j = i + 1; j < keymap.length; j++) {
|
||||||
|
const a = keymap[i];
|
||||||
|
const b = keymap[j];
|
||||||
|
if (!scopesOverlap(a.scopes, b.scopes)) continue;
|
||||||
|
if (a.combos.some((ca) => b.combos.some((cb) => sameCombo(ca, cb)))) {
|
||||||
|
link(a.id, b.id);
|
||||||
|
link(b.id, a.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keys whose modified forms edit text: ⌘⌫ deletes to line start, ⌥← jumps a word… */
|
||||||
|
const TEXT_EDITING = new Set(["Backspace", "Delete", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End"]);
|
||||||
|
|
||||||
|
/** Whether a combo may fire while focus is in a text field. Plain and
|
||||||
|
* Shift-only keys type there, and so do the text-editing chords (⌘⌫, ⌥←, ⌘A);
|
||||||
|
* other ⌘/Ctrl and Alt chords and F-keys fire — except Ctrl+Alt off macOS,
|
||||||
|
* which is AltGr. */
|
||||||
|
export function firesInTextField(c: Combo, isMac: boolean): boolean {
|
||||||
|
if (FKEY.test(c.key)) return true;
|
||||||
|
if (TEXT_EDITING.has(c.key) || (c.mod && c.key === "A")) return false;
|
||||||
|
if (!isMac && c.mod && c.alt) return false;
|
||||||
|
return c.mod || c.alt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a plain press of the combo types a character — bound in the file
|
||||||
|
* list, that key then no longer starts a type-to-filter query. */
|
||||||
|
export function typesCharacter(c: Combo): boolean {
|
||||||
|
return !c.mod && !c.ctrl && !c.alt && (LETTER.test(c.key) || DIGIT.test(c.key) || Object.hasOwn(CHAR_OF, c.key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Commands a held key may repeat; any other held shortcut fires once
|
||||||
|
* (holding ⌘W must not close every tab). */
|
||||||
|
const REPEATABLE = new Set<ShortcutId>([
|
||||||
|
"cursor-up",
|
||||||
|
"cursor-down",
|
||||||
|
"page-up",
|
||||||
|
"page-down",
|
||||||
|
"toggle-select",
|
||||||
|
"next-tab",
|
||||||
|
"prev-tab",
|
||||||
|
"move-tab-left",
|
||||||
|
"move-tab-right",
|
||||||
|
"scale-up",
|
||||||
|
"scale-down",
|
||||||
|
"find-next",
|
||||||
|
"find-prev",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isRepeatable(id: ShortcutId): boolean {
|
||||||
|
return REPEATABLE.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESERVED = {
|
||||||
|
mac: ["Mod+Q", "Mod+X", "Mod+C", "Mod+V", "Mod+Z", "Mod+Shift+Z", "Mod+H", "Mod+Alt+H", "Mod+M", "Mod+Backquote"],
|
||||||
|
other: ["Mod+X", "Mod+C", "Mod+V", "Mod+Z", "Mod+Y"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Quit, the clipboard / undo keys and macOS's window keys (⌘H ⌘M ⌘`) can't be rebound. */
|
||||||
|
export function isReserved(c: Combo, isMac: boolean): boolean {
|
||||||
|
return parseList(isMac ? RESERVED.mac : RESERVED.other, isMac).some((r) => sameCombo(r, c));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Key names for the non-macOS spelling ("Ctrl+Shift+Del"); i18n supplies
|
||||||
|
* localized ones (German keyboards say Strg and Entf). */
|
||||||
|
export interface KeyNames {
|
||||||
|
ctrl: string;
|
||||||
|
alt: string;
|
||||||
|
shift: string;
|
||||||
|
enter: string;
|
||||||
|
backspace: string;
|
||||||
|
del: string;
|
||||||
|
esc: string;
|
||||||
|
space: string;
|
||||||
|
tab: string;
|
||||||
|
pgup: string;
|
||||||
|
pgdn: string;
|
||||||
|
home: string;
|
||||||
|
end: string;
|
||||||
|
ins: string;
|
||||||
|
menu: string;
|
||||||
|
num: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EN_KEY_NAMES: KeyNames = {
|
||||||
|
ctrl: "Ctrl",
|
||||||
|
alt: "Alt",
|
||||||
|
shift: "Shift",
|
||||||
|
enter: "Enter",
|
||||||
|
backspace: "Backspace",
|
||||||
|
del: "Del",
|
||||||
|
esc: "Esc",
|
||||||
|
space: "Space",
|
||||||
|
tab: "Tab",
|
||||||
|
pgup: "PgUp",
|
||||||
|
pgdn: "PgDn",
|
||||||
|
home: "Home",
|
||||||
|
end: "End",
|
||||||
|
ins: "Ins",
|
||||||
|
menu: "Menu",
|
||||||
|
num: "Num",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ARROWS: Record<string, string> = { ArrowUp: "↑", ArrowDown: "↓", ArrowLeft: "←", ArrowRight: "→" };
|
||||||
|
const MAC_GLYPHS: Record<string, string> = { Enter: "↩", Escape: "⎋", Backspace: "⌫", Delete: "⌦", Tab: "⇥" };
|
||||||
|
|
||||||
|
function keyLabel(key: string, isMac: boolean, names: KeyNames): string {
|
||||||
|
if (Object.hasOwn(CHAR_OF, key)) return CHAR_OF[key];
|
||||||
|
if (Object.hasOwn(ARROWS, key)) return ARROWS[key];
|
||||||
|
if (isMac && Object.hasOwn(MAC_GLYPHS, key)) return MAC_GLYPHS[key];
|
||||||
|
const named: Record<string, string> = {
|
||||||
|
Enter: names.enter,
|
||||||
|
Escape: names.esc,
|
||||||
|
Backspace: names.backspace,
|
||||||
|
Delete: names.del,
|
||||||
|
Tab: names.tab,
|
||||||
|
Space: names.space,
|
||||||
|
Home: names.home,
|
||||||
|
End: names.end,
|
||||||
|
PageUp: names.pgup,
|
||||||
|
PageDown: names.pgdn,
|
||||||
|
Insert: names.ins,
|
||||||
|
ContextMenu: names.menu,
|
||||||
|
NumpadAdd: `${names.num} +`,
|
||||||
|
};
|
||||||
|
return Object.hasOwn(named, key) ? named[key] : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "⇧⌘N" on macOS (Apple's ⌃⌥⇧⌘ order), "Ctrl+Shift+N" elsewhere. */
|
||||||
|
export function formatCombo(c: Combo, isMac: boolean, names: KeyNames = EN_KEY_NAMES): string {
|
||||||
|
const key = keyLabel(c.key, isMac, names);
|
||||||
|
if (isMac) return `${c.ctrl ? "⌃" : ""}${c.alt ? "⌥" : ""}${c.shift ? "⇧" : ""}${c.mod ? "⌘" : ""}${key}`;
|
||||||
|
return [c.mod && names.ctrl, c.alt && names.alt, c.shift && names.shift, key].filter(Boolean).join("+");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACCELERATOR_KEYS: Record<string, string> = {
|
||||||
|
Enter: "return",
|
||||||
|
Escape: "escape",
|
||||||
|
Tab: "tab",
|
||||||
|
Space: "space",
|
||||||
|
ArrowUp: "up",
|
||||||
|
ArrowDown: "down",
|
||||||
|
ArrowLeft: "left",
|
||||||
|
ArrowRight: "right",
|
||||||
|
Home: "home",
|
||||||
|
End: "end",
|
||||||
|
PageUp: "pageup",
|
||||||
|
PageDown: "pagedown",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Electrobun's native menu accelerator ("Command+Shift+n"), or null when the
|
||||||
|
* menu can't express the combo faithfully:
|
||||||
|
* - only ⌘ / ⌃ chords — the native parser turns any combo without a modifier
|
||||||
|
* token into ⌘+key;
|
||||||
|
* - no ⌫ / ⌦ (its "backspace" and "delete" tokens map to characters AppKit
|
||||||
|
* matches unreliably), Insert, the Menu key or the keypad;
|
||||||
|
* - no Shift+punctuation — AppKit matches the shifted character ("}"), not "]".
|
||||||
|
*/
|
||||||
|
export function toAccelerator(c: Combo): string | null {
|
||||||
|
if (!c.mod && !c.ctrl) return null;
|
||||||
|
if (c.shift && Object.hasOwn(CHAR_OF, c.key)) return null;
|
||||||
|
let key: string | undefined;
|
||||||
|
if (LETTER.test(c.key)) key = c.key.toLowerCase();
|
||||||
|
else if (DIGIT.test(c.key)) key = c.key;
|
||||||
|
else if (FKEY.test(c.key)) key = c.key.toLowerCase();
|
||||||
|
else if (Object.hasOwn(CHAR_OF, c.key)) key = c.key === "Minus" ? "minus" : CHAR_OF[c.key];
|
||||||
|
else if (Object.hasOwn(ACCELERATOR_KEYS, c.key)) key = ACCELERATOR_KEYS[c.key];
|
||||||
|
if (!key) return null;
|
||||||
|
return [c.mod && "Command", c.ctrl && "Control", c.alt && "Alt", c.shift && "Shift", key]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("+");
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
AppSettings,
|
AppSettings,
|
||||||
ColumnKey,
|
|
||||||
ConnectionConfig,
|
ConnectionConfig,
|
||||||
ConnectionsFile,
|
ConnectionsFile,
|
||||||
EntryType,
|
EntryType,
|
||||||
@@ -27,9 +26,9 @@ export interface EditorOpenContext {
|
|||||||
// bun.requests = calls the webview makes, handled in src/bun/rpc-handlers.ts
|
// bun.requests = calls the webview makes, handled in src/bun/rpc-handlers.ts
|
||||||
// webview.messages = push events from bun, handled in ui rpc.service.ts
|
// webview.messages = push events from bun, handled in ui rpc.service.ts
|
||||||
//
|
//
|
||||||
// ONE schema serves both window kinds (main + editor): electrobun allows
|
// ONE schema serves every window kind (main, editor, manager, settings):
|
||||||
// partial handler sets, and messages nobody listens to are silently dropped,
|
// electrobun allows partial handler sets, and messages nobody listens to are
|
||||||
// so each side only implements/hears what applies to it.
|
// silently dropped, so each side only implements/hears what applies to it.
|
||||||
export type AppRPC = {
|
export type AppRPC = {
|
||||||
bun: {
|
bun: {
|
||||||
requests: {
|
requests: {
|
||||||
@@ -44,6 +43,9 @@ export type AppRPC = {
|
|||||||
params: { sessionId: string; path: string | null }; // null -> home dir
|
params: { sessionId: string; path: string | null }; // null -> home dir
|
||||||
response: Result<{ path: string; entries: FileEntry[] }>;
|
response: Result<{ path: string; entries: FileEntry[] }>;
|
||||||
};
|
};
|
||||||
|
// Local dirs to fs.watch for auto-refresh (the active tab's local panels);
|
||||||
|
// a change pushes pathRefresh. Remote panels are polled UI-side instead.
|
||||||
|
setWatchedDirs: { params: { dirs: string[] }; response: Result };
|
||||||
mkdir: { params: { sessionId: string; path: string }; response: Result };
|
mkdir: { params: { sessionId: string; path: string }; response: Result };
|
||||||
createFile: { params: { sessionId: string; path: string }; response: Result };
|
createFile: { params: { sessionId: string; path: string }; response: Result };
|
||||||
rename: {
|
rename: {
|
||||||
@@ -123,20 +125,18 @@ export type AppRPC = {
|
|||||||
response: Result<{ names: string[] }>;
|
response: Result<{ names: string[] }>;
|
||||||
};
|
};
|
||||||
clearEditLeftovers: { params: Record<string, never>; response: Result };
|
clearEditLeftovers: { params: Record<string, never>; response: Result };
|
||||||
// context menu + local shell integration
|
// local shell integration
|
||||||
showRowMenu: {
|
/**
|
||||||
params: {
|
* Shell actions the context menu triggers on a LOCAL path. The menu
|
||||||
path: string;
|
* itself is HTML (ui/src/app/core/context-menu.service.ts) — electrobun's native
|
||||||
name: string;
|
* context menu is a no-op on Linux — but these three still need the
|
||||||
type: EntryType;
|
* bun side's Utils.
|
||||||
isLocal: boolean;
|
*/
|
||||||
hasEntry: boolean;
|
shellItemAction: {
|
||||||
};
|
params: { action: "reveal" | "open" | "copyPath"; path: string };
|
||||||
response: Result;
|
response: Result;
|
||||||
};
|
};
|
||||||
moveToTrash: { params: { path: string }; response: Result };
|
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). */
|
/** Native About box (name, Build n, commit, engine versions). */
|
||||||
showAbout: { params: Record<string, never>; response: Result };
|
showAbout: { params: Record<string, never>; response: Result };
|
||||||
/** Windows drive roots for the local panel ("C:\", …); empty elsewhere. */
|
/** Windows drive roots for the local panel ("C:\", …); empty elsewhere. */
|
||||||
@@ -154,6 +154,21 @@ export type AppRPC = {
|
|||||||
managerConnect: { params: { id: string }; response: Result };
|
managerConnect: { params: { id: string }; response: Result };
|
||||||
managerOpenStack: { params: { id: string }; response: Result };
|
managerOpenStack: { params: { id: string }; response: Result };
|
||||||
managerClose: { params: Record<string, never>; response: Result };
|
managerClose: { params: Record<string, never>; response: Result };
|
||||||
|
// settings window
|
||||||
|
/** Main window: open (or focus) the single Settings window. */
|
||||||
|
openSettings: { params: Record<string, never>; response: Result };
|
||||||
|
/** Settings window only: its close-window key (Ctrl+W off macOS, ⌘W). */
|
||||||
|
settingsClose: { params: Record<string, never>; response: Result };
|
||||||
|
// auto-updater (electrobun Updater)
|
||||||
|
/** Main window: re-run the update check on demand (menu item). */
|
||||||
|
checkForUpdate: {
|
||||||
|
params: Record<string, never>;
|
||||||
|
response: Result<{ updateAvailable: boolean; version: string }>;
|
||||||
|
};
|
||||||
|
/** Download the pending update (patch-chain, else full). Progress arrives via updateStatus. */
|
||||||
|
downloadUpdate: { params: Record<string, never>; response: Result };
|
||||||
|
/** Swap in the downloaded update and relaunch — quits the app, so may not resolve. */
|
||||||
|
applyUpdate: { params: Record<string, never>; response: Result };
|
||||||
};
|
};
|
||||||
messages: {
|
messages: {
|
||||||
/** Editor → bun draft mirror; safety net for the non-cancellable native close. */
|
/** Editor → bun draft mirror; safety net for the non-cancellable native close. */
|
||||||
@@ -176,6 +191,16 @@ export type AppRPC = {
|
|||||||
managerState: { open: boolean };
|
managerState: { open: boolean };
|
||||||
/** bun → main: the manager window changed the connection store. */
|
/** bun → main: the manager window changed the connection store. */
|
||||||
connectionsChanged: Record<string, never>;
|
connectionsChanged: Record<string, never>;
|
||||||
|
/**
|
||||||
|
* bun → main: auto-updater lifecycle, drives the tab-bar update pill.
|
||||||
|
* "ready" is not a phase — the UI derives it from downloadUpdate resolving ok.
|
||||||
|
*/
|
||||||
|
updateStatus: {
|
||||||
|
phase: "available" | "downloading" | "error";
|
||||||
|
version?: string;
|
||||||
|
progress?: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -62,29 +62,25 @@ export interface ConnectionsFile {
|
|||||||
connections: SavedConnection[];
|
connections: SavedConnection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AccentTone =
|
/** UI languages the app ships translations for. */
|
||||||
| "graphite"
|
export type SupportedLocale = "en" | "es" | "eu" | "fr" | "de" | "zh" | "ja";
|
||||||
| "blue"
|
|
||||||
| "indigo"
|
|
||||||
| "purple"
|
|
||||||
| "rose"
|
|
||||||
| "orange"
|
|
||||||
| "green"
|
|
||||||
| "teal";
|
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
|
/** UI language; "system" resolves to the OS locale (fallback English). */
|
||||||
|
locale: "system" | SupportedLocale;
|
||||||
theme: "system" | "light" | "dark";
|
theme: "system" | "light" | "dark";
|
||||||
/** Dark appearance: grey ("dark") or near-black AMOLED ("darker"). */
|
/** Dark appearance: grey ("dark") or near-black AMOLED ("darker"). */
|
||||||
darkTone: "dark" | "darker";
|
darkTone: "dark" | "darker";
|
||||||
/** Accent tone for chrome and selection tint; graphite = neutral zinc. */
|
|
||||||
accent: AccentTone;
|
|
||||||
/** File-list icons: plain neutral glyphs, or per-category glyphs and colors. */
|
|
||||||
iconColors: "plain" | "multi";
|
|
||||||
/** Visible optional file-list columns (both panels; Name always shows). */
|
/** Visible optional file-list columns (both panels; Name always shows). */
|
||||||
columns: ColumnKey[];
|
columns: ColumnKey[];
|
||||||
speedUnit: "MBps" | "Mbps";
|
speedUnit: "MBps" | "Mbps";
|
||||||
/** Size of the panel interface: file list, path bar, header controls. */
|
/**
|
||||||
textSize: "compact" | "default" | "large";
|
* Interface scale factor, 0.5–4 in 0.1 steps (shown as 50%–400%). Works by
|
||||||
|
* setting the root font-size — every size in the UI is rem, so one lever
|
||||||
|
* scales the whole UI. Replaced the 3-tier `textSize`
|
||||||
|
* (compact|default|large); SettingsStore.load migrates old files.
|
||||||
|
*/
|
||||||
|
uiScale: number;
|
||||||
/** Run transfer jobs one at a time, or several in parallel. */
|
/** Run transfer jobs one at a time, or several in parallel. */
|
||||||
transferMode: "single" | "multi";
|
transferMode: "single" | "multi";
|
||||||
/** How many jobs run at once when transferMode is "multi". */
|
/** How many jobs run at once when transferMode is "multi". */
|
||||||
@@ -95,23 +91,54 @@ export interface AppSettings {
|
|||||||
editor: "builtin" | "custom";
|
editor: "builtin" | "custom";
|
||||||
/** Full path to the external editor .app; empty = OS default application. */
|
/** Full path to the external editor .app; empty = OS default application. */
|
||||||
editorApp: string;
|
editorApp: string;
|
||||||
|
/**
|
||||||
|
* Keyboard remaps from Settings → Keyboard: command id → combo strings, only
|
||||||
|
* for rows that differ from the defaults ([] unbinds). Format and sanitizing
|
||||||
|
* live in keymap.ts — a hand-edited entry it can't parse is simply ignored.
|
||||||
|
*/
|
||||||
|
keybindings: Record<string, string[]>;
|
||||||
|
/** Enter / double-click on a LOCAL file opens it in its default app. */
|
||||||
|
openFilesOnEnter: boolean;
|
||||||
|
// Auto-refresh the active tab's panels (local watched, remote polled). Always
|
||||||
|
// on for now; uncomment (+ the settings.service and tab-bar breadcrumbs, and
|
||||||
|
// gate the poll/watch in panels.service) to expose a toggle later.
|
||||||
|
// autoRefresh: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: AppSettings = {
|
export const DEFAULT_SETTINGS: AppSettings = {
|
||||||
|
locale: "system",
|
||||||
theme: "system",
|
theme: "system",
|
||||||
darkTone: "dark",
|
darkTone: "dark",
|
||||||
accent: "blue",
|
|
||||||
iconColors: "multi",
|
|
||||||
columns: ["size", "mtime"],
|
columns: ["size", "mtime"],
|
||||||
speedUnit: "MBps",
|
speedUnit: "MBps",
|
||||||
textSize: "default",
|
uiScale: 1,
|
||||||
transferMode: "single",
|
transferMode: "single",
|
||||||
transferConcurrency: 3,
|
transferConcurrency: 3,
|
||||||
autoClearCompleted: true,
|
autoClearCompleted: true,
|
||||||
editor: "builtin",
|
editor: "builtin",
|
||||||
editorApp: "",
|
editorApp: "",
|
||||||
|
keybindings: {},
|
||||||
|
openFilesOnEnter: false,
|
||||||
|
// autoRefresh: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const UI_SCALE_MIN = 0.5;
|
||||||
|
export const UI_SCALE_MAX = 4;
|
||||||
|
export const UI_SCALE_STEP = 0.1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamp + snap a scale factor to the legal range and step grid. Shared by the
|
||||||
|
* store (a hand-edited settings.json must not be able to render the UI
|
||||||
|
* unusable), the settings service and the stepper — one definition of "valid",
|
||||||
|
* same as effectiveConcurrency below.
|
||||||
|
*/
|
||||||
|
export function clampUiScale(value: number): number {
|
||||||
|
if (!Number.isFinite(value)) return 1;
|
||||||
|
const clamped = Math.min(UI_SCALE_MAX, Math.max(UI_SCALE_MIN, value));
|
||||||
|
// snap to the step grid; toFixed strips float noise (0.7000000000000001 -> 0.7)
|
||||||
|
return Number((Math.round(clamped / UI_SCALE_STEP) * UI_SCALE_STEP).toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
export function effectiveConcurrency(settings: AppSettings): number {
|
export function effectiveConcurrency(settings: AppSettings): number {
|
||||||
if (settings.transferMode === "single") return 1;
|
if (settings.transferMode === "single") return 1;
|
||||||
return Math.max(1, Math.min(8, Math.round(settings.transferConcurrency) || 1));
|
return Math.max(1, Math.min(8, Math.round(settings.transferConcurrency) || 1));
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
// electrobun ships raw .ts sources whose WGPU adapter imports "three" without
|
|
||||||
// bundled types; we never use that path, so give tsc an any-typed stand-in.
|
|
||||||
declare module "three";
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"extends": "./.hutch/devkit/tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"useDefineForClassFields": false,
|
"useDefineForClassFields": false,
|
||||||
@@ -16,6 +17,6 @@
|
|||||||
"emitDecoratorMetadata": false,
|
"emitDecoratorMetadata": false,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src", "electrobun.config.ts"],
|
||||||
"exclude": ["node_modules", "dist", "build", "../../package/dist"]
|
"exclude": ["node_modules", "dist", "build", "../../package/dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"cli": {
|
"cli": {
|
||||||
"packageManager": "bun"
|
"packageManager": "bun",
|
||||||
|
"analytics": false
|
||||||
},
|
},
|
||||||
"newProjectRoot": "projects",
|
"newProjectRoot": "projects",
|
||||||
"projects": {
|
"projects": {
|
||||||
@@ -25,7 +26,9 @@
|
|||||||
"input": "public"
|
"input": "public"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": ["src/styles.css"]
|
"styles": [
|
||||||
|
"src/styles.css"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"@angular/core": "^22.0.0",
|
"@angular/core": "^22.0.0",
|
||||||
"@angular/platform-browser": "^22.0.0",
|
"@angular/platform-browser": "^22.0.0",
|
||||||
"@ng-icons/core": "^34.0.0",
|
"@ng-icons/core": "^34.0.0",
|
||||||
"@ng-icons/mynaui": "^34.0.0",
|
"@ng-icons/tdesign-icons": "^34.0.0",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
|
||||||
import { NgIcon } from '@ng-icons/core';
|
|
||||||
import { BRANDING } from '@shared/branding';
|
|
||||||
import type { ColumnKey } from '@shared/types';
|
|
||||||
import { ConnectionsService } from './connections.service';
|
|
||||||
import { ChmodDialog } from './dialogs/chmod-dialog';
|
|
||||||
import { ConfirmDialog } from './dialogs/confirm-dialog';
|
|
||||||
import { OverwriteDialog } from './dialogs/overwrite-dialog';
|
|
||||||
import { PromptDialog } from './dialogs/prompt-dialog';
|
|
||||||
import { DialogsService } from './dialogs.service';
|
|
||||||
import { FileActionsService } from './file-actions.service';
|
|
||||||
import { FilePanel } from './file-panel/file-panel';
|
|
||||||
import { PanelsService } from './panels.service';
|
|
||||||
import { blockNativeContextMenu } from './platform';
|
|
||||||
import { RpcService } from './rpc.service';
|
|
||||||
import { SettingsService } from './settings.service';
|
|
||||||
import { StatusBar } from './status-bar/status-bar';
|
|
||||||
import { TabBar } from './tab-bar/tab-bar';
|
|
||||||
import { ToastService } from './toast.service';
|
|
||||||
import { Toasts } from './toasts/toasts';
|
|
||||||
import { TransferBar } from './transfer-bar/transfer-bar';
|
|
||||||
import { TransfersService } from './transfers.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-root',
|
|
||||||
imports: [
|
|
||||||
NgIcon,
|
|
||||||
FilePanel,
|
|
||||||
TabBar,
|
|
||||||
StatusBar,
|
|
||||||
PromptDialog,
|
|
||||||
ConfirmDialog,
|
|
||||||
ChmodDialog,
|
|
||||||
OverwriteDialog,
|
|
||||||
Toasts,
|
|
||||||
TransferBar,
|
|
||||||
],
|
|
||||||
templateUrl: './app.html',
|
|
||||||
host: {
|
|
||||||
'(window:keydown)': 'onWindowKeydown($event)',
|
|
||||||
'(window:contextmenu)': 'blockContextMenu($event)',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export class App {
|
|
||||||
readonly rpc = inject(RpcService);
|
|
||||||
readonly toast = inject(ToastService);
|
|
||||||
readonly dialogs = inject(DialogsService);
|
|
||||||
readonly panels = inject(PanelsService);
|
|
||||||
readonly actions = inject(FileActionsService);
|
|
||||||
readonly connections = inject(ConnectionsService);
|
|
||||||
private readonly transfers = inject(TransfersService);
|
|
||||||
private readonly settings = inject(SettingsService);
|
|
||||||
|
|
||||||
/** The connection-manager window is open — this window is modal-blocked. */
|
|
||||||
readonly managerOpen = signal(false);
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
document.title = BRANDING.appName;
|
|
||||||
if (this.rpc.available) {
|
|
||||||
this.rpc.onMenuAction((action) => void this.handleMenuAction(action));
|
|
||||||
this.rpc.onManagerState((open) => this.managerOpen.set(open));
|
|
||||||
void this.promptForEditLeftovers();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
focusManager(): void {
|
|
||||||
void this.rpc.request.openConnectionManager({});
|
|
||||||
}
|
|
||||||
|
|
||||||
blockContextMenu(event: Event): void {
|
|
||||||
blockNativeContextMenu(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Temp files from external edits in a previous run (crash / quit mid-edit). */
|
|
||||||
private async promptForEditLeftovers(): Promise<void> {
|
|
||||||
const res = await this.rpc.request.getEditLeftovers({});
|
|
||||||
if (!res.ok || !res.names.length) return;
|
|
||||||
const shown = res.names.slice(0, 5).join(', ');
|
|
||||||
const more = res.names.length > 5 ? `, +${res.names.length - 5} more` : '';
|
|
||||||
this.dialogs.confirm.set({
|
|
||||||
title: 'Leftover edited files',
|
|
||||||
body: `A previous session left ${res.names.length} temporary edited file(s) behind (${shown}${more}). Clear them?`,
|
|
||||||
confirmLabel: 'Clear',
|
|
||||||
destructive: true,
|
|
||||||
onConfirm: () => void this.rpc.request.clearEditLeftovers({}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A connection by id; reloads once if unseen (a manager-window save can
|
|
||||||
* race the connectionsChanged push). */
|
|
||||||
private async findConnection(id: string) {
|
|
||||||
const lookup = () => this.connections.connections().find((c) => c.id === id);
|
|
||||||
if (!lookup()) await this.connections.reload();
|
|
||||||
return lookup();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Native application-menu / manager-window actions forwarded from bun. */
|
|
||||||
private async handleMenuAction(action: string): Promise<void> {
|
|
||||||
if (action.startsWith('ctx-')) {
|
|
||||||
this.handleContextAction(action);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (action.startsWith('connect:')) {
|
|
||||||
const connection = await this.findConnection(action.slice('connect:'.length));
|
|
||||||
if (connection) {
|
|
||||||
// remote goes right, local stays left — the commander convention
|
|
||||||
void this.panels.connectPanel('right', connection, connection.name, connection.localDir);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (action.startsWith('col:')) {
|
|
||||||
this.settings.toggleColumn(action.slice('col:'.length) as ColumnKey);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (action.startsWith('open-stack:')) {
|
|
||||||
const id = action.slice('open-stack:'.length);
|
|
||||||
let stackConnections = this.connections.connections().filter((c) => c.stackId === id);
|
|
||||||
if (!stackConnections.length) {
|
|
||||||
await this.connections.reload();
|
|
||||||
stackConnections = this.connections.connections().filter((c) => c.stackId === id);
|
|
||||||
}
|
|
||||||
if (stackConnections.length) this.panels.openStack(stackConnections);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
switch (action) {
|
|
||||||
case 'new-tab':
|
|
||||||
this.panels.newWorkspace();
|
|
||||||
break;
|
|
||||||
case 'close-tab':
|
|
||||||
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
|
|
||||||
break;
|
|
||||||
case 'new-folder':
|
|
||||||
this.actions.mkdir(this.panels.active());
|
|
||||||
break;
|
|
||||||
case 'new-file':
|
|
||||||
this.actions.createFile(this.panels.active());
|
|
||||||
break;
|
|
||||||
case 'edit-file':
|
|
||||||
void this.actions.edit(this.panels.active());
|
|
||||||
break;
|
|
||||||
case 'refresh-active':
|
|
||||||
void this.panels.active().refresh();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tab (workspace) shortcuts — panel-level keys live on the panels themselves. */
|
|
||||||
onWindowKeydown(event: KeyboardEvent): void {
|
|
||||||
if (this.managerOpen()) return; // modal-blocked behind the manager
|
|
||||||
const mod = event.metaKey || event.ctrlKey;
|
|
||||||
if (mod && event.key.toLowerCase() === 't') {
|
|
||||||
event.preventDefault();
|
|
||||||
this.panels.newWorkspace();
|
|
||||||
} else if (mod && event.key.toLowerCase() === 'w') {
|
|
||||||
event.preventDefault();
|
|
||||||
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
|
|
||||||
} else if (event.ctrlKey && event.key === 'Tab') {
|
|
||||||
event.preventDefault();
|
|
||||||
this.panels.cycleWorkspace(event.shiftKey ? -1 : 1);
|
|
||||||
} else if (mod && !event.shiftKey && !event.altKey) {
|
|
||||||
// ⌘1–⌘8 jump to that tab, ⌘9 to the last (Ctrl+digit elsewhere), like
|
|
||||||
// Firefox. Matched on event.code so the digit row works on any layout.
|
|
||||||
const digit = /^(?:Digit|Numpad)([1-9])$/.exec(event.code)?.[1];
|
|
||||||
if (!digit) return;
|
|
||||||
event.preventDefault();
|
|
||||||
this.panels.goToWorkspace(digit === '9' ? -1 : Number(digit) - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Context-menu actions apply to the row/panel remembered at right-click time. */
|
|
||||||
private handleContextAction(action: string): void {
|
|
||||||
const target = this.actions.menuTarget;
|
|
||||||
if (!target) return;
|
|
||||||
const { panel, side, entry } = target;
|
|
||||||
switch (action) {
|
|
||||||
case 'ctx-open':
|
|
||||||
if (entry) panel.open(entry);
|
|
||||||
break;
|
|
||||||
case 'ctx-edit':
|
|
||||||
void this.actions.edit(panel);
|
|
||||||
break;
|
|
||||||
case 'ctx-copy':
|
|
||||||
void this.transfers.copyToOtherPanel(side);
|
|
||||||
break;
|
|
||||||
case 'ctx-rename':
|
|
||||||
this.actions.rename(panel);
|
|
||||||
break;
|
|
||||||
case 'ctx-chmod':
|
|
||||||
this.actions.chmod(panel);
|
|
||||||
break;
|
|
||||||
case 'ctx-delete':
|
|
||||||
this.actions.delete(panel);
|
|
||||||
break;
|
|
||||||
case 'ctx-trash':
|
|
||||||
if (entry) void this.actions.trash(panel, entry);
|
|
||||||
break;
|
|
||||||
case 'ctx-new-folder':
|
|
||||||
this.actions.mkdir(panel);
|
|
||||||
break;
|
|
||||||
case 'ctx-new-file':
|
|
||||||
this.actions.createFile(panel);
|
|
||||||
break;
|
|
||||||
case 'ctx-refresh':
|
|
||||||
void panel.refresh();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
import { Component, computed, inject, signal } from '@angular/core';
|
|
||||||
import { NgIcon } from '@ng-icons/core';
|
|
||||||
import type { SavedConnection, Stack } from '@shared/types';
|
|
||||||
import { ConnectionsService } from '../connections.service';
|
|
||||||
import { ConfirmDialog } from '../dialogs/confirm-dialog';
|
|
||||||
import { PromptDialog } from '../dialogs/prompt-dialog';
|
|
||||||
import { DialogsService } from '../dialogs.service';
|
|
||||||
import { blockNativeContextMenu, IS_MAC_CHROME } from '../platform';
|
|
||||||
import { RpcService } from '../rpc.service';
|
|
||||||
import { SettingsService } from '../settings.service';
|
|
||||||
import { Toasts } from '../toasts/toasts';
|
|
||||||
|
|
||||||
interface Draft {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
stackId: string | null;
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
username: string;
|
|
||||||
authKind: 'password' | 'key';
|
|
||||||
password: string;
|
|
||||||
privateKeyPath: string;
|
|
||||||
passphrase: string;
|
|
||||||
initialPath: string;
|
|
||||||
localDir: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toDraft(c: SavedConnection): Draft {
|
|
||||||
return {
|
|
||||||
id: c.id,
|
|
||||||
name: c.name,
|
|
||||||
stackId: c.stackId,
|
|
||||||
host: c.host,
|
|
||||||
port: c.port,
|
|
||||||
username: c.username,
|
|
||||||
// agent auth only exists on ~/.ssh/config hosts, which never reach this form
|
|
||||||
authKind: c.auth.kind === 'password' ? 'password' : 'key',
|
|
||||||
password: c.auth.kind === 'password' ? c.auth.password : '',
|
|
||||||
privateKeyPath: c.auth.kind === 'key' ? c.auth.privateKeyPath : '',
|
|
||||||
passphrase: c.auth.kind === 'key' ? (c.auth.passphrase ?? '') : '',
|
|
||||||
initialPath: c.initialPath ?? '',
|
|
||||||
localDir: c.localDir ?? '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function fromDraft(d: Draft): SavedConnection {
|
|
||||||
return {
|
|
||||||
id: d.id,
|
|
||||||
name: d.name.trim() || `${d.username}@${d.host}`,
|
|
||||||
stackId: d.stackId,
|
|
||||||
host: d.host.trim(),
|
|
||||||
port: d.port || 22,
|
|
||||||
username: d.username.trim(),
|
|
||||||
auth:
|
|
||||||
d.authKind === 'password'
|
|
||||||
? { kind: 'password', password: d.password }
|
|
||||||
: {
|
|
||||||
kind: 'key',
|
|
||||||
privateKeyPath: d.privateKeyPath.trim(),
|
|
||||||
...(d.passphrase ? { passphrase: d.passphrase } : {}),
|
|
||||||
},
|
|
||||||
initialPath: d.initialPath.trim() || null,
|
|
||||||
localDir: d.localDir.trim() || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Root component of the connection-manager window (__APP_MODE === 'manager').
|
|
||||||
* Modal to the main window (enforced bun-side); connect/open-stack requests
|
|
||||||
* are executed by the MAIN window — this window never touches panels.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'app-root',
|
|
||||||
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts],
|
|
||||||
templateUrl: './connection-manager.html',
|
|
||||||
host: {
|
|
||||||
'(window:keydown.escape)': 'onEscape()',
|
|
||||||
'(window:contextmenu)': 'blockContextMenu($event)',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export class ConnectionManager {
|
|
||||||
readonly store = inject(ConnectionsService);
|
|
||||||
readonly dialogs = inject(DialogsService);
|
|
||||||
readonly isMacChrome = IS_MAC_CHROME;
|
|
||||||
private readonly rpc = inject(RpcService);
|
|
||||||
// injected for its side effects: fetches settings and applies theme/accent/
|
|
||||||
// text-size data attributes to this window's document root
|
|
||||||
private readonly settings = inject(SettingsService);
|
|
||||||
|
|
||||||
readonly draft = signal<Draft | null>(null);
|
|
||||||
readonly busy = signal(false);
|
|
||||||
readonly importing = signal(false);
|
|
||||||
/** The More… section (local directory) in the edit form. */
|
|
||||||
readonly moreOpen = signal(false);
|
|
||||||
/** Connection id being dragged between stacks. */
|
|
||||||
readonly draggedId = signal<string | null>(null);
|
|
||||||
/** Highlighted drop target: a stack id, 'loose', or null. */
|
|
||||||
readonly dropTarget = signal<string | null>(null);
|
|
||||||
|
|
||||||
readonly valid = computed(() => {
|
|
||||||
const d = this.draft();
|
|
||||||
if (!d) return false;
|
|
||||||
if (!d.host.trim() || !d.username.trim()) return false;
|
|
||||||
if (d.authKind === 'key' && !d.privateKeyPath.trim()) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
patch<K extends keyof Draft>(key: K, value: Draft[K]): void {
|
|
||||||
this.draft.update((d) => (d ? { ...d, [key]: value } : d));
|
|
||||||
}
|
|
||||||
|
|
||||||
newConnection(): void {
|
|
||||||
this.moreOpen.set(false);
|
|
||||||
this.draft.set({
|
|
||||||
id: '',
|
|
||||||
name: '',
|
|
||||||
stackId: null,
|
|
||||||
host: '',
|
|
||||||
port: 22,
|
|
||||||
username: 'root',
|
|
||||||
authKind: 'password',
|
|
||||||
password: '',
|
|
||||||
privateKeyPath: '',
|
|
||||||
passphrase: '',
|
|
||||||
initialPath: '',
|
|
||||||
localDir: '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
edit(connection: SavedConnection): void {
|
|
||||||
this.draft.set(toDraft(connection));
|
|
||||||
this.moreOpen.set(!!connection.localDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
close(): void {
|
|
||||||
void this.rpc.request.managerClose({});
|
|
||||||
}
|
|
||||||
|
|
||||||
onEscape(): void {
|
|
||||||
// in-window dialogs (confirm/prompt) handle their own Escape first
|
|
||||||
if (!this.dialogs.anyOpen) this.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
blockContextMenu(event: Event): void {
|
|
||||||
blockNativeContextMenu(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
async save(): Promise<string | null> {
|
|
||||||
const d = this.draft();
|
|
||||||
if (!d || !this.valid()) return null;
|
|
||||||
const id = await this.store.upsert(fromDraft(d));
|
|
||||||
if (id) this.patch('id', id);
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveAndConnect(): Promise<void> {
|
|
||||||
if (!this.draft()) return;
|
|
||||||
this.busy.set(true);
|
|
||||||
try {
|
|
||||||
const id = await this.save();
|
|
||||||
if (!id) return;
|
|
||||||
// the main window connects (and this window closes); a failed connect
|
|
||||||
// surfaces as an error there
|
|
||||||
await this.rpc.request.managerConnect({ id });
|
|
||||||
} finally {
|
|
||||||
this.busy.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteCurrent(): void {
|
|
||||||
const d = this.draft();
|
|
||||||
if (!d?.id) {
|
|
||||||
this.draft.set(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.dialogs.confirm.set({
|
|
||||||
title: 'Delete connection',
|
|
||||||
body: `Delete "${d.name || d.host}"?`,
|
|
||||||
confirmLabel: 'Delete',
|
|
||||||
destructive: true,
|
|
||||||
onConfirm: async () => {
|
|
||||||
await this.store.remove(d.id);
|
|
||||||
this.draft.set(null);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async pickKeyFile(): Promise<void> {
|
|
||||||
const res = await this.rpc.request.pickKeyFile({});
|
|
||||||
if (res.ok && res.path) this.patch('privateKeyPath', res.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
async importWinScp(): Promise<void> {
|
|
||||||
this.importing.set(true);
|
|
||||||
try {
|
|
||||||
await this.store.importWinScp();
|
|
||||||
} finally {
|
|
||||||
this.importing.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- stacks ----
|
|
||||||
|
|
||||||
openStack(stackId: string): void {
|
|
||||||
// opened as tabs in the main window; bun closes this window after
|
|
||||||
void this.rpc.request.managerOpenStack({ id: stackId });
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleStack(stack: Stack): void {
|
|
||||||
void this.store.upsertStack({ ...stack, collapsed: !stack.collapsed });
|
|
||||||
}
|
|
||||||
|
|
||||||
newStack(): void {
|
|
||||||
this.dialogs.prompt.set({
|
|
||||||
title: 'New stack',
|
|
||||||
placeholder: 'stack name',
|
|
||||||
confirmLabel: 'Create',
|
|
||||||
onConfirm: (name) => void this.store.upsertStack({ id: '', name }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
renameStack(stack: Stack): void {
|
|
||||||
this.dialogs.prompt.set({
|
|
||||||
title: 'Rename stack',
|
|
||||||
initial: stack.name,
|
|
||||||
confirmLabel: 'Rename',
|
|
||||||
onConfirm: (name) => void this.store.upsertStack({ ...stack, name }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteStack(stack: Stack): void {
|
|
||||||
this.dialogs.confirm.set({
|
|
||||||
title: 'Delete stack',
|
|
||||||
body: `Delete stack "${stack.name}"? Its connections stay, ungrouped.`,
|
|
||||||
confirmLabel: 'Delete',
|
|
||||||
destructive: true,
|
|
||||||
onConfirm: () => void this.store.removeStack(stack.id),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- drag & drop (move a connection between stacks / loose) ----
|
|
||||||
|
|
||||||
onDragStart(event: DragEvent, connection: SavedConnection): void {
|
|
||||||
this.draggedId.set(connection.id);
|
|
||||||
event.dataTransfer?.setData('text/plain', connection.id);
|
|
||||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
|
||||||
}
|
|
||||||
|
|
||||||
onDragOver(event: DragEvent, target: string): void {
|
|
||||||
if (!this.draggedId()) return;
|
|
||||||
event.preventDefault();
|
|
||||||
this.dropTarget.set(target);
|
|
||||||
}
|
|
||||||
|
|
||||||
onDrop(event: DragEvent, stackId: string | null): void {
|
|
||||||
event.preventDefault();
|
|
||||||
const id = this.draggedId();
|
|
||||||
this.draggedId.set(null);
|
|
||||||
this.dropTarget.set(null);
|
|
||||||
const connection = this.store.connections().find((c) => c.id === id);
|
|
||||||
if (!connection || connection.stackId === stackId) return;
|
|
||||||
void this.store.upsert({ ...connection, stackId });
|
|
||||||
}
|
|
||||||
|
|
||||||
onDragEnd(): void {
|
|
||||||
this.draggedId.set(null);
|
|
||||||
this.dropTarget.set(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
import type { SavedConnection, SshConfigHost, Stack } from '@shared/types';
|
import type { SavedConnection, SshConfigHost, Stack } from '@shared/types';
|
||||||
import { RpcService } from './rpc.service';
|
import { RpcService } from './rpc.service';
|
||||||
|
import { I18nService } from './i18n.service';
|
||||||
import { ToastService } from './toast.service';
|
import { ToastService } from './toast.service';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ConnectionsService {
|
export class ConnectionsService {
|
||||||
|
private readonly i18n = inject(I18nService);
|
||||||
private readonly rpc = inject(RpcService);
|
private readonly rpc = inject(RpcService);
|
||||||
private readonly toast = inject(ToastService);
|
private readonly toast = inject(ToastService);
|
||||||
|
|
||||||
@@ -86,11 +88,10 @@ export class ConnectionsService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (res.connections || res.stacks || res.skipped) {
|
if (res.connections || res.stacks || res.skipped) {
|
||||||
const skipped = res.skipped ? ` (${res.skipped} skipped)` : '';
|
const skipped = res.skipped ? this.i18n.t('toasts.importedSkipped', { skipped: res.skipped }) : '';
|
||||||
|
const stacks = res.stacks ? this.i18n.t('toasts.importedIntoStacks', { count: res.stacks }) : '';
|
||||||
this.toast.success(
|
this.toast.success(
|
||||||
`Imported ${res.connections} connection${res.connections === 1 ? '' : 's'}` +
|
this.i18n.t('toasts.imported', { count: res.connections }) + stacks + skipped,
|
||||||
(res.stacks ? ` into ${res.stacks} new stack${res.stacks === 1 ? '' : 's'}` : '') +
|
|
||||||
skipped,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await this.reload();
|
await this.reload();
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
|
import type { ColumnKey, EntryType } from '@shared/types';
|
||||||
|
import type { FileCommand } from './file-actions.service';
|
||||||
|
import type { Icon14 } from './icons';
|
||||||
|
import { I18nService } from './i18n.service';
|
||||||
|
import { stepIndex } from './menu-nav';
|
||||||
|
import type { Side } from './panels.service';
|
||||||
|
import { IS_MAC, IS_WINDOWS } from './platform';
|
||||||
|
import { RpcService } from './rpc.service';
|
||||||
|
import type { ShortcutId } from './shortcuts.service';
|
||||||
|
|
||||||
|
/** Optional list columns, in display order (Name is fixed). */
|
||||||
|
export const COLUMN_KEYS: ColumnKey[] = ['size', 'mtime', 'kind', 'rights', 'owner', 'group'];
|
||||||
|
|
||||||
|
export interface MenuItem {
|
||||||
|
/** separator: a rule; header: a non-pickable section title (the source picker). */
|
||||||
|
type?: 'separator' | 'header';
|
||||||
|
label?: string;
|
||||||
|
/** Handed to the request's onPick, else emitted to the action dispatcher
|
||||||
|
* (`col:*` / `tab:*`). */
|
||||||
|
action?: string;
|
||||||
|
checked?: boolean;
|
||||||
|
tooltip?: string;
|
||||||
|
/** The keymap command the item runs: its first key shows as the soft hint,
|
||||||
|
* and pressing that key while the menu is open picks the item. */
|
||||||
|
shortcut?: ShortcutId;
|
||||||
|
/** A 14px icon (core/icons.ts), drawn in the check column. */
|
||||||
|
icon?: Icon14;
|
||||||
|
/** Greyed and unclickable. Menus disable rather than hide, so the set of
|
||||||
|
* commands stays in the same place from one right-click to the next. */
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anything with a viewport point: a MouseEvent, or a row's rect for a
|
||||||
|
* keyboard-opened menu. */
|
||||||
|
export interface MenuPoint {
|
||||||
|
clientX: number;
|
||||||
|
clientY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuRequest {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
items: MenuItem[];
|
||||||
|
/** Highlighted on open: the first item when the keyboard opened the menu. */
|
||||||
|
initialIndex: number;
|
||||||
|
/** Where a pick goes instead of the shared dispatcher (App.handleMenuAction). */
|
||||||
|
onPick?: (action: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabMenuContext {
|
||||||
|
/** The right-clicked tab (NOT necessarily the active one). */
|
||||||
|
id: number;
|
||||||
|
/** Position in the strip, for "close to the right". */
|
||||||
|
index: number;
|
||||||
|
tabCount: number;
|
||||||
|
/** Any pane in this tab holds a live remote session. */
|
||||||
|
isRemote: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RowMenuContext {
|
||||||
|
side: Side;
|
||||||
|
name: string;
|
||||||
|
type: EntryType;
|
||||||
|
isLocal: boolean;
|
||||||
|
hasEntry: boolean; // false = right-click on empty panel space
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Items the highlight can land on. */
|
||||||
|
export const isSelectable = (item: MenuItem): boolean => !item.type && !item.disabled;
|
||||||
|
|
||||||
|
// PORTABILITY: shell-integration wording per OS (resolved at build time for locale)
|
||||||
|
const revealKey = (): string =>
|
||||||
|
IS_MAC ? 'ctx.revealFinder' : IS_WINDOWS ? 'ctx.revealExplorer' : 'ctx.revealFileManager';
|
||||||
|
const trashKey = (): string => (IS_WINDOWS ? 'ctx.moveToRecycleBin' : 'ctx.moveToTrash');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds and opens the HTML context menus. This used to be a native
|
||||||
|
* electrobun ContextMenu on the bun side, but `showContextMenu` is a documented
|
||||||
|
* no-op on Linux (electrobun's own .hutch/devkit/api/sdks/main/proc/linux.md),
|
||||||
|
* which left the whole right-click surface dead there. The menu is plain HTML
|
||||||
|
* now — one code path on every platform.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ContextMenuService {
|
||||||
|
private readonly i18n = inject(I18nService);
|
||||||
|
private readonly rpc = inject(RpcService);
|
||||||
|
|
||||||
|
readonly request = signal<MenuRequest | null>(null);
|
||||||
|
|
||||||
|
open(
|
||||||
|
point: MenuPoint,
|
||||||
|
items: MenuItem[],
|
||||||
|
opts: { keyboard?: boolean; onPick?: (action: string) => void } = {},
|
||||||
|
): void {
|
||||||
|
const initialIndex = opts.keyboard ? stepIndex(items.map(isSelectable), -1, 1) : -1;
|
||||||
|
this.request.set({ x: point.clientX, y: point.clientY, items, initialIndex, onPick: opts.onPick });
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.request.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pick without its own handler goes through the dispatcher the app
|
||||||
|
* menubar uses. */
|
||||||
|
emit(action: string): void {
|
||||||
|
this.rpc.emitMenuAction(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row / empty-space menu for a file panel. Actions are FileCommand ids,
|
||||||
|
* picked through FileActionsService.run. Icons only on the everyday verbs —
|
||||||
|
* the same glyphs as their toolbar buttons.
|
||||||
|
*/
|
||||||
|
rowItems(ctx: RowMenuContext): MenuItem[] {
|
||||||
|
const t = (key: string) => this.i18n.t(key);
|
||||||
|
const cmd = (id: FileCommand, label: string, extra: Partial<MenuItem> = {}): MenuItem => ({
|
||||||
|
label,
|
||||||
|
action: id,
|
||||||
|
shortcut: id,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
const items: MenuItem[] = [];
|
||||||
|
|
||||||
|
if (!ctx.hasEntry || ctx.name === '..') {
|
||||||
|
// empty space (or the parent row): folder-level actions
|
||||||
|
items.push(cmd('new-folder', t('ctx.newFolder'), { icon: 'folderAdd14' }));
|
||||||
|
items.push(cmd('new-file', t('ctx.newFile')));
|
||||||
|
items.push({ type: 'separator' });
|
||||||
|
items.push(cmd('refresh', t('ctx.refresh')));
|
||||||
|
if (ctx.isLocal) {
|
||||||
|
items.push({ type: 'separator' });
|
||||||
|
items.push(cmd('reveal', t(revealKey())));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the arrow points at the other panel, as on the toolbar's Send button
|
||||||
|
const sendIcon = ctx.side === 'left' ? 'arrowRight14' : 'arrowLeft14';
|
||||||
|
items.push(cmd('send', t('ctx.copyToOther'), { icon: sendIcon }));
|
||||||
|
if (ctx.type === 'dir') {
|
||||||
|
items.push(cmd('open', t('ctx.open')));
|
||||||
|
} else {
|
||||||
|
if (ctx.isLocal) {
|
||||||
|
items.push(cmd('open-default', t('ctx.open'), { tooltip: t('ctx.openDefault') }));
|
||||||
|
}
|
||||||
|
items.push(cmd('edit', t('ctx.edit'), { icon: 'fileText14' }));
|
||||||
|
}
|
||||||
|
items.push(cmd('rename', t('ctx.rename'), { icon: 'edit14' }));
|
||||||
|
// chmod on Windows local files only flips the read-only bit — hide the lie
|
||||||
|
if (!(ctx.isLocal && IS_WINDOWS)) {
|
||||||
|
items.push(cmd('chmod', t('ctx.permissions')));
|
||||||
|
}
|
||||||
|
items.push({ type: 'separator' });
|
||||||
|
items.push(cmd('copy-path', t('ctx.copyPath')));
|
||||||
|
if (ctx.isLocal) {
|
||||||
|
items.push(cmd('reveal', t(revealKey())));
|
||||||
|
}
|
||||||
|
items.push({ type: 'separator' });
|
||||||
|
if (ctx.isLocal) {
|
||||||
|
items.push(cmd('trash', t(trashKey()), IS_MAC ? { tooltip: t('ctx.trashTooltip') } : {}));
|
||||||
|
}
|
||||||
|
items.push(cmd('delete', t('ctx.delete'), { icon: 'delete14' }));
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The workspace-tab menu. Actions carry the target tab's id, because the
|
||||||
|
* right-clicked tab is not necessarily the active one — nothing here may
|
||||||
|
* assume `activeWorkspace`. (The hints name the keys, which act on the
|
||||||
|
* ACTIVE tab; picking by key while this menu is open acts on THIS one.)
|
||||||
|
*
|
||||||
|
* Everything illegal is DISABLED, not omitted: a menu whose items move
|
||||||
|
* between right-clicks is worse than a dead row, and "Close Tab" greyed out
|
||||||
|
* explains why the last tab cannot be closed better than its absence does.
|
||||||
|
*
|
||||||
|
* Duplicate/Rename/Save-session are deliberately absent until tabs have an
|
||||||
|
* identity to duplicate (a stored connection id) — the sessions round.
|
||||||
|
*/
|
||||||
|
tabItems(ctx: TabMenuContext): MenuItem[] {
|
||||||
|
const t = (key: string) => this.i18n.t(key);
|
||||||
|
const last = ctx.tabCount <= 1;
|
||||||
|
return [
|
||||||
|
{ label: t('ctx.tabNew'), action: 'tab:new', shortcut: 'new-tab', icon: 'add14' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: t('ctx.tabClose'),
|
||||||
|
action: `tab:close:${ctx.id}`,
|
||||||
|
shortcut: 'close-tab',
|
||||||
|
icon: 'close14',
|
||||||
|
disabled: last,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('ctx.tabCloseOthers'),
|
||||||
|
action: `tab:close-others:${ctx.id}`,
|
||||||
|
shortcut: 'close-other-tabs',
|
||||||
|
disabled: last,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('ctx.tabCloseRight'),
|
||||||
|
action: `tab:close-right:${ctx.id}`,
|
||||||
|
shortcut: 'close-tabs-right',
|
||||||
|
disabled: ctx.index >= ctx.tabCount - 1,
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: t('ctx.tabDisconnect'),
|
||||||
|
action: `tab:disconnect:${ctx.id}`,
|
||||||
|
shortcut: 'disconnect-tab',
|
||||||
|
disabled: !ctx.isRemote,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Column picker for the list headers: checkable toggles per column. */
|
||||||
|
columnItems(visible: ColumnKey[]): MenuItem[] {
|
||||||
|
return COLUMN_KEYS.map((key) => ({
|
||||||
|
label: this.i18n.t(`columns.${key}`),
|
||||||
|
action: `col:${key}`,
|
||||||
|
checked: visible.includes(key),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,8 +47,10 @@ export class DialogsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Any dialog up — the overwrite question included. Reads signals, so an
|
||||||
|
* effect or computed that asks re-runs when a dialog opens or closes. */
|
||||||
get anyOpen(): boolean {
|
get anyOpen(): boolean {
|
||||||
return !!(this.prompt() || this.confirm() || this.chmod());
|
return !!(this.prompt() || this.confirm() || this.chmod() || this.overwrite());
|
||||||
}
|
}
|
||||||
|
|
||||||
closeAll(): void {
|
closeAll(): void {
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import type { FileEntry } from '@shared/types';
|
||||||
|
import { ContextMenuService, type MenuPoint } from './context-menu.service';
|
||||||
|
import { DialogsService } from './dialogs.service';
|
||||||
|
import { joinPath } from './format';
|
||||||
|
import { type PanelState, type Side, type SortKey } from './panels.service';
|
||||||
|
import { IS_WINDOWS } from './platform';
|
||||||
|
import { RpcService } from './rpc.service';
|
||||||
|
import { SettingsService } from './settings.service';
|
||||||
|
import type { ShortcutId } from './shortcuts.service';
|
||||||
|
import { I18nService } from './i18n.service';
|
||||||
|
import { ToastService } from './toast.service';
|
||||||
|
import { TransfersService } from './transfers.service';
|
||||||
|
|
||||||
|
/** What a file command acts on: a panel, and the row it was invoked on (the
|
||||||
|
* cursor row from the keyboard, the right-clicked row from a menu; null =
|
||||||
|
* the folder itself). */
|
||||||
|
export interface FileTarget {
|
||||||
|
side: Side;
|
||||||
|
panel: PanelState;
|
||||||
|
entry: FileEntry | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The keymap commands that act on files — everything the keyboard, the
|
||||||
|
* right-click menu, the macOS menu bar and the panel toolbar run, all through
|
||||||
|
* run() so the four entry points can't drift apart. */
|
||||||
|
export type FileCommand = Extract<
|
||||||
|
ShortcutId,
|
||||||
|
| 'open'
|
||||||
|
| 'open-default'
|
||||||
|
| 'send'
|
||||||
|
| 'edit'
|
||||||
|
| 'rename'
|
||||||
|
| 'new-folder'
|
||||||
|
| 'new-file'
|
||||||
|
| 'chmod'
|
||||||
|
| 'delete'
|
||||||
|
| 'trash'
|
||||||
|
| 'copy-path'
|
||||||
|
| 'reveal'
|
||||||
|
| 'refresh'
|
||||||
|
| `sort-${string}`
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File operations, always against an explicit panel: on its selection if there
|
||||||
|
* is one, else on the target row.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class FileActionsService {
|
||||||
|
private readonly i18n = inject(I18nService);
|
||||||
|
private readonly rpc = inject(RpcService);
|
||||||
|
private readonly toast = inject(ToastService);
|
||||||
|
private readonly dialogs = inject(DialogsService);
|
||||||
|
private readonly menu = inject(ContextMenuService);
|
||||||
|
private readonly settings = inject(SettingsService);
|
||||||
|
private readonly transfers = inject(TransfersService);
|
||||||
|
|
||||||
|
/** Run a file command. One switch for every entry point. */
|
||||||
|
run(id: FileCommand, target: FileTarget): void {
|
||||||
|
const { side, panel } = target;
|
||||||
|
const local = panel.sessionId() === 'local';
|
||||||
|
// the ".." row stands for the folder itself, as on the empty-space menu
|
||||||
|
const entry = target.entry && target.entry.name !== '..' ? target.entry : null;
|
||||||
|
switch (id) {
|
||||||
|
case 'open':
|
||||||
|
case 'open-default':
|
||||||
|
// folders, symlinks and ".." navigate; a LOCAL file opens in its default
|
||||||
|
// app — from the menu's Open always, from Enter / double-click only
|
||||||
|
// when Settings → File list says so
|
||||||
|
if (!target.entry) return;
|
||||||
|
if (target.entry.type !== 'file') panel.open(target.entry);
|
||||||
|
else if (local && (id === 'open-default' || this.settings.openFilesOnEnter())) {
|
||||||
|
this.shell('open', target.entry.path);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'send':
|
||||||
|
void this.transfers.copyToOtherPanel(side);
|
||||||
|
return;
|
||||||
|
case 'edit':
|
||||||
|
void this.edit(panel);
|
||||||
|
return;
|
||||||
|
case 'rename':
|
||||||
|
this.rename(panel);
|
||||||
|
return;
|
||||||
|
case 'new-folder':
|
||||||
|
this.mkdir(panel);
|
||||||
|
return;
|
||||||
|
case 'new-file':
|
||||||
|
this.createFile(panel);
|
||||||
|
return;
|
||||||
|
case 'chmod':
|
||||||
|
this.chmod(panel);
|
||||||
|
return;
|
||||||
|
case 'delete':
|
||||||
|
this.delete(panel);
|
||||||
|
return;
|
||||||
|
case 'trash':
|
||||||
|
if (local) void this.trash(panel);
|
||||||
|
return;
|
||||||
|
case 'copy-path':
|
||||||
|
this.shell('copyPath', entry?.path ?? panel.cwd());
|
||||||
|
return;
|
||||||
|
case 'reveal':
|
||||||
|
if (local) this.shell('reveal', entry?.path ?? panel.cwd());
|
||||||
|
return;
|
||||||
|
case 'refresh':
|
||||||
|
void panel.refresh();
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
// sort-name, sort-size…: again = reverse
|
||||||
|
if (id.startsWith('sort-')) panel.toggleSort(id.slice('sort-'.length) as SortKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the row / empty-space menu on `target`; its picks run here. */
|
||||||
|
openContextMenu(point: MenuPoint, target: FileTarget, keyboard = false): void {
|
||||||
|
const { side, panel, entry } = target;
|
||||||
|
this.menu.open(
|
||||||
|
point,
|
||||||
|
this.menu.rowItems({
|
||||||
|
side,
|
||||||
|
name: entry?.name ?? '',
|
||||||
|
type: entry?.type ?? 'dir',
|
||||||
|
isLocal: panel.sessionId() === 'local',
|
||||||
|
hasEntry: !!entry,
|
||||||
|
}),
|
||||||
|
{ keyboard, onPick: (action) => this.run(action as FileCommand, target) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local shell integration — only bun can reach the OS shell / clipboard. */
|
||||||
|
private shell(action: 'open' | 'reveal' | 'copyPath', path: string): void {
|
||||||
|
void this.rpc.request.shellItemAction({ action, path });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move the selection (else the target row) to the Trash, one item at a time. */
|
||||||
|
async trash(panel: PanelState): Promise<void> {
|
||||||
|
const targets = this.targets(panel);
|
||||||
|
if (!targets.length) return;
|
||||||
|
let moved = 0;
|
||||||
|
for (const entry of targets) {
|
||||||
|
const res = await this.rpc.request.moveToTrash({ path: entry.path });
|
||||||
|
if (!res.ok) {
|
||||||
|
this.toast.error(res.error);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
moved++;
|
||||||
|
}
|
||||||
|
if (moved === 1) this.toast.info(this.i18n.t('toasts.movedToTrash', { name: targets[0].name }));
|
||||||
|
else if (moved > 1) this.toast.info(this.i18n.t('toasts.movedManyToTrash', { count: moved }));
|
||||||
|
await panel.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Selection if non-empty, else the cursor entry. Never the ".." parent row. */
|
||||||
|
targets(panel: PanelState): FileEntry[] {
|
||||||
|
const selected = panel.selectedEntries();
|
||||||
|
if (selected.length) return selected;
|
||||||
|
const atCursor = panel.sorted()[panel.cursor()];
|
||||||
|
return atCursor && atCursor.name !== '..' ? [atCursor] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir(panel: PanelState): void {
|
||||||
|
this.dialogs.prompt.set({
|
||||||
|
title: this.i18n.t('fileActions.newFolderTitle'),
|
||||||
|
placeholder: this.i18n.t('fileActions.newFolderPlaceholder'),
|
||||||
|
confirmLabel: this.i18n.t('fileActions.createConfirm'),
|
||||||
|
onConfirm: async (name) => {
|
||||||
|
const res = await this.rpc.request.mkdir({
|
||||||
|
sessionId: panel.sessionId(),
|
||||||
|
path: joinPath(panel.cwd(), name),
|
||||||
|
});
|
||||||
|
if (!res.ok) this.toast.error(res.error);
|
||||||
|
await panel.refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createFile(panel: PanelState): void {
|
||||||
|
this.dialogs.prompt.set({
|
||||||
|
title: this.i18n.t('fileActions.newFileTitle'),
|
||||||
|
placeholder: this.i18n.t('fileActions.newFilePlaceholder'),
|
||||||
|
confirmLabel: this.i18n.t('fileActions.createConfirm'),
|
||||||
|
onConfirm: async (name) => {
|
||||||
|
const res = await this.rpc.request.createFile({
|
||||||
|
sessionId: panel.sessionId(),
|
||||||
|
path: joinPath(panel.cwd(), name),
|
||||||
|
});
|
||||||
|
if (!res.ok) this.toast.error(res.error);
|
||||||
|
await panel.refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the cursor file in the built-in editor or the configured external app. */
|
||||||
|
async edit(panel: PanelState): Promise<void> {
|
||||||
|
const entry = panel.sorted()[panel.cursor()];
|
||||||
|
if (!entry || entry.type !== 'file' || entry.name === '..') return;
|
||||||
|
|
||||||
|
if (this.settings.editor() === 'custom') {
|
||||||
|
const res = await this.rpc.request.editExternal({
|
||||||
|
sessionId: panel.sessionId(),
|
||||||
|
path: entry.path,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
this.toast.error(res.error);
|
||||||
|
} else if (res.watching) {
|
||||||
|
this.toast.info(this.i18n.t('toasts.editingExternal', { name: entry.name }));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// built-in editor: its own native window (reads the file itself)
|
||||||
|
const res = await this.rpc.request.openEditor({
|
||||||
|
sessionId: panel.sessionId(),
|
||||||
|
path: entry.path,
|
||||||
|
name: entry.name,
|
||||||
|
sessionTitle: panel.title(),
|
||||||
|
});
|
||||||
|
if (!res.ok) this.toast.error(res.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
rename(panel: PanelState): void {
|
||||||
|
const entry = panel.sorted()[panel.cursor()];
|
||||||
|
if (!entry || entry.name === '..') return;
|
||||||
|
this.dialogs.prompt.set({
|
||||||
|
title: this.i18n.t('fileActions.renameTitle', { name: entry.name }),
|
||||||
|
initial: entry.name,
|
||||||
|
confirmLabel: this.i18n.t('fileActions.renameConfirm'),
|
||||||
|
onConfirm: async (name) => {
|
||||||
|
if (name === entry.name) return;
|
||||||
|
const res = await this.rpc.request.rename({
|
||||||
|
sessionId: panel.sessionId(),
|
||||||
|
oldPath: entry.path,
|
||||||
|
newPath: joinPath(panel.cwd(), name),
|
||||||
|
});
|
||||||
|
if (!res.ok) this.toast.error(res.error);
|
||||||
|
await panel.refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(panel: PanelState): void {
|
||||||
|
const targets = this.targets(panel);
|
||||||
|
if (!targets.length) return;
|
||||||
|
const what =
|
||||||
|
targets.length === 1
|
||||||
|
? `"${targets[0].name}"`
|
||||||
|
: this.i18n.t('fileActions.itemsCount', { count: targets.length });
|
||||||
|
this.dialogs.confirm.set({
|
||||||
|
title: this.i18n.t('fileActions.deleteTitle'),
|
||||||
|
body: this.i18n.t('fileActions.deleteBody', { what }),
|
||||||
|
confirmLabel: this.i18n.t('fileActions.deleteConfirm'),
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: async () => {
|
||||||
|
const res = await this.rpc.request.deleteEntries({
|
||||||
|
sessionId: panel.sessionId(),
|
||||||
|
entries: targets.map(({ path, type }) => ({ path, type })),
|
||||||
|
});
|
||||||
|
if (!res.ok) this.toast.error(res.error);
|
||||||
|
await panel.refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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({
|
||||||
|
targets: targets.map((t) => t.name),
|
||||||
|
initialMode: targets[0].mode,
|
||||||
|
onConfirm: async (mode) => {
|
||||||
|
const res = await this.rpc.request.chmod({
|
||||||
|
sessionId: panel.sessionId(),
|
||||||
|
paths: targets.map((t) => t.path),
|
||||||
|
mode,
|
||||||
|
});
|
||||||
|
if (!res.ok) this.toast.error(res.error);
|
||||||
|
await panel.refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,40 +1,40 @@
|
|||||||
// File-list glyphs + per-category colors ("Multi" icon mode). Category colors
|
// File-list glyphs + per-category colors. Category colors
|
||||||
// are fixed tokens (never follow the accent) so a folder is always folder-blue.
|
// are fixed tokens (never follow the accent) so a folder is always folder-blue.
|
||||||
// Only the icon glyph is colored; row text stays neutral. Docs and unknown
|
// Only the icon glyph is colored; row text stays neutral. Docs and unknown
|
||||||
// files stay muted on purpose — sparing color is what keeps it scannable.
|
// files stay muted on purpose — sparing color is what keeps it scannable.
|
||||||
import type { FileEntry } from '@shared/types';
|
import type { FileEntry } from '@shared/types';
|
||||||
|
import type { Icon16 } from './icons';
|
||||||
|
|
||||||
type Category = 'image' | 'audio' | 'video' | 'archive' | 'code' | 'doc';
|
type Category = 'image' | 'audio' | 'video' | 'archive' | 'code' | 'doc';
|
||||||
|
|
||||||
// Class strings are literals so the Tailwind scanner picks them up.
|
// Class strings are literals so the Tailwind scanner picks them up.
|
||||||
const MUTED = 'shrink-0 text-muted-foreground';
|
const MUTED = 'shrink-0 text-muted-foreground';
|
||||||
const PRIMARY = 'shrink-0 text-primary';
|
|
||||||
const FOLDER = 'shrink-0 text-icon-folder';
|
const FOLDER = 'shrink-0 text-icon-folder';
|
||||||
const LINK = 'shrink-0 text-icon-link';
|
const LINK = 'shrink-0 text-icon-link';
|
||||||
|
|
||||||
const CATEGORIES: Record<Category, { glyph: string; class: string; extensions: string[] }> = {
|
const CATEGORIES: Record<Category, { glyph: Icon16; class: string; extensions: string[] }> = {
|
||||||
image: {
|
image: {
|
||||||
glyph: 'mynaImage',
|
glyph: 'image16',
|
||||||
class: 'shrink-0 text-icon-image',
|
class: 'shrink-0 text-icon-image',
|
||||||
extensions: 'png jpg jpeg gif webp avif svg ico icns bmp tiff tif heic heif raw'.split(' '),
|
extensions: 'png jpg jpeg gif webp avif svg ico icns bmp tiff tif heic heif raw'.split(' '),
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
glyph: 'mynaMusic',
|
glyph: 'music16',
|
||||||
class: 'shrink-0 text-icon-media',
|
class: 'shrink-0 text-icon-media',
|
||||||
extensions: 'mp3 wav flac aac ogg oga m4a opus wma aiff aif mid midi'.split(' '),
|
extensions: 'mp3 wav flac aac ogg oga m4a opus wma aiff aif mid midi'.split(' '),
|
||||||
},
|
},
|
||||||
video: {
|
video: {
|
||||||
glyph: 'mynaVideo',
|
glyph: 'video16',
|
||||||
class: 'shrink-0 text-icon-media',
|
class: 'shrink-0 text-icon-media',
|
||||||
extensions: 'mp4 mkv mov avi webm m4v mpg mpeg wmv flv 3gp'.split(' '),
|
extensions: 'mp4 mkv mov avi webm m4v mpg mpeg wmv flv 3gp'.split(' '),
|
||||||
},
|
},
|
||||||
archive: {
|
archive: {
|
||||||
glyph: 'mynaArchive',
|
glyph: 'fileZip16',
|
||||||
class: 'shrink-0 text-icon-archive',
|
class: 'shrink-0 text-icon-archive',
|
||||||
extensions: 'zip tar gz tgz bz2 xz zst 7z rar dmg iso jar deb rpm pkg apk'.split(' '),
|
extensions: 'zip tar gz tgz bz2 xz zst 7z rar dmg iso jar deb rpm pkg apk'.split(' '),
|
||||||
},
|
},
|
||||||
code: {
|
code: {
|
||||||
glyph: 'mynaCode',
|
glyph: 'code16',
|
||||||
class: 'shrink-0 text-icon-code',
|
class: 'shrink-0 text-icon-code',
|
||||||
// ".ts" is TypeScript here, not an MPEG transport stream (deliberate)
|
// ".ts" is TypeScript here, not an MPEG transport stream (deliberate)
|
||||||
extensions: (
|
extensions: (
|
||||||
@@ -46,7 +46,7 @@ const CATEGORIES: Record<Category, { glyph: string; class: string; extensions: s
|
|||||||
).split(' '),
|
).split(' '),
|
||||||
},
|
},
|
||||||
doc: {
|
doc: {
|
||||||
glyph: 'mynaFileText',
|
glyph: 'fileText16',
|
||||||
class: MUTED, // docs keep the neutral color; only the glyph changes
|
class: MUTED, // docs keep the neutral color; only the glyph changes
|
||||||
extensions: 'md markdown txt rtf pdf doc docx xls xlsx ppt pptx csv tsv log odt ods odp epub'.split(' '),
|
extensions: 'md markdown txt rtf pdf doc docx xls xlsx ppt pptx csv tsv log odt ods odp epub'.split(' '),
|
||||||
},
|
},
|
||||||
@@ -64,36 +64,36 @@ function categoryOf(name: string): Category | null {
|
|||||||
return EXT_CATEGORY.get(key) ?? null;
|
return EXT_CATEGORY.get(key) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KIND_LABELS: Record<Category, string> = {
|
const KIND_KEYS: Record<Category, string> = {
|
||||||
image: 'Image',
|
image: 'image',
|
||||||
audio: 'Audio',
|
audio: 'audio',
|
||||||
video: 'Video',
|
video: 'video',
|
||||||
archive: 'Archive',
|
archive: 'archive',
|
||||||
code: 'Code',
|
code: 'code',
|
||||||
doc: 'Document',
|
doc: 'document',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Human label for the optional Kind column (category-based, like the icons). */
|
/**
|
||||||
export function fileKind(entry: FileEntry): string {
|
* i18n key suffix for the optional Kind column (category-based, like the
|
||||||
|
* icons); '' for the synthetic '..' row. The caller resolves `kinds.<suffix>`.
|
||||||
|
*/
|
||||||
|
export function fileKindKey(entry: FileEntry): string {
|
||||||
if (entry.name === '..') return '';
|
if (entry.name === '..') return '';
|
||||||
if (entry.type === 'dir') return 'Folder';
|
if (entry.type === 'dir') return 'folder';
|
||||||
if (entry.type === 'symlink') return 'Symlink';
|
if (entry.type === 'symlink') return 'symlink';
|
||||||
const category = categoryOf(entry.name);
|
const category = categoryOf(entry.name);
|
||||||
return category ? KIND_LABELS[category] : 'File';
|
return category ? KIND_KEYS[category] : 'file';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fileGlyph(entry: FileEntry, multi: boolean): string {
|
export function fileGlyph(entry: FileEntry): Icon16 {
|
||||||
if (entry.name === '..') return 'mynaArrowUp';
|
if (entry.name === '..') return 'arrowUp16';
|
||||||
if (entry.type === 'dir') return 'mynaFolder';
|
if (entry.type === 'dir') return 'folder16';
|
||||||
if (!multi) return 'mynaFile';
|
if (entry.type === 'symlink') return 'link16';
|
||||||
if (entry.type === 'symlink') return 'mynaLink';
|
|
||||||
const category = categoryOf(entry.name);
|
const category = categoryOf(entry.name);
|
||||||
return category ? CATEGORIES[category].glyph : 'mynaFile';
|
return category ? CATEGORIES[category].glyph : 'file16';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fileIconClass(entry: FileEntry, multi: boolean): string {
|
export function fileIconClass(entry: FileEntry): string {
|
||||||
// plain mode: exactly the pre-color look ('..' has type 'dir' -> primary)
|
|
||||||
if (!multi) return entry.type === 'dir' ? PRIMARY : MUTED;
|
|
||||||
if (entry.name === '..') return MUTED;
|
if (entry.name === '..') return MUTED;
|
||||||
if (entry.type === 'dir') return FOLDER;
|
if (entry.type === 'dir') return FOLDER;
|
||||||
if (entry.type === 'symlink') return LINK;
|
if (entry.type === 'symlink') return LINK;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/** What Tab can land on. Tab order is DOM order: the app sets no positive tabindex. */
|
||||||
|
const FOCUSABLE = 'button, input, select, textarea, [tabindex]';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tab / Shift+Tab through the focusable controls under `root`, in DOM order,
|
||||||
|
* wrapping. Done by hand because WKWebView (macOS) skips buttons, checkboxes
|
||||||
|
* and radios on Tab unless the user turned on System Settings → Keyboard →
|
||||||
|
* Keyboard navigation, which left dialogs, Settings and the connection manager
|
||||||
|
* mouse-only there. It is also the focus trap the modal dialogs lacked. A radio
|
||||||
|
* group is one stop (its checked radio), as natively; arrows move inside it.
|
||||||
|
* Returns true when it took the key.
|
||||||
|
*/
|
||||||
|
export function cycleFocus(root: Element, event: KeyboardEvent): boolean {
|
||||||
|
if (event.key !== 'Tab' || event.metaKey || event.ctrlKey || event.altKey) return false;
|
||||||
|
const stops = [...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(isTabStop);
|
||||||
|
if (!stops.length) return false;
|
||||||
|
event.preventDefault();
|
||||||
|
const at = stops.indexOf(document.activeElement as HTMLElement);
|
||||||
|
const step = event.shiftKey ? -1 : 1;
|
||||||
|
const next = at < 0 ? (step > 0 ? 0 : stops.length - 1) : (at + step + stops.length) % stops.length;
|
||||||
|
stops[next].focus();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTabStop(el: HTMLElement): boolean {
|
||||||
|
if (el.tabIndex < 0 || el.matches(':disabled')) return false;
|
||||||
|
if (!el.getClientRects().length) return false; // display:none, or inside a hidden parent
|
||||||
|
if (el instanceof HTMLInputElement && el.type === 'radio' && el.name) {
|
||||||
|
const group = [
|
||||||
|
...document.querySelectorAll<HTMLInputElement>(`input[type=radio][name="${CSS.escape(el.name)}"]`),
|
||||||
|
];
|
||||||
|
const checked = group.find((radio) => radio.checked);
|
||||||
|
return el === (checked ?? group[0]);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -1,34 +1,51 @@
|
|||||||
const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'];
|
const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
|
||||||
export function formatSize(bytes: number): string {
|
/** Locale-aware number formatting (decimal separator); cached per locale. */
|
||||||
|
const NUM_FORMATS = new Map<string, Intl.NumberFormat>();
|
||||||
|
function num(value: number, locale: string | undefined): string {
|
||||||
|
const key = locale ?? '';
|
||||||
|
let fmt = NUM_FORMATS.get(key);
|
||||||
|
if (!fmt) {
|
||||||
|
fmt = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 });
|
||||||
|
NUM_FORMATS.set(key, fmt);
|
||||||
|
}
|
||||||
|
return fmt.format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSize(bytes: number, locale?: string): string {
|
||||||
let value = bytes;
|
let value = bytes;
|
||||||
let unit = 0;
|
let unit = 0;
|
||||||
while (value >= 1024 && unit < SIZE_UNITS.length - 1) {
|
while (value >= 1024 && unit < SIZE_UNITS.length - 1) {
|
||||||
value /= 1024;
|
value /= 1024;
|
||||||
unit++;
|
unit++;
|
||||||
}
|
}
|
||||||
return `${unit === 0 ? value : value.toFixed(1)} ${SIZE_UNITS[unit]}`;
|
return `${unit === 0 ? num(value, locale) : num(Number(value.toFixed(1)), locale)} ${SIZE_UNITS[unit]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DATE_FORMAT = new Intl.DateTimeFormat(undefined, {
|
const DATE_FORMATS = new Map<string, Intl.DateTimeFormat>();
|
||||||
|
export function formatDate(epochMs: number, locale?: string): string {
|
||||||
|
if (!epochMs) return '';
|
||||||
|
const key = locale ?? '';
|
||||||
|
let fmt = DATE_FORMATS.get(key);
|
||||||
|
if (!fmt) {
|
||||||
|
fmt = new Intl.DateTimeFormat(locale, {
|
||||||
year: '2-digit',
|
year: '2-digit',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
});
|
});
|
||||||
|
DATE_FORMATS.set(key, fmt);
|
||||||
export function formatDate(epochMs: number): string {
|
}
|
||||||
if (!epochMs) return '';
|
return fmt.format(new Date(epochMs));
|
||||||
return DATE_FORMAT.format(new Date(epochMs));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** bytes/sec -> "14.2 MB/s" or "113.8 Mbps" depending on the configured unit. */
|
/** bytes/sec -> "14.2 MB/s" or "113.8 Mbps" depending on the configured unit. */
|
||||||
export function formatSpeed(bytesPerSec: number, unit: 'MBps' | 'Mbps'): string {
|
export function formatSpeed(bytesPerSec: number, unit: 'MBps' | 'Mbps', locale?: string): string {
|
||||||
if (unit === 'Mbps') {
|
if (unit === 'Mbps') {
|
||||||
return `${((bytesPerSec * 8) / 1_000_000).toFixed(1)} Mbps`;
|
return `${num(Number(((bytesPerSec * 8) / 1_000_000).toFixed(1)), locale)} Mbps`;
|
||||||
}
|
}
|
||||||
return `${formatSize(bytesPerSec)}/s`;
|
return `${formatSize(bytesPerSec, locale)}/s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Seconds -> "0:42" / "3:07" / "1:02:15". */
|
/** Seconds -> "0:42" / "3:07" / "1:02:15". */
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Injectable, computed, signal } from '@angular/core';
|
||||||
|
import { DICTS, type Messages } from '@shared/i18n';
|
||||||
|
import type { AppSettings, SupportedLocale } from '@shared/types';
|
||||||
|
import { formatDate, formatSize, formatSpeed } from './format';
|
||||||
|
|
||||||
|
type Params = Record<string, string | number>;
|
||||||
|
|
||||||
|
function lookup(dict: Messages, key: string): string | Messages | undefined {
|
||||||
|
let node: string | Messages | undefined = dict;
|
||||||
|
for (const part of key.split('.')) {
|
||||||
|
if (typeof node !== 'object') return undefined;
|
||||||
|
node = node[part];
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolate(template: string, params?: Params): string {
|
||||||
|
if (!params) return template;
|
||||||
|
return template.replace(/\{(\w+)\}/g, (_, name: string) =>
|
||||||
|
name in params ? String(params[name]) : `{${name}}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime translator. `t()` reads the `locale` signal, so any template binding
|
||||||
|
* that calls it re-renders live on a language switch (the same reactive
|
||||||
|
* function-call tracking the file-list icon classes rely on). Missing keys fall
|
||||||
|
* back to English, then to the raw key so gaps are visible in dev.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class I18nService {
|
||||||
|
readonly locale = signal<SupportedLocale>('en');
|
||||||
|
private readonly dict = computed(() => DICTS[this.locale()]);
|
||||||
|
|
||||||
|
t(key: string, params?: Params): string {
|
||||||
|
let value = lookup(this.dict(), key) ?? lookup(DICTS.en, key);
|
||||||
|
// plural map: pick the CLDR category for params.count (fallback to English)
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const count = Number(params?.['count'] ?? NaN);
|
||||||
|
if (Number.isFinite(count)) {
|
||||||
|
const rule = new Intl.PluralRules(this.locale()).select(count);
|
||||||
|
const picked = value[rule] ?? value['other'] ?? lookup(DICTS.en, `${key}.other`);
|
||||||
|
value = typeof picked === 'string' ? picked : undefined;
|
||||||
|
} else {
|
||||||
|
value = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return typeof value === 'string' ? interpolate(value, params) : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locale-reactive formatting (live-updates the file list on switch).
|
||||||
|
date(epochMs: number): string {
|
||||||
|
return formatDate(epochMs, this.locale());
|
||||||
|
}
|
||||||
|
size(bytes: number): string {
|
||||||
|
return formatSize(bytes, this.locale());
|
||||||
|
}
|
||||||
|
speed(bytesPerSec: number, unit: AppSettings['speedUnit']): string {
|
||||||
|
return formatSpeed(bytesPerSec, unit, this.locale());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { expect, test } from 'bun:test';
|
||||||
|
import { APP_ICONS } from './icons';
|
||||||
|
import { PIXEL_ICONS } from './pixel-icons';
|
||||||
|
|
||||||
|
// The px grid of each size token (styles.css); lg only carries the spinner.
|
||||||
|
const GRID: Record<string, string> = { sm: '14', md: '16', lg: '20', xl: '24' };
|
||||||
|
const SKINS = `${import.meta.dir}/../skins`;
|
||||||
|
|
||||||
|
// Templates can't type-check icon names, so read them: every <ng-icon> has a
|
||||||
|
// size token, and every literal name (both sides of a [name] ternary too) is
|
||||||
|
// registered and drawn for that token's grid. Names computed in TS are typed.
|
||||||
|
test('every template icon is registered and drawn for the size it renders at', async () => {
|
||||||
|
const problems: string[] = [];
|
||||||
|
for (const file of new Bun.Glob('**/*.{html,ts}').scanSync(SKINS)) {
|
||||||
|
const src = (await Bun.file(`${SKINS}/${file}`).text()).replace(/<!--[\s\S]*?-->/g, '');
|
||||||
|
for (const [tag] of src.matchAll(/<ng-icon\b[^>]*>/g)) {
|
||||||
|
const token = tag.match(/size="var\(--icon-size-(\w+)\)"/)?.[1];
|
||||||
|
if (!token || !GRID[token]) {
|
||||||
|
problems.push(`${file}: no size token in ${tag}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const bound = tag.match(/\[name\]="([^"]*)"/)?.[1] ?? '';
|
||||||
|
// a ternary's results, not its condition ('local' in `id === 'local' ? …`)
|
||||||
|
const names = [...tag.matchAll(/\bname="(\w+)"/g), ...bound.matchAll(/[?:]\s*'(\w+)'/g)].map((m) => m[1]);
|
||||||
|
for (const name of names) {
|
||||||
|
if (!Object.hasOwn(APP_ICONS, name)) problems.push(`${file}: ${name} is not registered`);
|
||||||
|
else if (name !== 'tdesignLoading' && !name.endsWith(GRID[token])) {
|
||||||
|
problems.push(`${file}: ${name} renders at ${token} (${GRID[token]}px)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(problems).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A grid name's glyph is drawn on that grid (a TDesign glyph registered as
|
||||||
|
// foo14 would be a 24-unit viewBox), and the redrawn ones use plain attributes
|
||||||
|
// only: TDesign's stroke-width var or any style would bypass the grid.
|
||||||
|
test('every glyph is drawn on the grid its name says', () => {
|
||||||
|
const problems: string[] = [];
|
||||||
|
for (const [name, svg] of Object.entries(APP_ICONS)) {
|
||||||
|
const grid = name.match(/(\d+)$/)?.[1];
|
||||||
|
if (grid && !svg.includes(`viewBox="0 0 ${grid} ${grid}"`)) problems.push(`${name}: not drawn on a ${grid}px grid`);
|
||||||
|
}
|
||||||
|
for (const [name, svg] of Object.entries(PIXEL_ICONS)) {
|
||||||
|
if (/var\(|style=/.test(svg)) problems.push(`${name}: uses a CSS var or a style`);
|
||||||
|
}
|
||||||
|
expect(problems).toEqual([]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Single icon registry for the whole app, registered once in app.config.ts.
|
||||||
|
// A name is <icon><px>: the pixel grid the glyph is drawn for, which must match
|
||||||
|
// the size token where it renders (pixel-icons.ts). Templates can't type-check
|
||||||
|
// names and an unregistered one renders nothing (a console warning only), so
|
||||||
|
// icons.spec.ts checks every template name; TS code takes the Icon14/Icon16 types.
|
||||||
|
import { tdesignCloseCircle, tdesignLoading } from "@ng-icons/tdesign-icons";
|
||||||
|
import { PIXEL_ICONS } from "./pixel-icons";
|
||||||
|
|
||||||
|
export const APP_ICONS = {
|
||||||
|
...PIXEL_ICONS,
|
||||||
|
// TDesign's own 24px grid: as sharp as a circle and its diagonals can be
|
||||||
|
closeCircle24: tdesignCloseCircle,
|
||||||
|
// spins, so no pixel grid holds; any size
|
||||||
|
tdesignLoading,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IconName = keyof typeof APP_ICONS;
|
||||||
|
export type Icon14 = Extract<IconName, `${string}14`>;
|
||||||
|
export type Icon16 = Extract<IconName, `${string}16`>;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { expect, test } from 'bun:test';
|
||||||
|
import { stepIndex } from './menu-nav';
|
||||||
|
|
||||||
|
// item, separator, item, disabled, item — the shape of a real row menu
|
||||||
|
const menu = [true, false, true, false, true];
|
||||||
|
|
||||||
|
test('a fresh menu: down lands on the first entry, up on the last', () => {
|
||||||
|
expect(stepIndex(menu, -1, 1)).toBe(0);
|
||||||
|
expect(stepIndex(menu, -1, -1)).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('separators and disabled entries are skipped', () => {
|
||||||
|
expect(stepIndex(menu, 0, 1)).toBe(2);
|
||||||
|
expect(stepIndex(menu, 2, 1)).toBe(4);
|
||||||
|
expect(stepIndex(menu, 4, -1)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wraps both ways', () => {
|
||||||
|
expect(stepIndex(menu, 4, 1)).toBe(0);
|
||||||
|
expect(stepIndex(menu, 0, -1)).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nothing selectable leaves the highlight alone', () => {
|
||||||
|
expect(stepIndex([false, false], -1, 1)).toBe(-1);
|
||||||
|
expect(stepIndex([false, false], -1, -1)).toBe(-1);
|
||||||
|
expect(stepIndex([], -1, -1)).toBe(-1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Next highlighted menu index: skips unselectable entries and wraps. From -1
|
||||||
|
* (nothing highlighted yet), down lands on the first selectable entry and up
|
||||||
|
* on the LAST — stepping up from -1 directly would land one short of it.
|
||||||
|
* Returns `from` unchanged when nothing is selectable.
|
||||||
|
*/
|
||||||
|
export function stepIndex(selectable: boolean[], from: number, delta: 1 | -1): number {
|
||||||
|
const count = selectable.length;
|
||||||
|
let index = from < 0 && delta < 0 ? 0 : from;
|
||||||
|
for (let hops = 0; hops < count; hops++) {
|
||||||
|
index = (index + delta + count) % count;
|
||||||
|
if (selectable[index]) return index;
|
||||||
|
}
|
||||||
|
return from;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Rem-to-pixel conversion at the CURRENT root font size.
|
||||||
|
*
|
||||||
|
* The interface scale (settings.uiScale) works by setting the root font-size,
|
||||||
|
* so any TypeScript that positions or measures against rem-sized CSS must
|
||||||
|
* convert through the LIVE root — never through an assumed 16. Both context
|
||||||
|
* menus' flip/clamp math is the canonical consumer: their widths and row
|
||||||
|
* heights are rem in CSS, and hardcoding their px mirrors is exactly the bug
|
||||||
|
* that made the shadlike menu flip at the wrong edge once scaling landed.
|
||||||
|
*
|
||||||
|
* Read per call, not cached: the scale can change mid-session (stepper,
|
||||||
|
* Ctrl+/-) and a menu opened after that must measure against the new root.
|
||||||
|
*/
|
||||||
|
export function remPx(rem: number): number {
|
||||||
|
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Injectable, computed, inject, signal, type WritableSignal } from '@angular/core';
|
import { Injectable, computed, effect, inject, signal, type WritableSignal } from '@angular/core';
|
||||||
import type { ColumnKey, ConnectionConfig, FileEntry, SavedConnection } from '@shared/types';
|
import type { ColumnKey, ConnectionConfig, FileEntry, SavedConnection } from '@shared/types';
|
||||||
import { fileKind } from './file-icons';
|
import { fileKindKey } from './file-icons';
|
||||||
import { FocusService } from './focus.service';
|
import { FocusService } from './focus.service';
|
||||||
import { formatMode, isRootPath, joinPath } from './format';
|
import { formatMode, isRootPath, joinPath } from './format';
|
||||||
import { RpcService } from './rpc.service';
|
import { RpcService } from './rpc.service';
|
||||||
|
import { I18nService } from './i18n.service';
|
||||||
import { ToastService } from './toast.service';
|
import { ToastService } from './toast.service';
|
||||||
|
|
||||||
export type Side = 'left' | 'right';
|
export type Side = 'left' | 'right';
|
||||||
@@ -12,7 +13,7 @@ export type SortKey = 'name' | ColumnKey;
|
|||||||
|
|
||||||
/** Display string the text-based columns sort by. */
|
/** Display string the text-based columns sort by. */
|
||||||
function sortText(entry: FileEntry, key: 'kind' | 'rights' | 'owner' | 'group'): string {
|
function sortText(entry: FileEntry, key: 'kind' | 'rights' | 'owner' | 'group'): string {
|
||||||
if (key === 'kind') return fileKind(entry);
|
if (key === 'kind') return fileKindKey(entry);
|
||||||
if (key === 'rights') return formatMode(entry.mode);
|
if (key === 'rights') return formatMode(entry.mode);
|
||||||
return (key === 'owner' ? entry.owner : entry.group) ?? '';
|
return (key === 'owner' ? entry.owner : entry.group) ?? '';
|
||||||
}
|
}
|
||||||
@@ -108,19 +109,34 @@ export class PanelState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reload cwd, keeping cursor position and selection by name. */
|
private refreshing = false;
|
||||||
async refresh(): Promise<void> {
|
|
||||||
|
/**
|
||||||
|
* Reload cwd, keeping cursor position and selection by name. `silent` (used by
|
||||||
|
* auto-refresh) skips the loading/slowLoad flags — so no spinner, no dim
|
||||||
|
* overlay, input never blocked — and touches no DOM, so scroll stays put.
|
||||||
|
*/
|
||||||
|
async refresh(opts?: { silent?: boolean }): Promise<void> {
|
||||||
|
const silent = opts?.silent ?? false;
|
||||||
|
// a silent refresh must never fight the user or overlap itself
|
||||||
|
if (silent && (this.refreshing || this.busy())) return;
|
||||||
|
this.refreshing = true;
|
||||||
|
// snapshot what we're listing; a silent refresh has no busy-block, so the
|
||||||
|
// user can navigate mid-flight — discard the result if they did
|
||||||
|
const forSession = this.sessionId();
|
||||||
|
const forDir = this.cwd();
|
||||||
const keepCursorName = this.sorted()[this.cursor()]?.name;
|
const keepCursorName = this.sorted()[this.cursor()]?.name;
|
||||||
const keepSelected = this.selected();
|
const keepSelected = this.selected();
|
||||||
this.loading.set(true);
|
if (!silent) this.loading.set(true);
|
||||||
const slowTimer = setTimeout(() => this.slowLoad.set(true), 300);
|
const slowTimer = silent ? null : setTimeout(() => this.slowLoad.set(true), 300);
|
||||||
try {
|
try {
|
||||||
const res = await this.deps.rpc.request.listDir({
|
const res = await this.deps.rpc.request.listDir({
|
||||||
sessionId: this.sessionId(),
|
sessionId: forSession,
|
||||||
path: this.cwd(),
|
path: forDir,
|
||||||
});
|
});
|
||||||
|
if (this.sessionId() !== forSession || this.cwd() !== forDir) return;
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
this.deps.toast.error(res.error);
|
if (!silent) this.deps.toast.error(res.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.cwd.set(res.path);
|
this.cwd.set(res.path);
|
||||||
@@ -130,10 +146,13 @@ export class PanelState {
|
|||||||
const idx = this.sorted().findIndex((e) => e.name === keepCursorName);
|
const idx = this.sorted().findIndex((e) => e.name === keepCursorName);
|
||||||
this.cursor.set(idx >= 0 ? idx : Math.min(this.cursor(), Math.max(0, this.sorted().length - 1)));
|
this.cursor.set(idx >= 0 ? idx : Math.min(this.cursor(), Math.max(0, this.sorted().length - 1)));
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(slowTimer);
|
if (slowTimer) clearTimeout(slowTimer);
|
||||||
|
if (!silent) {
|
||||||
this.slowLoad.set(false);
|
this.slowLoad.set(false);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
}
|
}
|
||||||
|
this.refreshing = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
open(entry: FileEntry): void {
|
open(entry: FileEntry): void {
|
||||||
@@ -207,6 +226,7 @@ export interface Workspace {
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class PanelsService {
|
export class PanelsService {
|
||||||
|
private readonly i18n = inject(I18nService);
|
||||||
readonly workspaces = signal<Workspace[]>([]);
|
readonly workspaces = signal<Workspace[]>([]);
|
||||||
readonly activeWorkspaceId = signal(0);
|
readonly activeWorkspaceId = signal(0);
|
||||||
readonly activeWorkspace = computed(() => {
|
readonly activeWorkspace = computed(() => {
|
||||||
@@ -230,18 +250,69 @@ export class PanelsService {
|
|||||||
for (const workspace of this.workspaces()) {
|
for (const workspace of this.workspaces()) {
|
||||||
for (const panel of [workspace.left, workspace.right]) {
|
for (const panel of [workspace.left, workspace.right]) {
|
||||||
if (panel.sessionId() === event.sessionId) {
|
if (panel.sessionId() === event.sessionId) {
|
||||||
this.toast.error(`${panel.title()}: disconnected (${event.reason})`);
|
this.toast.error(
|
||||||
|
this.i18n.t('toasts.disconnectedReason', {
|
||||||
|
title: panel.title(),
|
||||||
|
reason: event.reason,
|
||||||
|
}),
|
||||||
|
);
|
||||||
this.resetToLocal(panel);
|
this.resetToLocal(panel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// an editor window saved a file — refresh panels showing that directory
|
// a watched local dir changed, or an editor saved a file — silently
|
||||||
|
// refresh every panel showing that directory (no spinner, view preserved)
|
||||||
this.rpc.onPathRefresh(({ sessionId, dir }) => {
|
this.rpc.onPathRefresh(({ sessionId, dir }) => {
|
||||||
for (const panel of this.allPanels()) {
|
for (const panel of this.allPanels()) {
|
||||||
if (panel.sessionId() === sessionId && panel.cwd() === dir) void panel.refresh();
|
if (panel.sessionId() === sessionId && panel.cwd() === dir) {
|
||||||
|
void panel.refresh({ silent: true });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setupAutoRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-refresh the active tab's panels without disturbing the view. Local
|
||||||
|
* panels are watched instantly via fs.watch (backend pushes pathRefresh);
|
||||||
|
* remote panels have no change notification, so they're polled. Only the
|
||||||
|
* active tab is watched/polled; switching tabs refreshes the new one at once.
|
||||||
|
*/
|
||||||
|
private setupAutoRefresh(): void {
|
||||||
|
// REMOTE_POLL_MS: also the cadence a future "auto-refresh interval" setting
|
||||||
|
// would drive (see the commented breadcrumbs in settings).
|
||||||
|
const REMOTE_POLL_MS = 3000;
|
||||||
|
setInterval(() => {
|
||||||
|
const ws = this.activeWorkspace();
|
||||||
|
if (!ws) return;
|
||||||
|
for (const panel of [ws.left, ws.right]) {
|
||||||
|
if (panel.sessionId() !== 'local') void panel.refresh({ silent: true });
|
||||||
|
}
|
||||||
|
}, REMOTE_POLL_MS);
|
||||||
|
|
||||||
|
// register the active tab's LOCAL dirs for fs.watch; re-runs when the active
|
||||||
|
// tab changes or either panel navigates/(dis)connects
|
||||||
|
effect(() => {
|
||||||
|
const ws = this.activeWorkspace();
|
||||||
|
const dirs = ws
|
||||||
|
? [ws.left, ws.right]
|
||||||
|
.filter((p) => p.sessionId() === 'local' && p.cwd())
|
||||||
|
.map((p) => p.cwd())
|
||||||
|
: [];
|
||||||
|
void this.rpc.request.setWatchedDirs({ dirs: [...new Set(dirs)] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// switching tabs shows fresh data immediately instead of waiting a poll tick
|
||||||
|
let lastActive = this.activeWorkspaceId();
|
||||||
|
effect(() => {
|
||||||
|
const id = this.activeWorkspaceId();
|
||||||
|
if (id === lastActive) return;
|
||||||
|
lastActive = id;
|
||||||
|
const ws = this.activeWorkspace();
|
||||||
|
if (ws) for (const panel of [ws.left, ws.right]) void panel.refresh({ silent: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +352,19 @@ export class PanelsService {
|
|||||||
this.focus.bump();
|
this.focus.bump();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Each file list's key handler. A key that lands on <body> — a mouse click
|
||||||
|
* dropped focus (WebKit doesn't focus clicked buttons) — still reaches the
|
||||||
|
* active list through forwardListKey instead of dying until the next click. */
|
||||||
|
private readonly listKeys = new Map<Side, (event: KeyboardEvent) => void>();
|
||||||
|
|
||||||
|
registerListKeys(side: Side, handler: (event: KeyboardEvent) => void): void {
|
||||||
|
this.listKeys.set(side, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
forwardListKey(event: KeyboardEvent): void {
|
||||||
|
this.listKeys.get(this.activeSide())?.(event);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- workspace (tab) management ----
|
// ---- workspace (tab) management ----
|
||||||
|
|
||||||
newWorkspace(): void {
|
newWorkspace(): void {
|
||||||
@@ -327,6 +411,46 @@ export class PanelsService {
|
|||||||
this.refocus();
|
this.refocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close every tab except one — the tab menu's "Close Other Tabs".
|
||||||
|
*
|
||||||
|
* Snapshot the ids FIRST: closeWorkspace mutates the list on every call, so
|
||||||
|
* iterating the live signal would skip half of them. It also refuses the
|
||||||
|
* last tab, which is exactly the wanted floor here.
|
||||||
|
*/
|
||||||
|
closeOtherWorkspaces(id: number): void {
|
||||||
|
for (const other of this.workspaces().map((w) => w.id)) {
|
||||||
|
if (other !== id) this.closeWorkspace(other);
|
||||||
|
}
|
||||||
|
this.setActiveWorkspace(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close every tab to the right of one, in visual order. */
|
||||||
|
closeWorkspacesToRight(id: number): void {
|
||||||
|
const list = this.workspaces();
|
||||||
|
const index = list.findIndex((w) => w.id === id);
|
||||||
|
if (index < 0) return;
|
||||||
|
for (const target of list.slice(index + 1).map((w) => w.id)) {
|
||||||
|
this.closeWorkspace(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop both of a tab's remote sessions, keeping the tab itself. Unlike
|
||||||
|
* `disconnectPanel` this targets a tab by id, because the right-clicked tab
|
||||||
|
* is not necessarily the active one.
|
||||||
|
*/
|
||||||
|
async disconnectWorkspace(id: number): Promise<void> {
|
||||||
|
const workspace = this.workspaces().find((w) => w.id === id);
|
||||||
|
if (!workspace) return;
|
||||||
|
for (const panel of [workspace.left, workspace.right]) {
|
||||||
|
const sessionId = panel.sessionId();
|
||||||
|
if (sessionId === 'local') continue;
|
||||||
|
await this.rpc.request.disconnect({ sessionId });
|
||||||
|
this.resetToLocal(panel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setActiveWorkspace(id: number): void {
|
setActiveWorkspace(id: number): void {
|
||||||
if (this.activeWorkspaceId() !== id) {
|
if (this.activeWorkspaceId() !== id) {
|
||||||
this.activeWorkspaceId.set(id);
|
this.activeWorkspaceId.set(id);
|
||||||
@@ -389,7 +513,7 @@ export class PanelsService {
|
|||||||
const remoteTitles = [workspace.left, workspace.right]
|
const remoteTitles = [workspace.left, workspace.right]
|
||||||
.filter((p) => p.sessionId() !== 'local')
|
.filter((p) => p.sessionId() !== 'local')
|
||||||
.map((p) => p.title());
|
.map((p) => p.title());
|
||||||
return remoteTitles.length ? remoteTitles.join(' ⇄ ') : 'Local';
|
return remoteTitles.length ? remoteTitles.join(' ⇄ ') : this.i18n.t('common.local');
|
||||||
}
|
}
|
||||||
|
|
||||||
workspaceIsRemote(workspace: Workspace): boolean {
|
workspaceIsRemote(workspace: Workspace): boolean {
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Icons drawn on the pixel grid they render at. A TDesign glyph is a 24-unit
|
||||||
|
// SVG with 2-unit strokes on whole units, so at 14 or 16px every straight line
|
||||||
|
// lands between pixels and smears over two at half ink on a 1x screen. Each
|
||||||
|
// glyph here is drawn on its own grid (viewBox 0 0 N N for an N px icon, one
|
||||||
|
// unit = one CSS pixel at 100% scale) with 1px strokes, so straight lines fill
|
||||||
|
// whole pixels at 1x and 2x. The name's suffix is that grid and must match the
|
||||||
|
// size token where it renders: 14 = --icon-size-sm, 16 = -md, 24 = -xl
|
||||||
|
// (icons.spec.ts checks). Drawing rules: docs/gotchas.md.
|
||||||
|
|
||||||
|
/** One glyph on an n-px grid: 1px strokes in the text colour. */
|
||||||
|
const svg = (n: number, body: string) =>
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${n} ${n}" fill="none" stroke="currentColor">${body}</svg>`;
|
||||||
|
|
||||||
|
// Straight lines are runs of whole pixels, in pixel coordinates (inclusive),
|
||||||
|
// and end exactly on pixel edges. Nothing relies on caps or joins: WebKit
|
||||||
|
// drops square caps on a path of several subpaths, and Skia (WebView2,
|
||||||
|
// WebKitGTK) strokes 1px lines as hairlines that leave a joined corner pixel
|
||||||
|
// three-quarters inked.
|
||||||
|
/** Pixels x0..x1 of row y. */
|
||||||
|
const h = (y: number, x0: number, x1: number) => `M${x0} ${y + 0.5}H${x1 + 1}`;
|
||||||
|
/** Pixels y0..y1 of column x. */
|
||||||
|
const v = (x: number, y0: number, y1: number) => `M${x + 0.5} ${y0}V${y1 + 1}`;
|
||||||
|
/** The outline of the box from pixel (x0, y0) to (x1, y1). */
|
||||||
|
const box = (x0: number, y0: number, x1: number, y1: number) => h(y0, x0, x1) + h(y1, x0, x1) + v(x0, y0, y1) + v(x1, y0, y1);
|
||||||
|
/** Filled shapes in the text colour. */
|
||||||
|
const fill = (...d: string[]) => `<path fill="currentColor" stroke="none" d="${d.join('')}"/>`;
|
||||||
|
/** Filled pixels (indicator dots, zipper teeth), size × size each. */
|
||||||
|
const dots = (at: [number, number][], size = 1) => fill(...at.map(([x, y]) => `M${x} ${y}h${size}v${size}h-${size}z`));
|
||||||
|
const path = (...d: string[]) => `<path d="${d.join('')}"/>`;
|
||||||
|
/** Slanted and curved strokes: anti-aliased wherever they sit, so 1.25px to read
|
||||||
|
* as heavy as the crisp 1px runs. Their ends and vertices sit on pixel centres. */
|
||||||
|
const slant = (...d: string[]) => `<path stroke-width="1.25" d="${d.join('')}"/>`;
|
||||||
|
/** A circle as path data, for slant() or fill(). A whole-pixel centre with a half-pixel
|
||||||
|
* radius puts its top, bottom and sides on pixel centres. */
|
||||||
|
const circle = (cx: number, cy: number, r: number) => `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 ${-2 * r} 0`;
|
||||||
|
|
||||||
|
/** A page with its top-right corner folded: the outline, the fold and the cut. */
|
||||||
|
const FILE14 = path(h(1, 2, 9), v(2, 1, 12), h(12, 2, 11), v(11, 3, 12), v(8, 1, 4), h(4, 8, 11)) + slant('M9.5 1.5l2 2');
|
||||||
|
const FILE16 = path(h(1, 2, 10), v(2, 1, 14), h(14, 2, 13), v(13, 4, 14), v(9, 1, 5), h(5, 9, 13)) + slant('M10.5 1.5l3 3');
|
||||||
|
|
||||||
|
export const PIXEL_ICONS = {
|
||||||
|
add14: svg(14, path(v(7, 3, 11), h(7, 3, 11))),
|
||||||
|
|
||||||
|
// ---- boxes ----
|
||||||
|
server14: svg(14, path(box(1, 1, 12, 5), box(1, 8, 12, 12)) + dots([[3, 3], [3, 10]])),
|
||||||
|
server16: svg(16, path(box(1, 1, 14, 6), box(1, 9, 14, 14)) + dots([[4, 3], [4, 11]])),
|
||||||
|
// TDesign's own geometry, already on the 24px grid; only the dots move onto whole pixels
|
||||||
|
server24: svg(24, '<g stroke-width="2"><rect x="3" y="3" width="18" height="7"/><rect x="3" y="14" width="18" height="7"/></g>' + dots([[5, 5], [5, 16]], 2)),
|
||||||
|
hardDrive14: svg(14, path(box(1, 1, 12, 12), h(8, 1, 12)) + dots([[3, 10], [5, 10]])),
|
||||||
|
file14: svg(14, FILE14),
|
||||||
|
file16: svg(16, FILE16),
|
||||||
|
fileText14: svg(14, FILE14 + path(h(7, 4, 9), h(9, 4, 9))),
|
||||||
|
fileText16: svg(16, FILE16 + path(h(8, 5, 10), h(11, 5, 10))),
|
||||||
|
fileAdd14: svg(14, path(h(1, 2, 9), v(2, 1, 12), h(12, 2, 6), v(11, 3, 6), v(8, 1, 4), h(4, 8, 11), v(11, 9, 13), h(11, 9, 13)) + slant('M9.5 1.5l2 2')),
|
||||||
|
fileZip16: svg(16, FILE16 + dots([[6, 2], [5, 3], [6, 4], [5, 5], [6, 6], [5, 7], [6, 8], [5, 9]])),
|
||||||
|
folder14: svg(14, path(h(2, 1, 5), h(4, 7, 12), v(1, 2, 11), v(12, 4, 11), h(11, 1, 12)) + slant('M5.5 2.5l2 2')),
|
||||||
|
folder16: svg(16, path(h(2, 1, 6), h(4, 8, 14), v(1, 2, 13), v(14, 4, 13), h(13, 1, 14)) + slant('M6.5 2.5l2 2')),
|
||||||
|
folderAdd14: svg(14, path(h(2, 1, 5), h(4, 7, 12), v(12, 4, 6), v(1, 2, 11), h(11, 1, 7), v(11, 8, 12), h(10, 9, 13)) + slant('M5.5 2.5l2 2')),
|
||||||
|
delete14: svg(14, path(h(3, 2, 12), v(3, 3, 12), h(12, 3, 11), v(11, 3, 12), v(5, 1, 3), h(1, 5, 9), v(9, 1, 3), v(7, 5, 10))),
|
||||||
|
lockOn14: svg(14, path(box(2, 6, 11, 12), v(4, 4, 5), v(9, 4, 5), h(9, 5, 8)) + slant('M4.5 4a2.5 2.5 0 0 1 5 0')),
|
||||||
|
video16: svg(16, path(box(1, 1, 14, 14), v(6, 4, 11)) + slant('M6.5 4.5L12 8L6.5 11.5')),
|
||||||
|
|
||||||
|
// ---- 45° ----
|
||||||
|
chevronDown14: svg(14, slant('M3.5 5.5l3 3 3-3')),
|
||||||
|
chevronUp14: svg(14, slant('M3.5 8.5l3-3 3 3')),
|
||||||
|
chevronRight14: svg(14, slant('M5.5 3.5l3 3-3 3')),
|
||||||
|
arrowRight14: svg(14, path(h(7, 2, 10)) + slant('M7.5 4.5l3 3-3 3')),
|
||||||
|
arrowLeft14: svg(14, path(h(7, 3, 11)) + slant('M6.5 4.5l-3 3 3 3')),
|
||||||
|
arrowUp16: svg(16, path(v(7, 3, 13)) + slant('M3.5 6.5l4-4 4 4')),
|
||||||
|
close14: svg(14, slant('M4 4l6 6M10 4l-6 6')),
|
||||||
|
check14: svg(14, slant('M3.5 6.5l3 3 5-5')),
|
||||||
|
download14: svg(14, path(v(7, 2, 8), v(2, 8, 11), v(12, 8, 11), h(11, 2, 12)) + slant('M4.5 5.5l3 3 3-3')),
|
||||||
|
code16: svg(16, slant('M4 5L1 8l3 3M12 5l3 3-3 3M9.5 2.5l-3 11')),
|
||||||
|
|
||||||
|
// ---- circles and arcs ----
|
||||||
|
checkCircle14: svg(14, slant(circle(7, 7, 5.5), 'M4.5 6.5l2 2 3-3')),
|
||||||
|
closeCircle14: svg(14, slant(circle(7, 7, 5.5), 'M5 5l4 4M9 5l-4 4')),
|
||||||
|
search14: svg(14, slant(circle(6, 6, 4.5), 'M9.5 9.5l3 3')),
|
||||||
|
setting14: svg(14, path(v(2, 4, 9), v(11, 4, 9)) + slant('M2.5 4.5L7 1.5l4.5 3M2.5 9.5L7 12.5l4.5-3', circle(7, 7, 2.5))),
|
||||||
|
setting16: svg(16, path(v(2, 4, 11), v(13, 4, 11)) + slant('M2.5 4.5L8 1.5l5.5 3M2.5 11.5L8 14.5l5.5-3', circle(8, 8, 2.5))),
|
||||||
|
refresh14: svg(14, path(v(1, 9, 12), h(9, 1, 3), v(12, 1, 4), h(4, 10, 12)) + slant('M12.47 7.58A5.5 5.5 0 0 1 2.1 9.5M1.53 6.42A5.5 5.5 0 0 1 11.9 4.5')),
|
||||||
|
refresh16: svg(16, path(v(1, 10, 13), h(10, 1, 4), v(14, 2, 5), h(5, 11, 14)) + slant('M14.46 8.68A6.5 6.5 0 0 1 2 10.5M1.54 7.32A6.5 6.5 0 0 1 14 5.5')),
|
||||||
|
|
||||||
|
// ---- the rest ----
|
||||||
|
link16: svg(16, slant('M7.7 4.8L9.23 3.23A2.5 2.5 0 0 1 12.77 6.77L11.2 8.3M8.3 11.2L6.77 12.77A2.5 2.5 0 0 1 3.23 9.23L4.8 7.7M9.5 6.5l-3 3')),
|
||||||
|
music16: svg(16, path(h(2, 4, 12), h(5, 4, 12), v(4, 2, 12), v(12, 2, 12)) + fill(circle(3, 13, 2), circle(11, 13, 2))),
|
||||||
|
image16: svg(16, path(box(1, 1, 14, 14)) + slant('M1.5 10.5l4-4 8 8', circle(10.5, 5.5, 1))),
|
||||||
|
edit14: svg(14, path(v(2, 9, 11), h(11, 2, 4)) + slant('M2.5 8.5l7-7 2 2-7 7M7.5 3.5l2 2')),
|
||||||
|
};
|
||||||
@@ -7,7 +7,7 @@ declare global {
|
|||||||
interface Window {
|
interface Window {
|
||||||
__APP_PLATFORM?: string;
|
__APP_PLATFORM?: string;
|
||||||
/** Which root component this window boots. */
|
/** Which root component this window boots. */
|
||||||
__APP_MODE?: 'main' | 'editor' | 'manager';
|
__APP_MODE?: 'main' | 'editor' | 'manager' | 'settings';
|
||||||
/** Window-kind specific context (EditorOpenContext for editor windows). */
|
/** Window-kind specific context (EditorOpenContext for editor windows). */
|
||||||
__APP_CTX?: unknown;
|
__APP_CTX?: unknown;
|
||||||
}
|
}
|
||||||
@@ -15,21 +15,34 @@ declare global {
|
|||||||
|
|
||||||
export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?? '') : '';
|
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 = PLATFORM === 'darwin';
|
||||||
export const IS_MAC_CHROME = PLATFORM === 'darwin';
|
|
||||||
|
|
||||||
export const IS_WINDOWS = PLATFORM === 'win32';
|
export const IS_WINDOWS = PLATFORM === 'win32';
|
||||||
|
|
||||||
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. */
|
export const IS_LINUX = PLATFORM === 'linux';
|
||||||
export const MOD_LABEL = IS_MAC_CHROME ? '⌘' : 'Ctrl+';
|
|
||||||
export const ALT_LABEL = IS_MAC_CHROME ? '⌥' : 'Alt+';
|
|
||||||
|
|
||||||
export const APP_MODE: 'main' | 'editor' | 'manager' =
|
export const APP_MODE: 'main' | 'editor' | 'manager' | 'settings' =
|
||||||
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
|
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
|
||||||
|
|
||||||
export const EDITOR_CONTEXT =
|
export const EDITOR_CONTEXT =
|
||||||
typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined;
|
typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined;
|
||||||
|
|
||||||
|
/** True when macOS traffic lights overlay the app's own header row (the fused
|
||||||
|
* hiddenInset layout, see chromeOptions), which reserves `pl-24` for them. */
|
||||||
|
export const IS_MAC_CHROME = IS_MAC;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebView2 (Windows) reloads the whole page on F5 / Ctrl+R / Ctrl+F5 /
|
||||||
|
* Ctrl+Shift+R unless the page claims the key — dropping every session and any
|
||||||
|
* unsaved editor text. Every window root calls this for keys none of its
|
||||||
|
* bindings took. Known ceiling: unverified whether Electrobun already turns
|
||||||
|
* WebView2's browser keys off; harmless if it does.
|
||||||
|
*/
|
||||||
|
export function blockReloadKeys(event: KeyboardEvent): void {
|
||||||
|
if (!IS_WINDOWS || event.defaultPrevented) return;
|
||||||
|
if (event.key === 'F5' || (event.ctrlKey && event.code === 'KeyR')) event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kill the webview's default right-click menu (Reload, Inspect Element…)
|
* Kill the webview's default right-click menu (Reload, Inspect Element…)
|
||||||
* everywhere the app doesn't show its own native menu. Text fields keep the
|
* everywhere the app doesn't show its own native menu. Text fields keep the
|
||||||
@@ -3,6 +3,13 @@ import { Electroview } from 'electrobun/view';
|
|||||||
import type { AppRPC } from '@shared/rpc';
|
import type { AppRPC } from '@shared/rpc';
|
||||||
import type { AppSettings, TransferSnapshot } from '@shared/types';
|
import type { AppSettings, TransferSnapshot } from '@shared/types';
|
||||||
|
|
||||||
|
export type UpdateStatus = {
|
||||||
|
phase: 'available' | 'downloading' | 'error';
|
||||||
|
version?: string;
|
||||||
|
progress?: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
// Matches MAX_REQUEST_TIME_MS on the bun side — SFTP ops can be slow.
|
// Matches MAX_REQUEST_TIME_MS on the bun side — SFTP ops can be slow.
|
||||||
const MAX_REQUEST_TIME_MS = 10 * 60_000;
|
const MAX_REQUEST_TIME_MS = 10 * 60_000;
|
||||||
|
|
||||||
@@ -24,6 +31,7 @@ export class RpcService {
|
|||||||
private editorCloseListeners = new Set<() => void>();
|
private editorCloseListeners = new Set<() => void>();
|
||||||
private managerStateListeners = new Set<(open: boolean) => void>();
|
private managerStateListeners = new Set<(open: boolean) => void>();
|
||||||
private connectionsListeners = new Set<() => void>();
|
private connectionsListeners = new Set<() => void>();
|
||||||
|
private updateStatusListeners = new Set<(status: UpdateStatus) => void>();
|
||||||
|
|
||||||
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
|
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
|
||||||
private ready: Promise<void> = Promise.resolve();
|
private ready: Promise<void> = Promise.resolve();
|
||||||
@@ -60,14 +68,18 @@ export class RpcService {
|
|||||||
connectionsChanged: () => {
|
connectionsChanged: () => {
|
||||||
for (const listener of this.connectionsListeners) listener();
|
for (const listener of this.connectionsListeners) listener();
|
||||||
},
|
},
|
||||||
|
updateStatus: (status) => {
|
||||||
|
for (const listener of this.updateStatusListeners) listener(status);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const electroview = new Electroview({ rpc: this.rpc });
|
const electroview = new Electroview({ rpc: this.rpc });
|
||||||
|
|
||||||
// Requests fired before the RPC WebSocket finishes connecting are lost, so
|
// Gate everything below on the RPC WebSocket being open (services fire calls
|
||||||
// everything below gates on it being open (services fire calls at startup).
|
// at startup). v1 silently lost pre-open sends; 2.x Electroview queues them
|
||||||
const socket = electroview.bunSocket;
|
// and falls back to the native bridge, so this is now just belt-and-braces.
|
||||||
|
const socket = electroview.hostSocket;
|
||||||
if (socket && socket.readyState !== WebSocket.OPEN) {
|
if (socket && socket.readyState !== WebSocket.OPEN) {
|
||||||
this.ready = new Promise((resolve) => {
|
this.ready = new Promise((resolve) => {
|
||||||
socket.addEventListener('open', () => resolve(), { once: true });
|
socket.addEventListener('open', () => resolve(), { once: true });
|
||||||
@@ -88,6 +100,15 @@ export class RpcService {
|
|||||||
this.menuListeners.add(listener);
|
this.menuListeners.add(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch an action that originated in the UI (the HTML context menu) as if
|
||||||
|
* it had arrived from bun, so app.ts keeps ONE handler for menu actions
|
||||||
|
* regardless of whether the app menubar or a right-click produced them.
|
||||||
|
*/
|
||||||
|
emitMenuAction(action: string): void {
|
||||||
|
for (const listener of this.menuListeners) listener(action);
|
||||||
|
}
|
||||||
|
|
||||||
onSettingsChanged(listener: (settings: AppSettings) => void): void {
|
onSettingsChanged(listener: (settings: AppSettings) => void): void {
|
||||||
this.settingsListeners.add(listener);
|
this.settingsListeners.add(listener);
|
||||||
}
|
}
|
||||||
@@ -108,6 +129,10 @@ export class RpcService {
|
|||||||
this.connectionsListeners.add(listener);
|
this.connectionsListeners.add(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onUpdateStatus(listener: (status: UpdateStatus) => void): void {
|
||||||
|
this.updateStatusListeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
/** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */
|
/** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */
|
||||||
get request() {
|
get request() {
|
||||||
const rpc = this.rpc;
|
const rpc = this.rpc;
|
||||||
@@ -1,23 +1,37 @@
|
|||||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
import { DEFAULT_SETTINGS, type AppSettings, type ColumnKey } from '@shared/types';
|
import { resolveLocale } from '@shared/i18n';
|
||||||
|
import { clampUiScale, DEFAULT_SETTINGS, type AppSettings, type ColumnKey } from '@shared/types';
|
||||||
|
import { I18nService } from './i18n.service';
|
||||||
import { RpcService } from './rpc.service';
|
import { RpcService } from './rpc.service';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class SettingsService {
|
export class SettingsService {
|
||||||
|
readonly locale = signal<AppSettings['locale']>(DEFAULT_SETTINGS.locale);
|
||||||
readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme);
|
readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme);
|
||||||
readonly darkTone = signal<AppSettings['darkTone']>(DEFAULT_SETTINGS.darkTone);
|
readonly darkTone = signal<AppSettings['darkTone']>(DEFAULT_SETTINGS.darkTone);
|
||||||
readonly accent = signal<AppSettings['accent']>(DEFAULT_SETTINGS.accent);
|
|
||||||
readonly iconColors = signal<AppSettings['iconColors']>(DEFAULT_SETTINGS.iconColors);
|
|
||||||
readonly columns = signal<ColumnKey[]>(DEFAULT_SETTINGS.columns);
|
readonly columns = signal<ColumnKey[]>(DEFAULT_SETTINGS.columns);
|
||||||
readonly speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit);
|
readonly speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit);
|
||||||
readonly textSize = signal<AppSettings['textSize']>(DEFAULT_SETTINGS.textSize);
|
readonly uiScale = signal<number>(DEFAULT_SETTINGS.uiScale);
|
||||||
readonly transferMode = signal<AppSettings['transferMode']>(DEFAULT_SETTINGS.transferMode);
|
readonly transferMode = signal<AppSettings['transferMode']>(DEFAULT_SETTINGS.transferMode);
|
||||||
readonly transferConcurrency = signal(DEFAULT_SETTINGS.transferConcurrency);
|
readonly transferConcurrency = signal(DEFAULT_SETTINGS.transferConcurrency);
|
||||||
readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted);
|
readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted);
|
||||||
readonly editor = signal<AppSettings['editor']>(DEFAULT_SETTINGS.editor);
|
readonly editor = signal<AppSettings['editor']>(DEFAULT_SETTINGS.editor);
|
||||||
readonly editorApp = signal(DEFAULT_SETTINGS.editorApp);
|
readonly editorApp = signal(DEFAULT_SETTINGS.editorApp);
|
||||||
|
/** Settings → Keyboard overrides; ShortcutsService applies them to the keymap. */
|
||||||
|
readonly keybindings = signal<AppSettings['keybindings']>(DEFAULT_SETTINGS.keybindings);
|
||||||
|
readonly openFilesOnEnter = signal(DEFAULT_SETTINGS.openFilesOnEnter);
|
||||||
|
// Auto-refresh is always on for now (see panels.service). To make it
|
||||||
|
// user-controllable: uncomment the field in types.ts, then here add
|
||||||
|
// readonly autoRefresh = signal(DEFAULT_SETTINGS.autoRefresh);
|
||||||
|
// load it in the constructor, add setAutoRefresh(), include it in settings(),
|
||||||
|
// gate the poll/watch in panels.service on it, and add the tab-bar row below.
|
||||||
|
|
||||||
private readonly rpc = inject(RpcService);
|
private readonly rpc = inject(RpcService);
|
||||||
|
private readonly i18n = inject(I18nService);
|
||||||
|
/** Saves are whole-object: one sent before the first getSettings lands would
|
||||||
|
* write these defaults over the user's settings (a ⌘+ pressed as a window
|
||||||
|
* opens would wipe every keyboard remap), so nothing persists until then. */
|
||||||
|
private loaded = false;
|
||||||
|
|
||||||
/** Whether the app is actually rendering dark right now (drives the Tone row). */
|
/** Whether the app is actually rendering dark right now (drives the Tone row). */
|
||||||
readonly effectiveDark = computed(
|
readonly effectiveDark = computed(
|
||||||
@@ -29,8 +43,6 @@ export class SettingsService {
|
|||||||
window
|
window
|
||||||
.matchMedia('(prefers-color-scheme: dark)')
|
.matchMedia('(prefers-color-scheme: dark)')
|
||||||
.addEventListener('change', (e) => this.systemDark.set(e.matches));
|
.addEventListener('change', (e) => this.systemDark.set(e.matches));
|
||||||
// the default accent is not the CSS default — apply before first paint
|
|
||||||
this.applyAccent();
|
|
||||||
if (!this.rpc.available) return;
|
if (!this.rpc.available) return;
|
||||||
void this.rpc.request.getSettings({}).then((res) => {
|
void this.rpc.request.getSettings({}).then((res) => {
|
||||||
if (res.ok) this.applyAll(res.settings);
|
if (res.ok) this.applyAll(res.settings);
|
||||||
@@ -40,22 +52,30 @@ export class SettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applyAll(settings: AppSettings): void {
|
private applyAll(settings: AppSettings): void {
|
||||||
|
this.loaded = true;
|
||||||
|
this.locale.set(settings.locale);
|
||||||
this.theme.set(settings.theme);
|
this.theme.set(settings.theme);
|
||||||
this.darkTone.set(settings.darkTone);
|
this.darkTone.set(settings.darkTone);
|
||||||
this.accent.set(settings.accent);
|
|
||||||
this.iconColors.set(settings.iconColors);
|
|
||||||
this.columns.set(settings.columns);
|
this.columns.set(settings.columns);
|
||||||
this.speedUnit.set(settings.speedUnit);
|
this.speedUnit.set(settings.speedUnit);
|
||||||
this.textSize.set(settings.textSize);
|
this.uiScale.set(settings.uiScale);
|
||||||
this.transferMode.set(settings.transferMode);
|
this.transferMode.set(settings.transferMode);
|
||||||
this.transferConcurrency.set(settings.transferConcurrency);
|
this.transferConcurrency.set(settings.transferConcurrency);
|
||||||
this.autoClearCompleted.set(settings.autoClearCompleted);
|
this.autoClearCompleted.set(settings.autoClearCompleted);
|
||||||
this.editor.set(settings.editor);
|
this.editor.set(settings.editor);
|
||||||
this.editorApp.set(settings.editorApp);
|
this.editorApp.set(settings.editorApp);
|
||||||
|
this.keybindings.set(settings.keybindings);
|
||||||
|
this.openFilesOnEnter.set(settings.openFilesOnEnter);
|
||||||
this.applyTheme();
|
this.applyTheme();
|
||||||
this.applyDarkTone();
|
this.applyDarkTone();
|
||||||
this.applyAccent();
|
this.applyUiScale();
|
||||||
this.applyTextSize();
|
this.applyLocale();
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocale(locale: AppSettings['locale']): void {
|
||||||
|
this.locale.set(locale);
|
||||||
|
this.applyLocale();
|
||||||
|
void this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
setTheme(theme: AppSettings['theme']): void {
|
setTheme(theme: AppSettings['theme']): void {
|
||||||
@@ -70,18 +90,6 @@ export class SettingsService {
|
|||||||
void this.persist();
|
void this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
setAccent(tone: AppSettings['accent']): void {
|
|
||||||
this.accent.set(tone);
|
|
||||||
this.applyAccent();
|
|
||||||
void this.persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
setIconColors(mode: AppSettings['iconColors']): void {
|
|
||||||
// no apply step — the file panels read the signal reactively
|
|
||||||
this.iconColors.set(mode);
|
|
||||||
void this.persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleColumn(key: ColumnKey): void {
|
toggleColumn(key: ColumnKey): void {
|
||||||
this.columns.update((list) =>
|
this.columns.update((list) =>
|
||||||
list.includes(key) ? list.filter((c) => c !== key) : [...list, key],
|
list.includes(key) ? list.filter((c) => c !== key) : [...list, key],
|
||||||
@@ -94,9 +102,9 @@ export class SettingsService {
|
|||||||
void this.persist();
|
void this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
setTextSize(size: AppSettings['textSize']): void {
|
setUiScale(scale: number): void {
|
||||||
this.textSize.set(size);
|
this.uiScale.set(clampUiScale(scale));
|
||||||
this.applyTextSize();
|
this.applyUiScale();
|
||||||
void this.persist();
|
void this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +133,16 @@ export class SettingsService {
|
|||||||
void this.persist();
|
void this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setKeybindings(keybindings: AppSettings['keybindings']): void {
|
||||||
|
this.keybindings.set(keybindings);
|
||||||
|
void this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpenFilesOnEnter(open: boolean): void {
|
||||||
|
this.openFilesOnEnter.set(open);
|
||||||
|
void this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
private applyTheme(): void {
|
private applyTheme(): void {
|
||||||
const theme = this.theme();
|
const theme = this.theme();
|
||||||
if (theme === 'system') {
|
if (theme === 'system') {
|
||||||
@@ -143,42 +161,44 @@ export class SettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyAccent(): void {
|
/** The whole interface scale is ONE declaration: every size in the app is
|
||||||
const tone = this.accent();
|
* rem, so moving the root font-size moves the whole UI. 1 = the
|
||||||
if (tone === 'graphite') {
|
* browser-default 16px root; delete-when-default keeps the pristine state
|
||||||
delete document.documentElement.dataset['accent'];
|
* attribute-free like the other axes. buildPreload mirrors this for a
|
||||||
|
* flash-free first paint. */
|
||||||
|
private applyUiScale(): void {
|
||||||
|
const scale = this.uiScale();
|
||||||
|
if (scale === 1) {
|
||||||
|
document.documentElement.style.removeProperty('font-size');
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.dataset['accent'] = tone;
|
document.documentElement.style.fontSize = `${16 * scale}px`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyTextSize(): void {
|
private applyLocale(): void {
|
||||||
const size = this.textSize();
|
this.i18n.locale.set(resolveLocale(this.locale(), navigator.language));
|
||||||
if (size === 'default') {
|
|
||||||
delete document.documentElement.dataset['text'];
|
|
||||||
} else {
|
|
||||||
document.documentElement.dataset['text'] = size;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected settings(): AppSettings {
|
protected settings(): AppSettings {
|
||||||
return {
|
return {
|
||||||
|
locale: this.locale(),
|
||||||
theme: this.theme(),
|
theme: this.theme(),
|
||||||
darkTone: this.darkTone(),
|
darkTone: this.darkTone(),
|
||||||
accent: this.accent(),
|
|
||||||
iconColors: this.iconColors(),
|
|
||||||
columns: this.columns(),
|
columns: this.columns(),
|
||||||
speedUnit: this.speedUnit(),
|
speedUnit: this.speedUnit(),
|
||||||
textSize: this.textSize(),
|
uiScale: this.uiScale(),
|
||||||
transferMode: this.transferMode(),
|
transferMode: this.transferMode(),
|
||||||
transferConcurrency: this.transferConcurrency(),
|
transferConcurrency: this.transferConcurrency(),
|
||||||
autoClearCompleted: this.autoClearCompleted(),
|
autoClearCompleted: this.autoClearCompleted(),
|
||||||
editor: this.editor(),
|
editor: this.editor(),
|
||||||
editorApp: this.editorApp(),
|
editorApp: this.editorApp(),
|
||||||
|
keybindings: this.keybindings(),
|
||||||
|
openFilesOnEnter: this.openFilesOnEnter(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async persist(): Promise<void> {
|
private async persist(): Promise<void> {
|
||||||
|
if (!this.loaded) return;
|
||||||
await this.rpc.request.saveSettings({ settings: this.settings() });
|
await this.rpc.request.saveSettings({ settings: this.settings() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { Injectable, computed, inject } from '@angular/core';
|
||||||
|
import {
|
||||||
|
type Combo,
|
||||||
|
EN_KEY_NAMES,
|
||||||
|
effectiveKeymap,
|
||||||
|
firesInTextField,
|
||||||
|
formatCombo,
|
||||||
|
isRepeatable,
|
||||||
|
type KeyEventLike,
|
||||||
|
type KeyNames,
|
||||||
|
matchesEvent,
|
||||||
|
parseCombo,
|
||||||
|
type ResolvedBinding,
|
||||||
|
type Scope,
|
||||||
|
sameCombo,
|
||||||
|
serializeCombo,
|
||||||
|
type ShortcutId,
|
||||||
|
} from '@shared/keymap';
|
||||||
|
import { I18nService } from './i18n.service';
|
||||||
|
import { IS_MAC } from './platform';
|
||||||
|
import { SettingsService } from './settings.service';
|
||||||
|
|
||||||
|
export type { ShortcutId } from '@shared/keymap';
|
||||||
|
|
||||||
|
/** A keydown as resolve() reads it: the matching fields plus where it came from. */
|
||||||
|
type KeyInput = KeyEventLike & { target?: EventTarget | null; isComposing?: boolean; keyCode?: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The keyboard layer over the shared keymap (src/shared/keymap.ts): resolves
|
||||||
|
* keydowns against the EFFECTIVE keymap — defaults plus the Settings → Keyboard
|
||||||
|
* remaps, live in every window — and formats the hints menus and tooltips show.
|
||||||
|
*
|
||||||
|
* - Matching is exact: ⌃F is not ⌘F, Ctrl+Tab is not Tab. `Mod` is ⌘ on
|
||||||
|
* macOS and Ctrl elsewhere.
|
||||||
|
* - On macOS the PAGE sees ⌘-keys first: WKWebView hands key equivalents to the
|
||||||
|
* page before the app menu (WebKit's WebViewImpl::performKeyEquivalent), so a
|
||||||
|
* key a window claims here (claim() → preventDefault) never reaches the menu
|
||||||
|
* bar, whose accelerators bun builds from the same table. Keys nobody claims
|
||||||
|
* fall through to it.
|
||||||
|
* - From a text field only firesInTextField() combos count (⌘S yes, Delete no).
|
||||||
|
* - Escape, the arrows inside menus and dialog Enter stay component-local:
|
||||||
|
* they are fixed, so nobody can remap themselves out of a dialog.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ShortcutsService {
|
||||||
|
private readonly settings = inject(SettingsService);
|
||||||
|
private readonly i18n = inject(I18nService);
|
||||||
|
|
||||||
|
readonly keymap = computed(() => effectiveKeymap(this.settings.keybindings(), IS_MAC));
|
||||||
|
private readonly byId = computed(() => new Map(this.keymap().map((b) => [b.id, b])));
|
||||||
|
/** Localized key names for the non-mac spelling ("Strg+Entf"); reading t()
|
||||||
|
* makes every hint follow a language switch. */
|
||||||
|
private readonly names = computed<KeyNames>(() => {
|
||||||
|
const names = { ...EN_KEY_NAMES };
|
||||||
|
for (const key of Object.keys(names) as (keyof KeyNames)[]) {
|
||||||
|
names[key] = this.i18n.t(`keys.names.${key}`);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The command a keydown triggers in `scope`, or null. Window scopes include
|
||||||
|
* `any`; `panel` doesn't (the window root runs those). */
|
||||||
|
resolve(event: KeyInput, scope: Scope): ShortcutId | null {
|
||||||
|
// an IME's Enter commits a candidate, it is never a shortcut
|
||||||
|
if (event.isComposing || event.keyCode === 229) return null;
|
||||||
|
const typing = this.isTypingTarget(event);
|
||||||
|
for (const binding of this.keymap()) {
|
||||||
|
if (!binding.scopes.includes(scope) && (scope === 'panel' || !binding.scopes.includes('any'))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const combo of binding.combos) {
|
||||||
|
if (typing && !firesInTextField(combo, IS_MAC)) continue;
|
||||||
|
if (matchesEvent(combo, event, IS_MAC)) return binding.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* resolve() + take the key: a match is preventDefault-ed, so the macOS menu
|
||||||
|
* never runs it a second time. A held key repeats only movement-like
|
||||||
|
* commands; any other repeat is swallowed (holding ⌘W closes one tab).
|
||||||
|
*/
|
||||||
|
claim(event: KeyboardEvent, scope: Scope): ShortcutId | null {
|
||||||
|
const id = this.resolve(event, scope);
|
||||||
|
if (!id) return null;
|
||||||
|
event.preventDefault();
|
||||||
|
return event.repeat && !isRepeatable(id) ? null : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a keydown is one of `id`'s combos (an open menu matching its items). */
|
||||||
|
matches(id: ShortcutId, event: KeyEventLike): boolean {
|
||||||
|
return !!this.byId().get(id)?.combos.some((c) => matchesEvent(c, event, IS_MAC));
|
||||||
|
}
|
||||||
|
|
||||||
|
binding(id: ShortcutId): ResolvedBinding | undefined {
|
||||||
|
return this.byId().get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
format(combo: Combo): string {
|
||||||
|
return formatCombo(combo, IS_MAC, this.names());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The hint menus show: the command's first key, or '' when unbound. */
|
||||||
|
label(id: ShortcutId): string {
|
||||||
|
const first = this.byId().get(id)?.combos[0];
|
||||||
|
return first ? this.format(first) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
labels(id: ShortcutId): string[] {
|
||||||
|
return (this.byId().get(id)?.combos ?? []).map((c) => this.format(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tooltip with the command's keys: "Delete (F8 / Del)"; bare when unbound. */
|
||||||
|
tip(text: string, id: ShortcutId): string {
|
||||||
|
const keys = this.labels(id);
|
||||||
|
return keys.length ? `${text} (${keys.join(' / ')})` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A FIXED key — one no remap moves, such as Enter in the find box — in
|
||||||
|
* the platform's spelling (⇧↩ / Shift+Enter). */
|
||||||
|
fixedKey(combo: string): string {
|
||||||
|
const parsed = parseCombo(combo, IS_MAC);
|
||||||
|
return parsed ? this.format(parsed) : combo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** tip() for a fixed key. */
|
||||||
|
tipFixed(text: string, combo: string): string {
|
||||||
|
return `${text} (${this.fixedKey(combo)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store new combos for commands (Settings → Keyboard). Only rows that differ
|
||||||
|
* from their defaults are kept, so a reset — or a remap back to the
|
||||||
|
* default — leaves nothing behind.
|
||||||
|
*/
|
||||||
|
rebind(changes: Partial<Record<ShortcutId, Combo[]>>): void {
|
||||||
|
const next = { ...this.settings.keybindings() };
|
||||||
|
for (const [id, combos] of Object.entries(changes) as [ShortcutId, Combo[]][]) {
|
||||||
|
const defaults = this.byId().get(id)?.defaults ?? [];
|
||||||
|
const isDefault =
|
||||||
|
combos.length === defaults.length && combos.every((c, i) => sameCombo(c, defaults[i]));
|
||||||
|
if (isDefault) delete next[id];
|
||||||
|
else next[id] = combos.map(serializeCombo);
|
||||||
|
}
|
||||||
|
this.settings.setKeybindings(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetAll(): void {
|
||||||
|
this.settings.setKeybindings({});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The platform command modifier: ⌘ on macOS, Ctrl elsewhere — exactly one.
|
||||||
|
* Mouse events too: Mod+click toggle-selects in the panels. */
|
||||||
|
isMod(event: KeyboardEvent | MouseEvent): boolean {
|
||||||
|
return IS_MAC ? event.metaKey : event.ctrlKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the event comes from something the user types into. */
|
||||||
|
isTypingTarget(event: { target?: EventTarget | null }): boolean {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
return !!target?.closest?.(
|
||||||
|
'textarea, select, [contenteditable="true"], input:not([type=checkbox]):not([type=radio]):not([type=button])',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { DialogsService } from './dialogs.service';
|
|||||||
import { PanelsService, type Side } from './panels.service';
|
import { PanelsService, type Side } from './panels.service';
|
||||||
import { RpcService } from './rpc.service';
|
import { RpcService } from './rpc.service';
|
||||||
import { SettingsService } from './settings.service';
|
import { SettingsService } from './settings.service';
|
||||||
|
import { I18nService } from './i18n.service';
|
||||||
import { ToastService } from './toast.service';
|
import { ToastService } from './toast.service';
|
||||||
|
|
||||||
const AUTO_DISMISS_COMPLETED_MS = 5000;
|
const AUTO_DISMISS_COMPLETED_MS = 5000;
|
||||||
@@ -13,6 +14,7 @@ const SPEED_SMOOTHING = 0.7;
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class TransfersService {
|
export class TransfersService {
|
||||||
|
private readonly i18n = inject(I18nService);
|
||||||
private readonly rpc = inject(RpcService);
|
private readonly rpc = inject(RpcService);
|
||||||
private readonly panels = inject(PanelsService);
|
private readonly panels = inject(PanelsService);
|
||||||
private readonly toast = inject(ToastService);
|
private readonly toast = inject(ToastService);
|
||||||
@@ -53,19 +55,19 @@ export class TransfersService {
|
|||||||
(e) => e.type !== 'symlink' && e.name !== '..',
|
(e) => e.type !== 'symlink' && e.name !== '..',
|
||||||
);
|
);
|
||||||
if (!targets.length) {
|
if (!targets.length) {
|
||||||
this.toast.info('Nothing to copy (symlinks are skipped)');
|
this.toast.info(this.i18n.t('toasts.nothingToCopy'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sameFs = src.fsKey() === dst.fsKey();
|
const sameFs = src.fsKey() === dst.fsKey();
|
||||||
if (sameFs && src.cwd() === dst.cwd()) {
|
if (sameFs && src.cwd() === dst.cwd()) {
|
||||||
this.toast.info('Both panels show the same folder');
|
this.toast.info(this.i18n.t('toasts.sameFolder'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalTargets = await this.resolveCollisions(targets, dst.entries(), sameFs);
|
const finalTargets = await this.resolveCollisions(targets, dst.entries(), sameFs);
|
||||||
if (finalTargets === null) return; // user cancelled
|
if (finalTargets === null) return; // user cancelled
|
||||||
if (!finalTargets.length) {
|
if (!finalTargets.length) {
|
||||||
this.toast.info('Nothing to copy — all conflicting items were skipped');
|
this.toast.info(this.i18n.t('toasts.nothingToCopyAllSkipped'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,11 +107,11 @@ export class TransfersService {
|
|||||||
}
|
}
|
||||||
if (sameFs && dstEntry.path === target.path) {
|
if (sameFs && dstEntry.path === target.path) {
|
||||||
// replacing a file with ITSELF would only destroy it — never offer that
|
// replacing a file with ITSELF would only destroy it — never offer that
|
||||||
this.toast.info(`Skipped "${target.name}" — it is the same file`);
|
this.toast.info(this.i18n.t('toasts.sameFile', { name: target.name }));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if ((dstEntry.type === 'dir') !== (target.type === 'dir')) {
|
if ((dstEntry.type === 'dir') !== (target.type === 'dir')) {
|
||||||
this.toast.info(`Skipped "${target.name}" — a different kind of item is in the way`);
|
this.toast.info(this.i18n.t('toasts.typeMismatch', { name: target.name }));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (mode === 'all') {
|
if (mode === 'all') {
|
||||||
@@ -178,7 +180,12 @@ export class TransfersService {
|
|||||||
}
|
}
|
||||||
} else if (snapshot.state === 'failed') {
|
} else if (snapshot.state === 'failed') {
|
||||||
this.expanded.set(true);
|
this.expanded.set(true);
|
||||||
this.toast.error(`Transfer "${snapshot.name}" failed: ${snapshot.error ?? 'unknown error'}`);
|
this.toast.error(
|
||||||
|
this.i18n.t('toasts.transferFailed', {
|
||||||
|
name: snapshot.name,
|
||||||
|
error: snapshot.error ?? this.i18n.t('toasts.unknownError'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
this.refreshPanelsOn(snapshot.dstSessionId);
|
this.refreshPanelsOn(snapshot.dstSessionId);
|
||||||
} else if (snapshot.state === 'cancelled') {
|
} else if (snapshot.state === 'cancelled') {
|
||||||
this.refreshPanelsOn(snapshot.dstSessionId);
|
this.refreshPanelsOn(snapshot.dstSessionId);
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
|
import { RpcService } from './rpc.service';
|
||||||
|
|
||||||
|
export type UpdaterState = 'idle' | 'available' | 'downloading' | 'ready' | 'error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the tab-bar update pill. The backend pushes updateStatus events
|
||||||
|
* (available / downloading progress / error); "ready" is derived locally when
|
||||||
|
* downloadUpdate resolves ok. Two-step UX: available -> download -> ready ->
|
||||||
|
* apply (relaunch).
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class UpdaterService {
|
||||||
|
private rpc = inject(RpcService);
|
||||||
|
|
||||||
|
readonly state = signal<UpdaterState>('idle');
|
||||||
|
readonly version = signal('');
|
||||||
|
readonly progress = signal(0);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (!this.rpc.available) return;
|
||||||
|
this.rpc.onUpdateStatus((s) => {
|
||||||
|
if (s.phase === 'available') {
|
||||||
|
this.version.set(s.version ?? '');
|
||||||
|
this.state.set('available');
|
||||||
|
} else if (s.phase === 'downloading') {
|
||||||
|
this.state.set('downloading');
|
||||||
|
this.progress.set(s.progress ?? 0);
|
||||||
|
} else if (s.phase === 'error') {
|
||||||
|
this.state.set('error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async download(): Promise<void> {
|
||||||
|
if (this.state() !== 'available') return;
|
||||||
|
this.state.set('downloading');
|
||||||
|
this.progress.set(0);
|
||||||
|
const r = await this.rpc.request.downloadUpdate({});
|
||||||
|
this.state.set(r?.ok ? 'ready' : 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(): void {
|
||||||
|
if (this.state() !== 'ready') return;
|
||||||
|
void this.rpc.request.applyUpdate({}); // quits + relaunches
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pill's one action, also the "Install update" key: download when
|
||||||
|
* available, apply (relaunch) once ready; nothing otherwise. */
|
||||||
|
advance(): void {
|
||||||
|
const state = this.state();
|
||||||
|
if (state === 'available') void this.download();
|
||||||
|
else if (state === 'ready') this.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manual re-check (menu item). */
|
||||||
|
async check(): Promise<void> {
|
||||||
|
if (!this.rpc.available) return;
|
||||||
|
const r = await this.rpc.request.checkForUpdate({});
|
||||||
|
if (r?.ok && r.updateAvailable) {
|
||||||
|
this.version.set(r.version);
|
||||||
|
this.state.set('available');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
import {
|
|
||||||
Component,
|
|
||||||
ElementRef,
|
|
||||||
afterNextRender,
|
|
||||||
computed,
|
|
||||||
inject,
|
|
||||||
input,
|
|
||||||
viewChild,
|
|
||||||
} from '@angular/core';
|
|
||||||
import { NgIcon } from '@ng-icons/core';
|
|
||||||
import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '../dialogs.service';
|
|
||||||
import { formatDate, formatSize } from '../format';
|
|
||||||
import { FocusService } from '../focus.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-overwrite-dialog',
|
|
||||||
imports: [NgIcon],
|
|
||||||
template: `
|
|
||||||
<div
|
|
||||||
class="fixed inset-0 z-40 grid place-items-center bg-black/40"
|
|
||||||
(keydown.escape)="pick('cancel')"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-[26rem] rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-2">
|
|
||||||
<h2 class="min-w-0 text-sm font-semibold">
|
|
||||||
{{ isDir() ? 'Merge folder' : 'Replace' }}
|
|
||||||
<span class="font-mono">"{{ request().srcEntry.name }}"</span>?
|
|
||||||
</h2>
|
|
||||||
@if (request().total > 1) {
|
|
||||||
<span
|
|
||||||
class="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground"
|
|
||||||
>
|
|
||||||
{{ request().index }} of {{ request().total }}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
|
||||||
@if (isDir()) {
|
|
||||||
The destination already has a folder with this name. Merging may replace files inside
|
|
||||||
it.
|
|
||||||
} @else {
|
|
||||||
The destination already has a file with this name.
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- comparison -->
|
|
||||||
<div class="mt-3 grid grid-cols-2 gap-2">
|
|
||||||
@for (side of comparison(); track side.label) {
|
|
||||||
<div class="rounded-md border border-border p-2.5">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
|
|
||||||
{{ side.label }}
|
|
||||||
</span>
|
|
||||||
@if (side.newer) {
|
|
||||||
<span class="rounded-full bg-success/15 px-1.5 text-[10px] font-medium text-success">
|
|
||||||
newer
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="mt-1.5 flex items-center gap-1.5 text-xs">
|
|
||||||
<ng-icon
|
|
||||||
[name]="isDir() ? 'mynaFolder' : 'mynaFile'"
|
|
||||||
size="0.9rem"
|
|
||||||
class="shrink-0 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<span class="font-mono tabular-nums">
|
|
||||||
{{ isDir() ? '—' : formatSize(side.entry.size) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 font-mono text-[11px] tabular-nums text-muted-foreground">
|
|
||||||
{{ formatDate(side.entry.mtime) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- actions -->
|
|
||||||
<div class="mt-4 flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
(click)="pick('cancel')"
|
|
||||||
class="rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
@if (request().total > 1) {
|
|
||||||
<button
|
|
||||||
(click)="pick('skipAll')"
|
|
||||||
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
Skip all
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="pick('overwriteAll')"
|
|
||||||
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
{{ isDir() ? 'Merge all' : 'Replace all' }}
|
|
||||||
</button>
|
|
||||||
<div class="h-4 w-px bg-border"></div>
|
|
||||||
}
|
|
||||||
<button
|
|
||||||
(click)="pick('skip')"
|
|
||||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
|
||||||
>
|
|
||||||
Skip
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
#primaryBtn
|
|
||||||
(click)="pick('overwrite')"
|
|
||||||
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
{{ isDir() ? 'Merge' : 'Replace' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class OverwriteDialog {
|
|
||||||
readonly request = input.required<OverwriteRequest>();
|
|
||||||
|
|
||||||
readonly formatSize = formatSize;
|
|
||||||
readonly formatDate = formatDate;
|
|
||||||
|
|
||||||
private readonly dialogs = inject(DialogsService);
|
|
||||||
private readonly focus = inject(FocusService);
|
|
||||||
private readonly primaryBtn = viewChild.required<ElementRef<HTMLButtonElement>>('primaryBtn');
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
afterNextRender(() => this.primaryBtn().nativeElement.focus());
|
|
||||||
}
|
|
||||||
|
|
||||||
readonly isDir = computed(() => this.request().srcEntry.type === 'dir');
|
|
||||||
|
|
||||||
readonly comparison = computed(() => {
|
|
||||||
const { srcEntry, dstEntry } = this.request();
|
|
||||||
return [
|
|
||||||
{ label: 'Source', entry: srcEntry, newer: srcEntry.mtime > dstEntry.mtime },
|
|
||||||
{ label: 'Destination', entry: dstEntry, newer: dstEntry.mtime > srcEntry.mtime },
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
pick(choice: OverwriteChoice): void {
|
|
||||||
const req = this.request();
|
|
||||||
this.dialogs.overwrite.set(null);
|
|
||||||
this.focus.bump();
|
|
||||||
req.resolve(choice);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
|
||||||
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';
|
|
||||||
|
|
||||||
/** The row (or empty space) the user last right-clicked; ctx-* actions apply here. */
|
|
||||||
export interface MenuTarget {
|
|
||||||
side: Side;
|
|
||||||
panel: PanelState;
|
|
||||||
entry: FileEntry | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* File operations invoked from a panel's toolbar / keyboard. They act on the
|
|
||||||
* GIVEN panel: on the selection if there is one, else on the cursor row.
|
|
||||||
*/
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class FileActionsService {
|
|
||||||
private readonly rpc = inject(RpcService);
|
|
||||||
private readonly toast = inject(ToastService);
|
|
||||||
private readonly dialogs = inject(DialogsService);
|
|
||||||
private readonly settings = inject(SettingsService);
|
|
||||||
|
|
||||||
menuTarget: MenuTarget | null = null;
|
|
||||||
|
|
||||||
/** Right-click entry point: remember the target and ask bun for a native menu. */
|
|
||||||
async openContextMenu(target: MenuTarget): Promise<void> {
|
|
||||||
this.menuTarget = target;
|
|
||||||
const { panel, entry } = target;
|
|
||||||
await this.rpc.request.showRowMenu({
|
|
||||||
path: entry?.path ?? panel.cwd(),
|
|
||||||
name: entry?.name ?? '',
|
|
||||||
type: entry?.type ?? 'dir',
|
|
||||||
isLocal: panel.sessionId() === 'local',
|
|
||||||
hasEntry: !!entry,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async trash(panel: PanelState, entry: FileEntry): Promise<void> {
|
|
||||||
const res = await this.rpc.request.moveToTrash({ path: entry.path });
|
|
||||||
if (!res.ok) this.toast.error(res.error);
|
|
||||||
else this.toast.info(`Moved "${entry.name}" to Trash`);
|
|
||||||
await panel.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Selection if non-empty, else the cursor entry. Never the ".." parent row. */
|
|
||||||
targets(panel: PanelState): FileEntry[] {
|
|
||||||
const selected = panel.selectedEntries();
|
|
||||||
if (selected.length) return selected;
|
|
||||||
const atCursor = panel.sorted()[panel.cursor()];
|
|
||||||
return atCursor && atCursor.name !== '..' ? [atCursor] : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdir(panel: PanelState): void {
|
|
||||||
this.dialogs.prompt.set({
|
|
||||||
title: 'New folder',
|
|
||||||
placeholder: 'folder name',
|
|
||||||
confirmLabel: 'Create',
|
|
||||||
onConfirm: async (name) => {
|
|
||||||
const res = await this.rpc.request.mkdir({
|
|
||||||
sessionId: panel.sessionId(),
|
|
||||||
path: joinPath(panel.cwd(), name),
|
|
||||||
});
|
|
||||||
if (!res.ok) this.toast.error(res.error);
|
|
||||||
await panel.refresh();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
createFile(panel: PanelState): void {
|
|
||||||
this.dialogs.prompt.set({
|
|
||||||
title: 'New file',
|
|
||||||
placeholder: 'file name',
|
|
||||||
confirmLabel: 'Create',
|
|
||||||
onConfirm: async (name) => {
|
|
||||||
const res = await this.rpc.request.createFile({
|
|
||||||
sessionId: panel.sessionId(),
|
|
||||||
path: joinPath(panel.cwd(), name),
|
|
||||||
});
|
|
||||||
if (!res.ok) this.toast.error(res.error);
|
|
||||||
await panel.refresh();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open the cursor file in the built-in editor or the configured external app. */
|
|
||||||
async edit(panel: PanelState): Promise<void> {
|
|
||||||
const entry = panel.sorted()[panel.cursor()];
|
|
||||||
if (!entry || entry.type !== 'file' || entry.name === '..') return;
|
|
||||||
|
|
||||||
if (this.settings.editor() === 'custom') {
|
|
||||||
const res = await this.rpc.request.editExternal({
|
|
||||||
sessionId: panel.sessionId(),
|
|
||||||
path: entry.path,
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
this.toast.error(res.error);
|
|
||||||
} else if (res.watching) {
|
|
||||||
this.toast.info(`Editing ${entry.name} — saves upload automatically`);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// built-in editor: its own native window (reads the file itself)
|
|
||||||
const res = await this.rpc.request.openEditor({
|
|
||||||
sessionId: panel.sessionId(),
|
|
||||||
path: entry.path,
|
|
||||||
name: entry.name,
|
|
||||||
sessionTitle: panel.title(),
|
|
||||||
});
|
|
||||||
if (!res.ok) this.toast.error(res.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
rename(panel: PanelState): void {
|
|
||||||
const entry = panel.sorted()[panel.cursor()];
|
|
||||||
if (!entry || entry.name === '..') return;
|
|
||||||
this.dialogs.prompt.set({
|
|
||||||
title: `Rename "${entry.name}"`,
|
|
||||||
initial: entry.name,
|
|
||||||
confirmLabel: 'Rename',
|
|
||||||
onConfirm: async (name) => {
|
|
||||||
if (name === entry.name) return;
|
|
||||||
const res = await this.rpc.request.rename({
|
|
||||||
sessionId: panel.sessionId(),
|
|
||||||
oldPath: entry.path,
|
|
||||||
newPath: joinPath(panel.cwd(), name),
|
|
||||||
});
|
|
||||||
if (!res.ok) this.toast.error(res.error);
|
|
||||||
await panel.refresh();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(panel: PanelState): void {
|
|
||||||
const targets = this.targets(panel);
|
|
||||||
if (!targets.length) return;
|
|
||||||
const what =
|
|
||||||
targets.length === 1 ? `"${targets[0].name}"` : `${targets.length} items`;
|
|
||||||
this.dialogs.confirm.set({
|
|
||||||
title: 'Delete',
|
|
||||||
body: `Delete ${what}? Folders are deleted recursively. This cannot be undone.`,
|
|
||||||
confirmLabel: 'Delete',
|
|
||||||
destructive: true,
|
|
||||||
onConfirm: async () => {
|
|
||||||
const res = await this.rpc.request.deleteEntries({
|
|
||||||
sessionId: panel.sessionId(),
|
|
||||||
entries: targets.map(({ path, type }) => ({ path, type })),
|
|
||||||
});
|
|
||||||
if (!res.ok) this.toast.error(res.error);
|
|
||||||
await panel.refresh();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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({
|
|
||||||
targets: targets.map((t) => t.name),
|
|
||||||
initialMode: targets[0].mode,
|
|
||||||
onConfirm: async (mode) => {
|
|
||||||
const res = await this.rpc.request.chmod({
|
|
||||||
sessionId: panel.sessionId(),
|
|
||||||
paths: targets.map((t) => t.path),
|
|
||||||
mode,
|
|
||||||
});
|
|
||||||
if (!res.ok) this.toast.error(res.error);
|
|
||||||
await panel.refresh();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
<!-- session strip -->
|
|
||||||
<div class="relative flex items-center gap-1.5 border-b border-border px-2 py-1">
|
|
||||||
<button
|
|
||||||
(click)="toggleMenu()"
|
|
||||||
class="flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1 text-(length:--path-font) font-medium hover:bg-accent"
|
|
||||||
title="Choose what this panel shows"
|
|
||||||
>
|
|
||||||
@if (panel().connecting()) {
|
|
||||||
<ng-icon name="mynaSpinner" class="shrink-0 animate-spin text-primary" size="var(--action-icon)" />
|
|
||||||
} @else {
|
|
||||||
<ng-icon
|
|
||||||
[name]="isRemote() ? 'mynaServers' : 'mynaHardDrive'"
|
|
||||||
[class]="isRemote() ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'"
|
|
||||||
size="var(--action-icon)"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
<span class="truncate">{{ panel().title() }}</span>
|
|
||||||
<ng-icon
|
|
||||||
name="mynaChevronDown"
|
|
||||||
class="shrink-0 text-muted-foreground"
|
|
||||||
size="calc(var(--action-icon) - 0.1rem)"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
|
|
||||||
<!-- panel actions: act on THIS panel. Frequent/destructive verbs grow text
|
|
||||||
labels when the panel is wide enough (container query). -->
|
|
||||||
<button
|
|
||||||
(click)="copyToOther()"
|
|
||||||
[title]="side() === 'left' ? 'Copy to right panel (F5)' : 'Copy to left panel (F5)'"
|
|
||||||
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-primary"
|
|
||||||
>
|
|
||||||
<ng-icon [name]="copyIcon()" size="var(--action-icon)" />
|
|
||||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">Copy</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="run('edit')"
|
|
||||||
title="Edit file (F4)"
|
|
||||||
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaFileText" size="var(--action-icon)" />
|
|
||||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">Edit</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="run('rename')"
|
|
||||||
title="Rename (F2)"
|
|
||||||
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaEdit" size="var(--action-icon)" />
|
|
||||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">Rename</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="run('mkdir')"
|
|
||||||
title="New folder (F7)"
|
|
||||||
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaFolderPlus" size="var(--action-icon)" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="run('createFile')"
|
|
||||||
title="New file"
|
|
||||||
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaFilePlus" 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)"
|
|
||||||
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-destructive"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaTrash" size="var(--action-icon)" />
|
|
||||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">Delete</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
@if (menuOpen()) {
|
|
||||||
<div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div>
|
|
||||||
<div
|
|
||||||
class="absolute top-full left-2 z-30 mt-1 max-h-80 w-64 overflow-y-auto rounded-lg border border-border bg-card py-1 text-card-foreground shadow-xl"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
(click)="pickLocal()"
|
|
||||||
class="flex w-full items-center gap-2 px-3 py-1.5 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">Local</span>
|
|
||||||
@if (!isRemote()) {
|
|
||||||
<ng-icon name="mynaCheck" size="0.85rem" class="shrink-0 text-primary" />
|
|
||||||
}
|
|
||||||
</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>
|
|
||||||
}
|
|
||||||
@for (connection of connections.grouped().loose; track connection.id) {
|
|
||||||
<button
|
|
||||||
(click)="pickConnection(connection)"
|
|
||||||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
|
||||||
<span class="flex-1 truncate">{{ connection.name }}</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
@for (group of connections.grouped().stacks; track group.stack.id) {
|
|
||||||
<div class="px-3 pt-1.5 pb-0.5 text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
|
|
||||||
{{ group.stack.name }}
|
|
||||||
</div>
|
|
||||||
@for (connection of group.connections; track connection.id) {
|
|
||||||
<button
|
|
||||||
(click)="pickConnection(connection)"
|
|
||||||
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
|
||||||
<span class="flex-1 truncate">{{ connection.name }}</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (connections.sshHosts().length) {
|
|
||||||
<div class="my-1 border-t border-border"></div>
|
|
||||||
<div class="px-3 pt-1.5 pb-0.5 text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
|
|
||||||
~/.ssh/config
|
|
||||||
</div>
|
|
||||||
@for (host of connections.sshHosts(); track host.name) {
|
|
||||||
<button
|
|
||||||
(click)="pickSshHost(host)"
|
|
||||||
[disabled]="host.unsupported"
|
|
||||||
[title]="sshHostTitle(host)"
|
|
||||||
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent disabled:opacity-60 disabled:hover:bg-transparent"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
|
||||||
<span class="flex-1 truncate">{{ host.name }}</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- path bar -->
|
|
||||||
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
|
||||||
<button
|
|
||||||
(click)="panel().up()"
|
|
||||||
[disabled]="blocked()"
|
|
||||||
title="Parent directory (Backspace)"
|
|
||||||
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaArrowUp" size="1rem" />
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
[value]="pathDraft()"
|
|
||||||
(input)="pathDraft.set($any($event.target).value)"
|
|
||||||
(keydown.enter)="commitPath()"
|
|
||||||
(keydown.escape)="resetPath()"
|
|
||||||
(keydown)="$event.stopPropagation()"
|
|
||||||
[disabled]="blocked()"
|
|
||||||
spellcheck="false"
|
|
||||||
class="h-(--path-ctl-h) min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono text-(length:--path-font) outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
(click)="panel().refresh()"
|
|
||||||
[disabled]="blocked()"
|
|
||||||
title="Refresh"
|
|
||||||
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
|
|
||||||
>
|
|
||||||
@if (panel().loading()) {
|
|
||||||
<ng-icon name="mynaSpinner" size="1rem" class="animate-spin" />
|
|
||||||
} @else {
|
|
||||||
<ng-icon name="mynaRefresh" size="1rem" />
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- column headers (right-click to add/remove columns) -->
|
|
||||||
<div
|
|
||||||
(contextmenu)="headerContextMenu($event)"
|
|
||||||
[style.grid-template-columns]="gridTemplate()"
|
|
||||||
class="grid border-b border-border text-[11px] font-medium text-muted-foreground"
|
|
||||||
>
|
|
||||||
<button class="px-2 py-1 text-left hover:text-foreground" (click)="panel().toggleSort('name')">
|
|
||||||
Name {{ sortIndicator('name') }}
|
|
||||||
</button>
|
|
||||||
@for (col of visibleColumns(); track col.key) {
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 hover:text-foreground"
|
|
||||||
[class.text-right]="col.key === 'size'"
|
|
||||||
[class.text-left]="col.key !== 'size'"
|
|
||||||
(click)="panel().toggleSort(col.key)"
|
|
||||||
>
|
|
||||||
{{ col.label }} {{ sortIndicator(col.key) }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- type-to-filter bar (appears while a query is active) -->
|
|
||||||
@if (panel().filter(); as query) {
|
|
||||||
<div class="flex items-center gap-1.5 border-b border-border bg-accent/50 px-2 py-1">
|
|
||||||
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
|
||||||
<span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span>
|
|
||||||
<span class="shrink-0 text-[11px] text-muted-foreground">
|
|
||||||
{{ filterMatches() }} match{{ filterMatches() === 1 ? '' : 'es' }} · Esc
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
(click)="clearFilter()"
|
|
||||||
title="Clear filter (Esc)"
|
|
||||||
class="grid size-5 shrink-0 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaX" size="0.8rem" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- file list -->
|
|
||||||
<div class="relative min-h-0 flex-1">
|
|
||||||
<div
|
|
||||||
#list
|
|
||||||
tabindex="0"
|
|
||||||
(keydown)="onKeydown($event)"
|
|
||||||
(mousedown)="activate()"
|
|
||||||
(contextmenu)="listContextMenu($event)"
|
|
||||||
class="h-full overflow-y-auto outline-none"
|
|
||||||
>
|
|
||||||
@if (panel().connecting()) {
|
|
||||||
<div class="panel-fade-in grid h-full place-items-center">
|
|
||||||
<div class="flex flex-col items-center gap-3">
|
|
||||||
<ng-icon name="mynaSpinner" size="1.4rem" class="animate-spin text-primary" />
|
|
||||||
<span class="text-xs text-muted-foreground">
|
|
||||||
Connecting to {{ panel().connectingTo() }}…
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
(click)="cancelConnect()"
|
|
||||||
class="rounded-md border border-border px-3 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
@for (entry of panel().sorted(); track entry.name; let i = $index) {
|
|
||||||
<div
|
|
||||||
[attr.data-index]="i"
|
|
||||||
(click)="rowClick($event, i)"
|
|
||||||
(dblclick)="openAt(i)"
|
|
||||||
(contextmenu)="rowContextMenu($event, i)"
|
|
||||||
[style.grid-template-columns]="gridTemplate()"
|
|
||||||
class="grid h-(--row-h) items-center text-(length:--list-font)"
|
|
||||||
[class.bg-cursor-tint]="i === panel().cursor() && !isSelected(entry)"
|
|
||||||
[class.bg-primary/15]="isSelected(entry)"
|
|
||||||
[class.ring-1]="i === panel().cursor() && isActive()"
|
|
||||||
[class.ring-inset]="i === panel().cursor() && isActive()"
|
|
||||||
[class.ring-ring]="i === panel().cursor() && isActive()"
|
|
||||||
>
|
|
||||||
<div class="flex min-w-0 items-center gap-1.5 px-2">
|
|
||||||
<ng-icon [name]="iconFor(entry)" size="0.95rem" [class]="iconClass(entry)" />
|
|
||||||
<span class="truncate" [class.italic]="entry.type === 'symlink'">{{ entry.name }}</span>
|
|
||||||
</div>
|
|
||||||
@for (col of visibleColumns(); track col.key) {
|
|
||||||
@switch (col.key) {
|
|
||||||
@case ('size') {
|
|
||||||
<span class="px-2 text-right font-mono text-(length:--list-meta-font) tabular-nums text-muted-foreground">
|
|
||||||
{{ entry.type === 'dir' ? '—' : formatSize(entry.size) }}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
@case ('mtime') {
|
|
||||||
<span class="px-2 font-mono text-(length:--list-meta-font) tabular-nums text-muted-foreground">
|
|
||||||
{{ formatDate(entry.mtime) }}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
@case ('kind') {
|
|
||||||
<span class="truncate px-2 text-(length:--list-meta-font) text-muted-foreground">
|
|
||||||
{{ fileKind(entry) }}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
@case ('rights') {
|
|
||||||
<span class="px-2 font-mono text-(length:--list-meta-font) text-muted-foreground">
|
|
||||||
{{ entry.name === '..' ? '' : formatMode(entry.mode) }}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
@case ('owner') {
|
|
||||||
<span class="truncate px-2 font-mono text-(length:--list-meta-font) text-muted-foreground">
|
|
||||||
{{ entry.name === '..' ? '' : (entry.owner ?? '—') }}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
@case ('group') {
|
|
||||||
<span class="truncate px-2 font-mono text-(length:--list-meta-font) text-muted-foreground">
|
|
||||||
{{ entry.name === '..' ? '' : (entry.group ?? '—') }}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} @empty {
|
|
||||||
<div class="p-6 text-center text-xs text-muted-foreground">Empty directory</div>
|
|
||||||
}
|
|
||||||
@if (panel().filter() && filterMatches() === 0) {
|
|
||||||
<div class="p-6 text-center text-xs text-muted-foreground">No matches</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@if (panel().slowLoad() && !panel().connecting()) {
|
|
||||||
<div class="panel-fade-in absolute inset-0 z-10 grid place-items-center bg-background/60">
|
|
||||||
<ng-icon name="mynaSpinner" size="1.4rem" class="animate-spin text-primary" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@@ -1,352 +0,0 @@
|
|||||||
import {
|
|
||||||
Component,
|
|
||||||
ElementRef,
|
|
||||||
computed,
|
|
||||||
effect,
|
|
||||||
inject,
|
|
||||||
input,
|
|
||||||
signal,
|
|
||||||
viewChild,
|
|
||||||
} from '@angular/core';
|
|
||||||
import { NgIcon } from '@ng-icons/core';
|
|
||||||
import type { ColumnKey, FileEntry, SavedConnection, SshConfigHost } from '@shared/types';
|
|
||||||
import { ConnectionsService } from '../connections.service';
|
|
||||||
import { DialogsService } from '../dialogs.service';
|
|
||||||
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';
|
|
||||||
|
|
||||||
/** Optional columns in fixed display order, with their track widths. */
|
|
||||||
const COLUMN_DEFS: { key: ColumnKey; label: string; width: string }[] = [
|
|
||||||
{ key: 'size', label: 'Size', width: '5.5rem' },
|
|
||||||
{ key: 'mtime', label: 'Modified', width: '8.5rem' },
|
|
||||||
{ key: 'kind', label: 'Kind', width: '5.5rem' },
|
|
||||||
{ key: 'rights', label: 'Rights', width: '6.5rem' },
|
|
||||||
{ key: 'owner', label: 'Owner', width: '6rem' },
|
|
||||||
{ key: 'group', label: 'Group', width: '6rem' },
|
|
||||||
];
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-file-panel',
|
|
||||||
imports: [NgIcon],
|
|
||||||
templateUrl: './file-panel.html',
|
|
||||||
host: { class: '@container flex min-w-0 flex-1 flex-col overflow-hidden' },
|
|
||||||
})
|
|
||||||
export class FilePanel {
|
|
||||||
readonly side = input.required<Side>();
|
|
||||||
|
|
||||||
readonly panels = inject(PanelsService);
|
|
||||||
readonly actions = inject(FileActionsService);
|
|
||||||
readonly transfers = inject(TransfersService);
|
|
||||||
readonly connections = inject(ConnectionsService);
|
|
||||||
readonly dialogs = inject(DialogsService);
|
|
||||||
private readonly settings = inject(SettingsService);
|
|
||||||
private readonly rpc = inject(RpcService);
|
|
||||||
readonly panel = computed(() => this.panels.get(this.side()));
|
|
||||||
readonly isActive = computed(() => this.panels.activeSide() === this.side());
|
|
||||||
readonly isRemote = computed(() => this.panel().sessionId() !== 'local');
|
|
||||||
/** Input is ignored while the panel is connecting or a listing is in flight. */
|
|
||||||
readonly blocked = computed(() => this.panel().busy());
|
|
||||||
/** The copy button points toward the other panel. */
|
|
||||||
readonly copyIcon = computed(() => (this.side() === 'left' ? 'mynaArrowRight' : 'mynaArrowLeft'));
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
readonly formatSize = formatSize;
|
|
||||||
readonly formatDate = formatDate;
|
|
||||||
readonly formatMode = formatMode;
|
|
||||||
readonly fileKind = fileKind;
|
|
||||||
|
|
||||||
/** The optional columns currently shown, in canonical order. */
|
|
||||||
readonly visibleColumns = computed(() => {
|
|
||||||
const visible = this.settings.columns();
|
|
||||||
return COLUMN_DEFS.filter((c) => visible.includes(c.key));
|
|
||||||
});
|
|
||||||
readonly gridTemplate = computed(() =>
|
|
||||||
['minmax(0,1fr)', ...this.visibleColumns().map((c) => c.width)].join(' '),
|
|
||||||
);
|
|
||||||
|
|
||||||
headerContextMenu(event: MouseEvent): void {
|
|
||||||
event.preventDefault();
|
|
||||||
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)
|
|
||||||
effect(() => {
|
|
||||||
this.panels.focusTick();
|
|
||||||
if (this.isActive()) this.listEl().nativeElement.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
activate(): void {
|
|
||||||
this.panels.setActiveSide(this.side());
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelConnect(): void {
|
|
||||||
this.panels.cancelConnect(this.side());
|
|
||||||
}
|
|
||||||
|
|
||||||
copyToOther(): void {
|
|
||||||
if (this.blocked()) return;
|
|
||||||
this.activate();
|
|
||||||
void this.transfers.copyToOtherPanel(this.side());
|
|
||||||
}
|
|
||||||
|
|
||||||
run(action: 'edit' | 'rename' | 'mkdir' | 'createFile' | 'chmod' | 'delete'): void {
|
|
||||||
if (this.blocked()) return;
|
|
||||||
this.activate();
|
|
||||||
void this.actions[action](this.panel());
|
|
||||||
}
|
|
||||||
|
|
||||||
pickLocal(): void {
|
|
||||||
this.menuOpen.set(false);
|
|
||||||
void this.panels.disconnectPanel(this.side());
|
|
||||||
}
|
|
||||||
|
|
||||||
pickConnection(connection: SavedConnection): void {
|
|
||||||
this.menuOpen.set(false);
|
|
||||||
this.activate();
|
|
||||||
void this.panels.connectPanel(this.side(), connection, connection.name, connection.localDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleMenu(): void {
|
|
||||||
this.menuOpen.set(!this.menuOpen());
|
|
||||||
// re-read ~/.ssh/config on each open so external edits show up
|
|
||||||
if (this.menuOpen()) void this.connections.reloadSshHosts();
|
|
||||||
}
|
|
||||||
|
|
||||||
pickSshHost(host: SshConfigHost): void {
|
|
||||||
this.menuOpen.set(false);
|
|
||||||
this.activate();
|
|
||||||
void this.panels.connectPanel(
|
|
||||||
this.side(),
|
|
||||||
{
|
|
||||||
host: host.hostname,
|
|
||||||
port: host.port,
|
|
||||||
username: host.username,
|
|
||||||
auth: host.identityFile
|
|
||||||
? { kind: 'key', privateKeyPath: host.identityFile }
|
|
||||||
: { kind: 'agent' },
|
|
||||||
initialPath: null,
|
|
||||||
},
|
|
||||||
host.name,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
sshHostTitle(host: SshConfigHost): string {
|
|
||||||
if (host.unsupported === 'proxy') return 'Uses ProxyJump/ProxyCommand — not supported';
|
|
||||||
return `${host.username}@${host.hostname}:${host.port}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
iconFor(entry: FileEntry): string {
|
|
||||||
return fileGlyph(entry, this.settings.iconColors() === 'multi');
|
|
||||||
}
|
|
||||||
|
|
||||||
iconClass(entry: FileEntry): string {
|
|
||||||
return fileIconClass(entry, this.settings.iconColors() === 'multi');
|
|
||||||
}
|
|
||||||
|
|
||||||
isSelected(entry: FileEntry): boolean {
|
|
||||||
return this.panel().selected().has(entry.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearFilter(): void {
|
|
||||||
this.panel().setFilter('');
|
|
||||||
this.listEl().nativeElement.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
filterMatches(): number {
|
|
||||||
return this.panel()
|
|
||||||
.sorted()
|
|
||||||
.filter((e) => e.name !== '..').length;
|
|
||||||
}
|
|
||||||
|
|
||||||
sortIndicator(key: SortKey): string {
|
|
||||||
if (this.panel().sortKey() !== key) return '';
|
|
||||||
return this.panel().sortAsc() ? '↑' : '↓';
|
|
||||||
}
|
|
||||||
|
|
||||||
commitPath(): void {
|
|
||||||
if (this.blocked()) return;
|
|
||||||
const value = this.pathDraft().trim();
|
|
||||||
if (value) void this.panel().load(value);
|
|
||||||
this.listEl().nativeElement.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
resetPath(): void {
|
|
||||||
this.pathDraft.set(this.panel().cwd());
|
|
||||||
this.listEl().nativeElement.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
rowClick(event: MouseEvent, index: number): void {
|
|
||||||
if (this.blocked()) return;
|
|
||||||
this.activate();
|
|
||||||
const panel = this.panel();
|
|
||||||
if (event.shiftKey) {
|
|
||||||
panel.selectRange(this.lastClickedIndex, index);
|
|
||||||
} else if (event.metaKey || event.ctrlKey) {
|
|
||||||
const entry = panel.sorted()[index];
|
|
||||||
if (entry) panel.toggleSelect(entry.name);
|
|
||||||
} else {
|
|
||||||
panel.clearSelection();
|
|
||||||
}
|
|
||||||
panel.cursor.set(index);
|
|
||||||
this.lastClickedIndex = index;
|
|
||||||
}
|
|
||||||
|
|
||||||
rowContextMenu(event: MouseEvent, index: number): void {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
if (this.blocked()) return;
|
|
||||||
this.activate();
|
|
||||||
const panel = this.panel();
|
|
||||||
const entry = panel.sorted()[index] ?? null;
|
|
||||||
// standard behavior: keep the selection when right-clicking inside it
|
|
||||||
if (entry && !panel.selected().has(entry.name)) panel.clearSelection();
|
|
||||||
panel.cursor.set(index);
|
|
||||||
void this.actions.openContextMenu({ side: this.side(), panel, entry });
|
|
||||||
}
|
|
||||||
|
|
||||||
listContextMenu(event: MouseEvent): void {
|
|
||||||
event.preventDefault();
|
|
||||||
if (this.blocked()) return;
|
|
||||||
this.activate();
|
|
||||||
void this.actions.openContextMenu({ side: this.side(), panel: this.panel(), entry: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
openAt(index: number): void {
|
|
||||||
if (this.blocked()) return;
|
|
||||||
const entry = this.panel().sorted()[index];
|
|
||||||
if (entry) this.panel().open(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
onKeydown(event: KeyboardEvent): void {
|
|
||||||
if (this.blocked()) return;
|
|
||||||
const panel = this.panel();
|
|
||||||
const key = event.key;
|
|
||||||
|
|
||||||
if (key === 'Tab') {
|
|
||||||
event.preventDefault();
|
|
||||||
this.panels.setActiveSide(this.panels.otherSide(this.side()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if ((event.metaKey || event.ctrlKey) && key.toLowerCase() === 'a') {
|
|
||||||
event.preventDefault();
|
|
||||||
panel.selectAll();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// type-to-filter: printable characters build the query (Space only once a
|
|
||||||
// query exists — an empty-filter Space keeps its toggle-select meaning)
|
|
||||||
if (
|
|
||||||
key.length === 1 &&
|
|
||||||
!event.metaKey &&
|
|
||||||
!event.ctrlKey &&
|
|
||||||
!event.altKey &&
|
|
||||||
(key !== ' ' || panel.filter() !== '')
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
panel.setFilter(panel.filter() + key);
|
|
||||||
this.scrollCursorIntoView();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key === 'Backspace' && panel.filter() !== '') {
|
|
||||||
event.preventDefault();
|
|
||||||
panel.setFilter(panel.filter().slice(0, -1));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key === 'Escape' && panel.filter() !== '') {
|
|
||||||
event.preventDefault();
|
|
||||||
panel.setFilter('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (key) {
|
|
||||||
case 'ArrowDown':
|
|
||||||
panel.moveCursor(1);
|
|
||||||
break;
|
|
||||||
case 'ArrowUp':
|
|
||||||
panel.moveCursor(-1);
|
|
||||||
break;
|
|
||||||
case 'PageDown':
|
|
||||||
panel.moveCursor(20);
|
|
||||||
break;
|
|
||||||
case 'PageUp':
|
|
||||||
panel.moveCursor(-20);
|
|
||||||
break;
|
|
||||||
case 'Home':
|
|
||||||
panel.cursor.set(0);
|
|
||||||
break;
|
|
||||||
case 'End':
|
|
||||||
panel.cursor.set(Math.max(0, panel.sorted().length - 1));
|
|
||||||
break;
|
|
||||||
case ' ':
|
|
||||||
{
|
|
||||||
const entry = panel.sorted()[panel.cursor()];
|
|
||||||
if (entry) panel.toggleSelect(entry.name);
|
|
||||||
panel.moveCursor(1);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'Enter':
|
|
||||||
this.openAt(panel.cursor());
|
|
||||||
return void event.preventDefault();
|
|
||||||
case 'Backspace':
|
|
||||||
panel.up();
|
|
||||||
break;
|
|
||||||
case 'F2':
|
|
||||||
this.actions.rename(panel);
|
|
||||||
break;
|
|
||||||
case 'F4':
|
|
||||||
void this.actions.edit(panel);
|
|
||||||
break;
|
|
||||||
case 'F5':
|
|
||||||
void this.transfers.copyToOtherPanel(this.side());
|
|
||||||
break;
|
|
||||||
case 'F7':
|
|
||||||
this.actions.mkdir(panel);
|
|
||||||
break;
|
|
||||||
case 'F8':
|
|
||||||
case 'Delete':
|
|
||||||
this.actions.delete(panel);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
return; // let unhandled keys through
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
this.scrollCursorIntoView();
|
|
||||||
}
|
|
||||||
|
|
||||||
private scrollCursorIntoView(): void {
|
|
||||||
const row = this.listEl().nativeElement.querySelector(
|
|
||||||
`[data-index="${this.panel().cursor()}"]`,
|
|
||||||
);
|
|
||||||
row?.scrollIntoView({ block: 'nearest' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
// Single icon registry for the whole app, registered once in app.config.ts.
|
|
||||||
// NOTE: @ng-icons/mynaui's package root is empty — always import from /outline
|
|
||||||
// (or /solid, where names get a `Solid` suffix).
|
|
||||||
import {
|
|
||||||
mynaArchive,
|
|
||||||
mynaArrowLeft,
|
|
||||||
mynaArrowLeftRight,
|
|
||||||
mynaArrowRight,
|
|
||||||
mynaArrowUp,
|
|
||||||
mynaCheck,
|
|
||||||
mynaCheckCircle,
|
|
||||||
mynaChevronDown,
|
|
||||||
mynaChevronRight,
|
|
||||||
mynaChevronUp,
|
|
||||||
mynaCode,
|
|
||||||
mynaCog,
|
|
||||||
mynaDots,
|
|
||||||
mynaEdit,
|
|
||||||
mynaFile,
|
|
||||||
mynaFilePlus,
|
|
||||||
mynaFileText,
|
|
||||||
mynaFolder,
|
|
||||||
mynaFolderPlus,
|
|
||||||
mynaHardDrive,
|
|
||||||
mynaImage,
|
|
||||||
mynaLink,
|
|
||||||
mynaLock,
|
|
||||||
mynaMusic,
|
|
||||||
mynaPlus,
|
|
||||||
mynaRefresh,
|
|
||||||
mynaSearch,
|
|
||||||
mynaServers,
|
|
||||||
mynaSpinner,
|
|
||||||
mynaTrash,
|
|
||||||
mynaVideo,
|
|
||||||
mynaX,
|
|
||||||
mynaXCircle,
|
|
||||||
} from "@ng-icons/mynaui/outline";
|
|
||||||
|
|
||||||
export const APP_ICONS = {
|
|
||||||
mynaArchive,
|
|
||||||
mynaArrowLeft,
|
|
||||||
mynaArrowLeftRight,
|
|
||||||
mynaArrowRight,
|
|
||||||
mynaArrowUp,
|
|
||||||
mynaCheck,
|
|
||||||
mynaCheckCircle,
|
|
||||||
mynaChevronDown,
|
|
||||||
mynaChevronRight,
|
|
||||||
mynaChevronUp,
|
|
||||||
mynaCode,
|
|
||||||
mynaCog,
|
|
||||||
mynaDots,
|
|
||||||
mynaEdit,
|
|
||||||
mynaFile,
|
|
||||||
mynaFilePlus,
|
|
||||||
mynaFileText,
|
|
||||||
mynaFolder,
|
|
||||||
mynaFolderPlus,
|
|
||||||
mynaHardDrive,
|
|
||||||
mynaImage,
|
|
||||||
mynaLink,
|
|
||||||
mynaLock,
|
|
||||||
mynaMusic,
|
|
||||||
mynaPlus,
|
|
||||||
mynaRefresh,
|
|
||||||
mynaSearch,
|
|
||||||
mynaServers,
|
|
||||||
mynaSpinner,
|
|
||||||
mynaTrash,
|
|
||||||
mynaVideo,
|
|
||||||
mynaX,
|
|
||||||
mynaXCircle,
|
|
||||||
};
|
|
||||||