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 | ||
|
|
39b1858957 | ||
|
|
5863ae1079 | ||
|
|
2d95857de1 | ||
|
|
ef34104cf0 | ||
|
|
dc5cb1f707 | ||
|
|
2585dd128b | ||
|
|
c7271fb7cf | ||
|
|
d80d5c23dc | ||
|
|
14b92b33a4 | ||
|
|
ab59c785b5 | ||
|
|
485603fd30 | ||
|
|
d22bc9b4e2 | ||
|
|
7dd59a9752 | ||
|
|
27d0ac81d6 | ||
|
|
62ff845944 | ||
|
|
93a9b07722 | ||
|
|
6a19097b25 | ||
|
|
1652b9e298 |
@@ -0,0 +1,47 @@
|
|||||||
|
# Release matrix — INERT until act_runner instances with these labels are
|
||||||
|
# registered on git.teknes.es. Each OS builds its own artifacts (electrobun
|
||||||
|
# cannot cross-compile: packaging needs codesign/hdiutil on macOS and
|
||||||
|
# rcedit/PowerShell on Windows). No mac-x64: Electrobun 2.x has no macOS x64
|
||||||
|
# core.
|
||||||
|
#
|
||||||
|
# Runner prerequisites:
|
||||||
|
# macos — Apple Silicon Mac: bun, Xcode CLT, network to github.com and the
|
||||||
|
# Electrobun/Hutch release hosts (`bun install` runs `electrobun
|
||||||
|
# prepare`; the first build per Electrobun version fills ~/.hutch
|
||||||
|
# with Hutch, the core, Cottontail and the pinned Bun, ~200 MB)
|
||||||
|
# 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)).
|
||||||
|
name: release
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "build-*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
mac:
|
||||||
|
runs-on: macos
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # full history — the build number is the commit count
|
||||||
|
- run: bun install --frozen-lockfile
|
||||||
|
- run: bun scripts/release.ts --target mac-arm64
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: mac
|
||||||
|
path: release/**
|
||||||
|
|
||||||
|
windows:
|
||||||
|
runs-on: windows
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- run: bun install --frozen-lockfile
|
||||||
|
- run: bun scripts/release.ts --target win-x64
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: windows
|
||||||
|
path: release/**
|
||||||
@@ -6,3 +6,8 @@ artifacts/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
ui/dist/
|
ui/dist/
|
||||||
ui/.angular/
|
ui/.angular/
|
||||||
|
src/shared/version.gen.ts
|
||||||
|
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.
|
||||||
@@ -10,12 +10,20 @@ remote server over SSH/SFTP, so you can move files **local↔remote**,
|
|||||||
> **Name & icon are placeholders.** See [Rebranding](#rebranding) — it's a
|
> **Name & icon are placeholders.** See [Rebranding](#rebranding) — it's a
|
||||||
> one-file change.
|
> one-file change.
|
||||||
|
|
||||||
|
## Developer docs
|
||||||
|
|
||||||
|
Dense reference material lives in [docs/](docs/): [architecture](docs/architecture.md)
|
||||||
|
(processes, windows, RPC, every module/service), [build & release](docs/build-and-release.md)
|
||||||
|
(Build-n versioning, per-target release pipeline, CI), [platform notes](docs/platform-notes.md)
|
||||||
|
(macOS/Windows/Linux matrix, runtime deps + smoke tests), and
|
||||||
|
[gotchas](docs/gotchas.md) (known landmines).
|
||||||
|
|
||||||
## Features
|
## 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
|
||||||
@@ -27,15 +35,17 @@ remote server over SSH/SFTP, so you can move files **local↔remote**,
|
|||||||
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).
|
||||||
@@ -45,10 +55,10 @@ remote server over SSH/SFTP, so you can move files **local↔remote**,
|
|||||||
| 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
|
||||||
@@ -68,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
|
||||||
|
|
||||||
@@ -87,6 +106,29 @@ This starts the Angular dev server (hot reload for UI code) and launches the
|
|||||||
Electrobun app pointed at it. Edits under `ui/` reload instantly; edits to the
|
Electrobun app pointed at it. Edits under `ui/` reload instantly; edits to the
|
||||||
Bun main process (`src/bun/**`, RPC schema) trigger an app relaunch.
|
Bun main process (`src/bun/**`, RPC schema) trigger an app relaunch.
|
||||||
|
|
||||||
|
## Releases
|
||||||
|
|
||||||
|
Versioning is Sublime-style: **Build n**, where n is the commit count
|
||||||
|
(`git rev-list --count HEAD`), regenerated into the gitignored
|
||||||
|
`src/shared/version.gen.ts` by every build script (and `postinstall`).
|
||||||
|
|
||||||
|
One artifact per invocation, on the matching host — electrobun cannot
|
||||||
|
cross-compile (Hutch builds for its own OS and arch):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun run release -- --target mac-arm64 # on an Apple Silicon Mac
|
||||||
|
bun run release -- --target win-x64 # on a Windows 11 x64 machine
|
||||||
|
bun run release -- --target linux-x64 # on a Linux x64 machine
|
||||||
|
```
|
||||||
|
|
||||||
|
Artifacts land in `release/<target>/miniSCP-Build<n>-<target>.*` — a `.dmg`
|
||||||
|
on the Mac; on Windows a `-Setup.zip` installer **and** a `-portable.zip`
|
||||||
|
(unzip anywhere and run); on Linux Electrobun's `-Setup.tar.gz` installer.
|
||||||
|
There is no Intel Mac build: Electrobun 2.x publishes no macOS x64 core.
|
||||||
|
CI: `.gitea/workflows/release.yml` runs the
|
||||||
|
same script on a macos + windows runner matrix once act_runners are
|
||||||
|
registered; trigger by pushing a `build-<n>` tag.
|
||||||
|
|
||||||
## Build a packaged app
|
## Build a packaged app
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -98,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
|
||||||
@@ -109,7 +151,7 @@ Everything user-facing reads from **[`src/shared/branding.ts`](src/shared/brandi
|
|||||||
export const BRANDING = {
|
export const BRANDING = {
|
||||||
appName: "miniSCP", // window title, bundle name, app-data folder
|
appName: "miniSCP", // window title, bundle name, app-data folder
|
||||||
identifier: "es.teknes.miniscp",
|
identifier: "es.teknes.miniscp",
|
||||||
legacyNames: ["BunSCP"], // previous names; their data dirs migrate on boot
|
legacyNames: [], // previous names; their data dirs migrate on boot
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
icon: "assets/icon.iconset",
|
icon: "assets/icon.iconset",
|
||||||
} as const;
|
} as const;
|
||||||
@@ -138,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
|
||||||
|
|
||||||
@@ -173,30 +304,79 @@ Be careful when two panels reach the same data through different mount points.
|
|||||||
|
|
||||||
## Portability notes
|
## Portability notes
|
||||||
|
|
||||||
The codebase is platform-neutral except for two seams, both macOS-only today:
|
Platform seams are isolated behind `process.platform` / `PLATFORM` branches
|
||||||
|
marked with `PORTABILITY:` comments: editor launch (`open` / `cmd /c start` /
|
||||||
|
`xdg-open`), app pickers, owner/group lookups, tray images, window chrome,
|
||||||
|
shell-integration wording, drive listing, and the connections-file ACL. The
|
||||||
|
application menu ([src/bun/menu.ts](src/bun/menu.ts)) is macOS-only for now;
|
||||||
|
the in-app UI covers every menu action.
|
||||||
|
|
||||||
- **External editor launch** (`launchEditor` in [src/bun/edits.ts](src/bun/edits.ts)):
|
Right-click menus are **HTML, on every platform**
|
||||||
uses macOS `open [-a app] file`. A Windows port should use
|
([ui/src/app/core/context-menu.service.ts](ui/src/app/core/context-menu.service.ts)) rather than native —
|
||||||
`cmd /c start "" [app] file`; Linux `xdg-open file` (or invoke the configured
|
electrobun's native context menu is a documented no-op on Linux, and one menu
|
||||||
editor binary directly). The `.app` picker in the `pickEditorApp` handler
|
implementation beats two that drift. Only reveal / open-with-default / copy-path
|
||||||
needs the equivalent executable picker per platform.
|
cross to the backend, through the `shellItemAction` RPC.
|
||||||
- **Application menu** ([src/bun/menu.ts](src/bun/menu.ts)): electrobun menus
|
|
||||||
are macOS-only; the in-app UI covers every menu action.
|
## Windows (beta)
|
||||||
|
|
||||||
|
Windows 11+ x64 is supported as a **beta** target (win-arm64 deferred). All
|
||||||
|
electrobun APIs the app uses are implemented on Windows; known issues are
|
||||||
|
upstream:
|
||||||
|
|
||||||
|
- **HiDPI blur** ([electrobun#324](https://github.com/blackboardsh/electrobun/issues/324)):
|
||||||
|
WebView2 rendered at 1x on scaled displays on Electrobun 1.18; 2.x ships
|
||||||
|
per-monitor DPI setup — to re-check on the first 2.x Windows build.
|
||||||
|
- **Notifications** use legacy balloon style, not Windows toast.
|
||||||
|
- **Unsigned binaries**: both the Setup.exe installer and the portable exe
|
||||||
|
trigger SmartScreen/Mark-of-the-Web warnings on first run. The installer
|
||||||
|
unpacks to `%LOCALAPPDATA%` and, since Electrobun 2.x, ships an uninstaller.
|
||||||
|
- `connections.json` is protected with an `icacls` current-user ACL instead of
|
||||||
|
POSIX 0600 (which is meaningless on Windows).
|
||||||
|
|
||||||
|
First-Windows-boot smoke test: drives dropdown lists C:\ etc.; navigate to a
|
||||||
|
drive root ('..' disappears); local↔remote transfers; external editor launch
|
||||||
|
(`cmd /c start`) and the .exe picker; Show in Explorer / Recycle Bin; tray icon
|
||||||
|
renders colored; Permissions absent on local panels; Ctrl+labels in tooltips;
|
||||||
|
portable zip runs from any folder.
|
||||||
|
|
||||||
|
## Linux (beta)
|
||||||
|
|
||||||
|
Linux x64 is supported as a **beta** target and ships Electrobun's own
|
||||||
|
installer (`-Setup.tar.gz`).
|
||||||
|
One hard requirement: the AppIndicator library listed under
|
||||||
|
[Prerequisites](#prerequisites) — electrobun links it for the tray, so without
|
||||||
|
it the app aborts at startup with `ERR_DLOPEN_FAILED` rather than merely losing
|
||||||
|
its tray icon.
|
||||||
|
|
||||||
|
- **No native menus at all** upstream (both the app menubar and context menus
|
||||||
|
are no-ops). The menubar was already macOS-only and the context menus are now
|
||||||
|
HTML, so nothing is missing in practice.
|
||||||
|
- **Tray visibility** depends on the desktop: KDE, XFCE, MATE and Cinnamon show
|
||||||
|
it; GNOME 3.26+ needs the AppIndicator extension. The app works either way.
|
||||||
|
- **Unsigned**, and no AppImage or `.deb` — unpack the `-Setup.tar.gz` and run
|
||||||
|
its `installer`.
|
||||||
|
- **NVIDIA GPUs** need `WEBKIT_DISABLE_DMABUF_RENDERER=1` or the window renders
|
||||||
|
black. The dev scripts set it automatically when an NVIDIA driver is
|
||||||
|
detected; an installed build needs it set by hand (e.g. in its desktop
|
||||||
|
entry) — see [gotchas](docs/gotchas.md#linux).
|
||||||
|
|
||||||
|
Full dependency table, diagnostics and the first-boot smoke test:
|
||||||
|
[platform notes](docs/platform-notes.md).
|
||||||
|
|
||||||
## Known issues
|
## 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).
|
||||||
|
|||||||
|
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 |
|
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,8 @@
|
|||||||
"@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",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -25,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",
|
||||||
},
|
},
|
||||||
@@ -132,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=="],
|
||||||
@@ -252,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=="],
|
||||||
@@ -306,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=="],
|
||||||
|
|
||||||
@@ -474,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=="],
|
||||||
@@ -496,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=="],
|
||||||
|
|
||||||
@@ -512,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=="],
|
||||||
@@ -582,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=="],
|
||||||
@@ -610,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=="],
|
||||||
|
|
||||||
@@ -638,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=="],
|
||||||
@@ -694,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=="],
|
||||||
@@ -736,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=="],
|
||||||
@@ -750,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=="],
|
||||||
@@ -806,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=="],
|
||||||
|
|
||||||
@@ -858,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=="],
|
||||||
@@ -902,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=="],
|
||||||
@@ -930,9 +895,9 @@
|
|||||||
|
|
||||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||||
|
|
||||||
"png-to-ico": ["png-to-ico@2.1.8", "", { "dependencies": { "@types/node": "^17.0.36", "minimist": "^1.2.6", "pngjs": "^6.0.0" }, "bin": { "png-to-ico": "bin/cli.js" } }, "sha512-Nf+IIn/cZ/DIZVdGveJp86NG5uNib1ZXMiDd/8x32HCTeKSvgpyg6D/6tUBn1QO/zybzoMK0/mc3QRgAyXdv9w=="],
|
"png-to-ico": ["png-to-ico@3.0.2", "", { "dependencies": { "@types/node": "^25.5.0", "minimist": "^1.2.8", "pngjs": "^7.0.0" }, "bin": { "png-to-ico": "bin/cli.js" } }, "sha512-36vvp3/YF7LPYkUEOj08/WgB1wI63qW391YfOhOWckgOtGkarr9+EhPBimcCmRP7fJaG4EaXQhTTaQ8qqdj8aA=="],
|
||||||
|
|
||||||
"pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
|
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
|
||||||
|
|
||||||
"postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="],
|
"postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="],
|
||||||
|
|
||||||
@@ -944,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=="],
|
||||||
@@ -1040,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=="],
|
||||||
@@ -1104,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=="],
|
||||||
@@ -1148,12 +1105,14 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -1162,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@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="],
|
"png-to-ico/@types/node": ["@types/node@25.9.5", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg=="],
|
||||||
|
|
||||||
"proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
|
||||||
|
|
||||||
"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=="],
|
||||||
@@ -1184,8 +1141,6 @@
|
|||||||
|
|
||||||
"@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=="],
|
||||||
@@ -1196,6 +1151,8 @@
|
|||||||
|
|
||||||
"node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
|
"node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
|
||||||
|
|
||||||
|
"png-to-ico/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||||
|
|
||||||
"sass/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
"sass/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
|
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
|
||||||
|
|||||||
@@ -0,0 +1,531 @@
|
|||||||
|
# miniSCP architecture
|
||||||
|
|
||||||
|
Dense reference for development and debugging. Companion files:
|
||||||
|
[build-and-release.md](build-and-release.md), [platform-notes.md](platform-notes.md),
|
||||||
|
[gotchas.md](gotchas.md).
|
||||||
|
|
||||||
|
## Process model
|
||||||
|
|
||||||
|
- **Bun main process** (`src/bun/`): the native launcher runs electrobun's
|
||||||
|
`main.js` on the Bun the 2.x devkit pins (1.4.0, shipped in the bundle —
|
||||||
|
`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 /
|
||||||
|
WebView2 on Windows / WebKitGTK on Linux. Zero privileged access — everything
|
||||||
|
goes through RPC.
|
||||||
|
- **RPC transport**: one WebSocket server in the bun process, multiplexed by
|
||||||
|
`webviewId` (each webview auto-connects using natively injected globals;
|
||||||
|
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
|
||||||
|
|
||||||
|
`AppRPC` is a single superset schema shared by all window kinds. This works
|
||||||
|
because electrobun allows **partial handler sets** (a side only implements
|
||||||
|
what it serves) and **silently drops messages nobody listens to**. Every
|
||||||
|
response crosses the bridge as `Result<T>` (`{ok:true,…}|{ok:false,error}`) —
|
||||||
|
exceptions never cross; `wrap()` in `src/bun/rpc-handlers.ts` enforces it.
|
||||||
|
|
||||||
|
### Per-window RPC instances (critical)
|
||||||
|
|
||||||
|
Each `BrowserWindow` MUST receive its own `defineRPC(...)` instance: binding a
|
||||||
|
BrowserView calls `rpc.setTransport(...)`, so a shared instance would have its
|
||||||
|
transport stolen by the most recent window. `createAppRPC` (main),
|
||||||
|
`createEditorRPC` (per editor window, hooks close over that window's record),
|
||||||
|
`createManagerRPC` (the single manager window), `createSettingsRPC` (the single
|
||||||
|
Settings window). `rpc.send.*` on an instance targets exactly its own webview.
|
||||||
|
|
||||||
|
### The `events` late-binding pattern
|
||||||
|
|
||||||
|
`createAppRPC` needs to trigger things that need `rpc.send` (which doesn't
|
||||||
|
exist until after construction). `AppDeps.events` is a mutable object of
|
||||||
|
no-op functions, reassigned in `src/bun/index.ts` after everything exists:
|
||||||
|
`sessionClosed`, `connectionsChanged`, `openEditor`, `settingsChanged`,
|
||||||
|
`openConnectionManager`, `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
|
||||||
|
|
||||||
|
`src/bun/windows.ts` — `buildPreload(mode, settings, ctx?)` injects
|
||||||
|
`__APP_PLATFORM`, `__APP_MODE` (`main|editor|manager|settings`), `__APP_CTX`
|
||||||
|
before any page script, plus the persisted theme attributes and interface
|
||||||
|
scale so the first paint is already right. `ui/src/main.ts` picks the root
|
||||||
|
component from `APP_MODE`; all four roots use selector `app-root` (index.html
|
||||||
|
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
|
||||||
|
native titlebar and a compact `h-9` non-drag header.
|
||||||
|
|
||||||
|
`WindowRegistry<Rec>` (same file): keyed window map with dedupe/activate,
|
||||||
|
cascade placement off an anchor frame, focus tracking. electrobun's emitter
|
||||||
|
has **no `off()`** — `track()` closures capture only `(this, key)` so closed
|
||||||
|
records can be garbage collected. A future "window follows window" grouping
|
||||||
|
belongs here (`anchor.on('move')` + `setPosition`).
|
||||||
|
|
||||||
|
### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/skins/shadlike/editor-window/`)
|
||||||
|
|
||||||
|
One window per file, key `sessionId+path` (sessionId, not fsKey: saves route
|
||||||
|
through a concrete session, and `local` is one shared session anyway).
|
||||||
|
Re-opening focuses the existing window. The webview fetches its own content
|
||||||
|
(`readFile`) — `openEditor` just creates the window with ctx in the preload.
|
||||||
|
|
||||||
|
**Close safety** (designed when electrobun window `close` was NOT cancellable —
|
||||||
|
1.18.1 had no `allow` response like `before-quit`; 2.x adds a cancellable
|
||||||
|
`will-close` for the titlebar close, not used yet — see gotchas):
|
||||||
|
1. ⌘W (the page's own `close-window` key; a ⌘W it doesn't claim falls through
|
||||||
|
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
|
||||||
|
its draft to bun (`editorState`, 400 ms debounce + flush on blur/dirty-flip)
|
||||||
|
→ native Save/Discard box from the held draft. A failed save (dead session)
|
||||||
|
parks the draft via `EditManager.rescueDraft` into the edits-leftovers dir,
|
||||||
|
offered back at next boot. Known limit: the last ≤400 ms of keystrokes can
|
||||||
|
be lost on an instant type-then-close.
|
||||||
|
3. ⌘Q → the app-level `before-quit` guard (cancellable) counts dirty editors
|
||||||
|
plus running transfers.
|
||||||
|
|
||||||
|
`editorSave` is a dedicated request (not generic `writeFile`) so one handler
|
||||||
|
atomically writes + cleans the bun record + retitles + pushes `pathRefresh`
|
||||||
|
to the main window (panels showing that directory refresh). Editor upgrades:
|
||||||
|
scroll-synced line gutter (hidden when wrap is on — wrapped line numbers would
|
||||||
|
need per-edit height measurement), Alt/⌥Z wrap toggle, custom find bar
|
||||||
|
(matches computed in TS, `setSelectionRange` + scrollTop math — deliberately
|
||||||
|
not `findInPage`, which is a shim on WebView2). 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`)
|
||||||
|
|
||||||
|
Single instance, centered over main, **modal to main only** (editors stay
|
||||||
|
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
|
||||||
|
click-swallowing overlay (`managerState` message); main drops its keyboard
|
||||||
|
shortcuts and bun drops main-targeted menu actions while open. Connect /
|
||||||
|
open-stack execute in the MAIN window via the `connect:<id>` /
|
||||||
|
`open-stack:<id>` menuAction channel (the manager closes right after — a
|
||||||
|
failed connect surfaces in main). Store edits push `connectionsChanged` so
|
||||||
|
main's `ConnectionsService` reloads; a reload-retry in `App.findConnection`
|
||||||
|
covers the save→connect race. `setAlwaysOnTop` is deliberately NOT used (it
|
||||||
|
would float over editor windows).
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
- `fs/provider.ts` — `FileSystemProvider` interface: uniform local/SFTP ops +
|
||||||
|
path helpers + streams + **`fsKey`** (see self-copy below).
|
||||||
|
- `fs/local.ts` — node:fs, `lstat`-based, `fsKey="local"`. Owner/group via
|
||||||
|
cached `id -nu` / `dscacheutil|getent` spawns (win32 → undefined).
|
||||||
|
`listLocalDrives()` for the Windows drive picker (`fsutil`).
|
||||||
|
- `fs/sftp.ts` — ssh2 under Bun (pure JS; native accelerators are build
|
||||||
|
`external`s). POSIX paths always. `fsKey = resolvedIP:port` via `dns.lookup`
|
||||||
|
at connect. Owner/group parsed from the ls-style readdir `longname`.
|
||||||
|
Keepalive 15 s; `closedByUs` flag so `dispose()` doesn't fire the onClose
|
||||||
|
callback. mtime seconds→ms; mode masked `& 0o7777`.
|
||||||
|
- `fs/registry.ts` — sessionId → provider map; `"local"` is built-in;
|
||||||
|
`add()` mints `sftp-<n>`; `drop()` (session died) vs `remove()` (clean).
|
||||||
|
- `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
|
||||||
|
(`wake()` reserves slots synchronously — an async waiter increment was a
|
||||||
|
race). Per-job worker pool (`MAX_WORKERS_PER_JOB=8`) draws from the shared
|
||||||
|
slot budget, so in-folder parallelism and cross-job fairness share one knob
|
||||||
|
(`setConcurrency` from settings; `single` mode = 1). `activeStreams` set
|
||||||
|
enables hard cancel. `emit` throttled 100 ms. `failSession` fails that
|
||||||
|
session's jobs. Probe files: `.{appname}-probe-*`.
|
||||||
|
- `transfers/walk.ts` — recursive tree walk (segment arrays, skips symlinks)
|
||||||
|
+ `removeRecursive`.
|
||||||
|
- **Self-copy protection** (data-loss class): copying a file onto itself
|
||||||
|
truncates it (`createWriteStream` O_TRUNCs before the read). Guards compare
|
||||||
|
**fsKey, never sessionId** — two sessions to one server are distinct
|
||||||
|
sessions on one filesystem. When fsKeys differ but paths collide,
|
||||||
|
`probeSameFilesystem` writes a token via dst and reads it via src to unmask
|
||||||
|
hidden aliases (SFTP has no dev/inode). Fails open (permissions produce
|
||||||
|
their own error). Residual risk (documented in README): same filesystem
|
||||||
|
mounted at different paths.
|
||||||
|
- `edits.ts` — `readFileText`/`writeFileText` (2 MB cap) for the built-in
|
||||||
|
editor; `EditManager` for the external-editor flow: temp-download under
|
||||||
|
`appDataDir()/edits/<id>/` + `meta.json`, directory watch (survives atomic
|
||||||
|
renames), 400 ms debounce → engine upload. Watchers die with their session;
|
||||||
|
leftover dirs are offered for cleanup at boot. `launchEditor` is the
|
||||||
|
platform seam (`open` / `cmd /c start` / `xdg-open`).
|
||||||
|
- `store/paths.ts` — per-OS `appDataDir()` + `migrateLegacyDataDir()` (renames
|
||||||
|
a `BRANDING.legacyNames` dir into place on boot; list currently empty).
|
||||||
|
- `store/connections.ts` — plaintext-passwords JSON (deliberate v1 choice),
|
||||||
|
atomic tmp+rename, 0600 + win32 `icacls` ACL. `store/settings.ts` — defaults
|
||||||
|
spread-merge on load, so NEW SETTINGS KEYS need no backend change.
|
||||||
|
- `menu.ts` — macOS-only app menu, rebuilt on every connections change (the
|
||||||
|
Connect submenu mirrors the store), language switch and keyboard remap.
|
||||||
|
**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;
|
||||||
|
Windows colored icon. No `setImage` calls ever (they lose the template
|
||||||
|
treatment) and no menu (clicks arrive directly).
|
||||||
|
- `index.ts` — composition root: wiring order matters (events assigned after
|
||||||
|
rpc; manager/editors constructed after win; quit guard; focus tracking for
|
||||||
|
notification suppression across all windows).
|
||||||
|
|
||||||
|
## UI 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
|
||||||
|
through plain callback registries, never signals**: a signal consumed from
|
||||||
|
an effect that also writes signals re-triggers itself (this froze the whole
|
||||||
|
app once), and signal coalescing drops progress snapshots. All requests are
|
||||||
|
gated on the socket-open promise (`electroview.hostSocket`) — on 1.18.1 calls
|
||||||
|
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,
|
||||||
|
entries, sort, cursor, selection, type-to-filter. `sorted` computed:
|
||||||
|
filter → dirs-first sort (name/size/mtime numeric, kind/rights/owner/group
|
||||||
|
via display-string compare) → synthetic `'..'` row unshifted unless
|
||||||
|
`isRootPath(cwd)`. `touched` marks user navigation (connect flows only
|
||||||
|
auto-load `localDir` into an untouched local panel). `connectToken`
|
||||||
|
supersedes stale connects. `Workspace` = tab = left/right pair + activeSide.
|
||||||
|
Remote goes RIGHT on connect/open-stack (commander convention); per-panel
|
||||||
|
dropdown connects target their own panel. **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
|
||||||
|
(`SPEED_SMOOTHING=0.7`); `copyToOtherPanel` guards same-fs+same-dir before
|
||||||
|
enqueue and runs the collision flow (same-path auto-skip, type-mismatch
|
||||||
|
skip, overwrite dialog with all/none batch modes).
|
||||||
|
- `settings.service.ts` — one signal per setting; **imperative apply pattern**
|
||||||
|
(`applyTheme/DarkTone` set `data-*` attributes on `<html>`;
|
||||||
|
`applyUiScale` sets its inline `font-size`); `applyAll` also serves the
|
||||||
|
`settingsChanged` broadcast, so every window follows a save made in any
|
||||||
|
other. Every window RPC serves `saveSettings` (editor and manager windows
|
||||||
|
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
|
||||||
|
main window's panels listen — but a panel only takes focus while NO dialog or
|
||||||
|
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
|
||||||
|
`connectionsChanged` push.
|
||||||
|
- `i18n.service.ts` — runtime translator. `locale` signal + `t('ns.key', params)`
|
||||||
|
(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
|
||||||
|
(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`,
|
||||||
|
`EDITOR_CONTEXT`, `IS_MAC`/`IS_MAC_CHROME`, `IS_WINDOWS`, `IS_LINUX`) +
|
||||||
|
`blockNativeContextMenu` (kills WebKit's Reload/Inspect menu outside text
|
||||||
|
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
|
||||||
|
|
||||||
|
`ui/src/styles.css`: OKLCH tokens adapted from **Oxide's design system**
|
||||||
|
(docs.oxide.computer: a cool neutral scale at hue 260 with almost no chroma,
|
||||||
|
one green accent, opaque strokes; the comments carry Oxide's step names
|
||||||
|
n0…n1300 / green-800). Dark values are duplicated in `:root[data-theme='dark']`
|
||||||
|
AND a `prefers-color-scheme` media block (system mode) — every dark token
|
||||||
|
change goes in BOTH; 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`,
|
||||||
|
`text-icon-folder`, `bg-toolbar-2`, `bg-cursor-tint`). File-type icon colors
|
||||||
|
come from Oxide's hue scales (800 steps dark, 600 light). **Icon sizes** follow
|
||||||
|
TDesign's rule (an icon is 2px larger than its text, in whole pixels) through
|
||||||
|
four rem tokens, and every `<ng-icon>` takes one: `--icon-size-sm` 14px (beside
|
||||||
|
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.
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# Build & release
|
||||||
|
|
||||||
|
## Versioning — "Build n"
|
||||||
|
|
||||||
|
Sublime-style: the version IS the commit count. `scripts/gen-version.ts` runs
|
||||||
|
`git rev-list --count HEAD` + `git rev-parse --short HEAD` and writes the
|
||||||
|
**gitignored** `src/shared/version.gen.ts` (`BUILD = {number, commit, date,
|
||||||
|
electrobun}`). It is regenerated by `postinstall` and at the start of every
|
||||||
|
`dev`/`build`/`start`/`release` script; committing it would bump the count it
|
||||||
|
records, hence gitignored. Fresh checkouts fail `tsc` until `bun install` has
|
||||||
|
run once (postinstall creates the file). `BRANDING.version` stays
|
||||||
|
semver-shaped (`0.1.<n>`) for electrobun's config/updater; `BRANDING.buildLabel`
|
||||||
|
("Build n") is what humans see (About box, Settings → About).
|
||||||
|
|
||||||
|
## Dev loop
|
||||||
|
|
||||||
|
- `bun run dev` — gen-version → `electrobun prepare` → scripts/dev.ts: starts
|
||||||
|
`ng serve` (HMR) and `electrobun dev --watch`, with `DEV_SERVER_URL` pointing
|
||||||
|
the webviews at the dev server (only honored on the dev channel and only if
|
||||||
|
the server answers).
|
||||||
|
- `bun run build` — gen-version → `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/)
|
||||||
|
fakes a Node version the Angular CLI accepts under Bun. Both preloads are
|
||||||
|
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
|
||||||
|
|
||||||
|
`bun scripts/release.ts [--target mac-arm64|win-x64|linux-x64]`
|
||||||
|
(default: host).
|
||||||
|
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>.*`.
|
||||||
|
|
||||||
|
Facts the script encodes:
|
||||||
|
|
||||||
|
- **`--env=stable` is mandatory for releases** — `electrobun build` defaults to
|
||||||
|
the dev channel (different app name `miniSCP-dev`, no distributable artifacts).
|
||||||
|
- **No cross-compile, and no Intel Mac.** Hutch builds for its host OS *and*
|
||||||
|
arch only; packaging needs host tools (codesign/hdiutil on macOS,
|
||||||
|
rcedit/PowerShell on Windows). Hence one script run per artifact, on the
|
||||||
|
matching machine; `win-x64` on a non-Windows host and `linux-x64` on a
|
||||||
|
non-Linux host both error out. **Electrobun 2.x publishes no macOS x64 core
|
||||||
|
(nor an x64 Hutch)**, so mac-x64 was dropped — the v1 trick of running the x64
|
||||||
|
CLI under Rosetta has nothing left to run. `--target mac-x64`, and any build
|
||||||
|
on an Intel Mac, fail with that message.
|
||||||
|
- **Runtime downloads**: on first use per Electrobun version Hutch fetches the
|
||||||
|
platform core from `github.com/blackboardsh/electrobun/releases`
|
||||||
|
(SHA-256-checked against `electrobun-artifacts.json`), plus Cottontail and the
|
||||||
|
pinned Bun, into `~/.hutch` — network is a build-time requirement on every
|
||||||
|
fresh machine/runner.
|
||||||
|
- **`artifacts/` is emptied before the build** — `site/deploy.sh` publishes it
|
||||||
|
verbatim, so it must hold exactly this build (stale files, e.g. v1-named
|
||||||
|
installers, would otherwise ship).
|
||||||
|
- **Artifact shapes** (stable channel, in `artifacts/`): 2.x installers carry
|
||||||
|
**no channel prefix**; only the updater feed keeps `stable-<os>-<arch>-` (the
|
||||||
|
names v1.18.1+ clients request). macOS → `macos-arm64-miniSCP.dmg` +
|
||||||
|
`stable-macos-arm64-miniSCP.app.tar.zst` + `stable-macos-arm64-update.json`
|
||||||
|
(verified); Windows → `win-x64-miniSCP-Setup.zip` (setup exe + hidden payload)
|
||||||
|
+ the `stable-win-x64-*` feed, plus the script's own **portable zip** of
|
||||||
|
`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
|
||||||
|
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)
|
||||||
|
|
||||||
|
`.gitea/workflows/release.yml` — tag-triggered (`build-*`), macos (mac-arm64) +
|
||||||
|
windows (win-x64) runner matrix, each running the same release script; inert
|
||||||
|
until act_runners with those labels are registered. Needs `fetch-depth: 0` (the
|
||||||
|
build number is the commit count); `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
|
||||||
|
|
||||||
|
`scripts/gen-icons.ts` (resvg, dev-time): renders `assets/logo.svg` into the
|
||||||
|
macOS iconset + `icon.png`, a multi-size `assets/icon.ico` (Windows installer/
|
||||||
|
taskbar, wired via `electrobun.config.ts win.icon`), the macOS tray template
|
||||||
|
PNG from `assets/tray/tray-glyph.svg`, and a small colored Windows tray PNG.
|
||||||
|
Rerun after touching either SVG; outputs are committed (build inputs).
|
||||||
|
|
||||||
|
## electrobun config notes
|
||||||
|
|
||||||
|
`electrobun.config.ts`: app identity comes from `BRANDING`; **`build.mainProcess:
|
||||||
|
"bun"` is load-bearing** — 2.x defaults to Cottontail, which ignores the whole
|
||||||
|
`build.bun` block (verified: without the line, `bunx electrobun config
|
||||||
|
--env=stable` resolves `mainProcess: "cottontail"` with no externals);
|
||||||
|
`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.
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
# Gotchas — landmines this codebase has already stepped on
|
||||||
|
|
||||||
|
Every one of these caused a real bug or debugging spiral. Read before
|
||||||
|
touching the adjacent code.
|
||||||
|
|
||||||
|
## Angular (v22, zoneless, signals, OnPush)
|
||||||
|
|
||||||
|
- **Never consume push streams through signal+effect.** An effect that reads
|
||||||
|
a signal and (directly or transitively) writes signals re-triggers itself —
|
||||||
|
this froze the entire app on the first transfer. Push messages
|
||||||
|
(transferUpdate etc.) go through plain callback registries in RpcService.
|
||||||
|
Signal coalescing would also silently drop intermediate progress snapshots.
|
||||||
|
- **No signal writes during template render** (NG0600). Anything that loads
|
||||||
|
panels from code paths reachable during render defers with
|
||||||
|
`queueMicrotask` (see `PanelsService.newWorkspace`).
|
||||||
|
- **`[value]` on `<select>` is applied before `@for` options render** and
|
||||||
|
silently selects nothing. Use per-option `[selected]` bindings (connection
|
||||||
|
manager stack picker).
|
||||||
|
- Template bindings with special characters: `[class.bg-primary/15]` works;
|
||||||
|
`[class.shadow-[...]]` (brackets) does NOT parse — use `[style.box-shadow]`.
|
||||||
|
Static class attributes may use arbitrary values freely.
|
||||||
|
- Functions called from templates ARE reactive: signals read inside them
|
||||||
|
register on the view's consumer (call-stack-based tracking). That's why a
|
||||||
|
template calling `i18n.t()` re-renders on a language switch — `t()` reads
|
||||||
|
the `locale` signal.
|
||||||
|
- Dedicated tools > shell: IDE diagnostics between paired template/class
|
||||||
|
edits are often stale mid-sequence — trust `bun run build:ui` (full AOT
|
||||||
|
type-check), not the first red squiggle.
|
||||||
|
- Tailwind v4 canonical class names: `wrap-break-word` not `break-words`,
|
||||||
|
`accent-primary` not `accent-[var(--primary)]`, `z-60` not `z-[60]`.
|
||||||
|
Every color used in a template must resolve through `@theme inline`.
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- **The `electrobun` npm package (2.x) is a bootstrap whose every export is a
|
||||||
|
throwing stub** ("Electrobun 2.x APIs come from the Hutch devkit, not
|
||||||
|
node_modules"). Every `electrobun/*` import must resolve through the
|
||||||
|
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
|
||||||
|
transport; a shared instance silently sends to the wrong webview.
|
||||||
|
- **RPC before socket open was silently lost on 1.18.1.** 2.x Electroview queues
|
||||||
|
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
|
||||||
|
you want garbage collected.
|
||||||
|
- `electrobun build` defaults to the **dev** channel; release builds need
|
||||||
|
`--env=stable`. The stable app name differs from the dev one
|
||||||
|
(`miniSCP` vs `miniSCP-dev`), including the app-data dir implications when
|
||||||
|
testing.
|
||||||
|
- Tray: never call `setImage` after construction on macOS — the template
|
||||||
|
treatment is lost and the icon renders black on dark menu bars.
|
||||||
|
- `ApplicationMenu` is macOS-only in our usage; accelerators on Windows are
|
||||||
|
single-character only.
|
||||||
|
- **`ContextMenu.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
|
||||||
|
white/inverted blink returns: (1) windows are created `hidden: true` and
|
||||||
|
revealed by `showWhenReady` on the webview's first `dom-ready` (800 ms
|
||||||
|
fallback), (2) the bun preload bakes the persisted `data-theme`/tone
|
||||||
|
attributes and the interface scale before first paint (`buildPreload`),
|
||||||
|
(3) `ui/src/index.html` carries an inline `<style>` with the html background
|
||||||
|
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
|
||||||
|
when `DEV_SERVER_URL` is set AND the updater channel is `dev` AND the
|
||||||
|
server answers a HEAD request; secondary windows must reuse the resolved
|
||||||
|
URL, never hardcode `views://`.
|
||||||
|
- Upstream Bun panic (`FFI_Callback_threadsafe_call` → `web_worker.spin`):
|
||||||
|
rare segfault under long sessions; upstream electrobun×Bun issue, restart
|
||||||
|
recovers, nothing app-side to fix. 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
|
||||||
|
|
||||||
|
- **sessionId ≠ filesystem identity.** Two panels on the same server are two
|
||||||
|
SSH sessions. Every same-file/nested-path guard compares `fsKey`
|
||||||
|
(`resolvedIP:port` / `"local"`); comparing sessionId caused a
|
||||||
|
truncate-to-zero data-loss bug. When fsKeys differ but paths collide, the
|
||||||
|
probe-file check unmasks aliased hosts. Never "simplify" this back.
|
||||||
|
- `createWriteStream` truncates BEFORE the source is read — that's why
|
||||||
|
self-copy is destructive, and why guards must run before any stream opens.
|
||||||
|
- The synthetic `'..'` row: type `'dir'`, must be excluded from selection,
|
||||||
|
targets(), filtering match-count, kind/rights/owner display, and drag
|
||||||
|
sources. Any new row-level feature must decide what `'..'` does.
|
||||||
|
- `Semaphore.wake()` must reserve slots synchronously; waking waiters that
|
||||||
|
increment asynchronously loses budget under concurrent release.
|
||||||
|
- Settings: the backend spread-merges (`{...DEFAULT_SETTINGS, ...data}`) so
|
||||||
|
new keys auto-default, but the UI SettingsService reads field-by-field —
|
||||||
|
a new setting silently stays at default until wired in all five spots.
|
||||||
|
- **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);
|
||||||
|
don't shrink the 400 ms debounce.
|
||||||
|
- `panel.touched` gates localDir auto-navigation; `connectToken` guards
|
||||||
|
against a slow connect resolving into a panel the user already reused.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- Angular CLI rejects Bun's Node-compat version — `scripts/node-version-shim.ts`
|
||||||
|
is preloaded via `bunfig.toml` at the root AND in `ui/`. Removing either
|
||||||
|
breaks `ng`.
|
||||||
|
- ssh2's optional native deps must stay `external: ["cpu-features", "*.node"]`
|
||||||
|
in `electrobun.config.ts` — bundling them breaks the build; ssh2 falls back
|
||||||
|
to pure JS at runtime. (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
|
||||||
|
not do what you want).
|
||||||
|
- `version.gen.ts` is gitignored and regenerated by postinstall + every build
|
||||||
|
script; a fresh checkout fails `tsc` until `bun install` (or any script)
|
||||||
|
has run once. Never commit it — it would bump the commit count it records.
|
||||||
|
- Icon pipeline is `scripts/gen-icons.ts` (resvg) — regenerate after touching
|
||||||
|
`assets/logo.svg` or `assets/tray/tray-glyph.svg`; the .ico and tray PNGs
|
||||||
|
are build inputs, not build outputs.
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# Platform notes — macOS / Windows / Linux behavior matrix
|
||||||
|
|
||||||
|
macOS (Apple Silicon) is the primary target; Windows 11+ x64 and Linux x64 are
|
||||||
|
**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.
|
||||||
|
|
||||||
|
## Seam inventory (where platform branches live)
|
||||||
|
|
||||||
|
| Concern | File | mac | win | linux |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 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` |
|
||||||
|
| Editor app picker | `rpc-handlers.ts pickEditorApp` | /Applications, `.app` | %ProgramFiles%, `.exe` | home, any |
|
||||||
|
| Owner/group names | `fs/local.ts resolveId` | `id -nu` + `dscacheutil` | undefined → "—" | `id -nu` + `getent` |
|
||||||
|
| Drive roots | `fs/local.ts listLocalDrives` | empty | `fsutil fsinfo drives` | empty |
|
||||||
|
| chmod/Permissions | context-menu.service.ts, file-panel, file-actions | full | hidden for LOCAL panels (only flips read-only); SFTP unaffected | full |
|
||||||
|
| 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 | `src/shared/keymap.ts formatCombo` (+ `keys.names.*`) | ⌘⇧⌥⌃ glyphs: ⇧⌘N | Ctrl+Shift+N, localized key names (Strg+Entf) | as Windows |
|
||||||
|
| Command modifier | `keymap.ts` `Mod` | ⌘ only (⌃ stays Cocoa's text keys) | Ctrl | Ctrl |
|
||||||
|
| 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 |
|
||||||
|
| App data dir | `store/paths.ts` | ~/Library/Application Support | %APPDATA% | $XDG_CONFIG_HOME/.config |
|
||||||
|
| ssh-agent | `fs/sftp.ts` | $SSH_AUTH_SOCK | `\\.\pipe\openssh-ssh-agent` fallback | $SSH_AUTH_SOCK |
|
||||||
|
| Paths in UI | `ui format.ts joinPath/isRootPath` | "/" | separator detected from the dir string; drive-root regex | "/" |
|
||||||
|
|
||||||
|
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
|
||||||
|
(`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)
|
||||||
|
|
||||||
|
- **HiDPI blur** — WebView2 renders at 1x on scaled displays
|
||||||
|
(blackboardsh/electrobun#324, seen on 1.18.1). Most user-visible defect; 2.x
|
||||||
|
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
|
||||||
|
Windows toasts.
|
||||||
|
- **Unsigned binaries** — Setup.exe and the portable exe both trigger
|
||||||
|
SmartScreen / Mark-of-the-Web on first run. No Authenticode support (2.x:
|
||||||
|
"Windows release signing is not currently part of Hutch's packaging
|
||||||
|
pipeline"; a signtool post-hook is an option later). Installer unpacks to
|
||||||
|
`%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
|
||||||
|
non-mac windows use the native titlebar.
|
||||||
|
- `findInPage` is a `window.find()` shim on WebView2 — irrelevant here, the
|
||||||
|
editor find is custom.
|
||||||
|
|
||||||
|
## First-Windows-boot smoke test
|
||||||
|
|
||||||
|
Code paths written blind (no Windows box was available); verify in order:
|
||||||
|
|
||||||
|
1. Build: `bun scripts/release.ts --target win-x64` on the Windows machine —
|
||||||
|
Hutch, the core and the pinned Bun download into `~/.hutch` on first run;
|
||||||
|
Setup.zip AND the portable zip appear under `release/win-x64/` (the portable
|
||||||
|
step warns instead of failing if the assembled-app folder layout differs —
|
||||||
|
fix the `appDir` path in release.ts if so). 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.
|
||||||
|
3. Drives dropdown in each panel lists `C:\` (+ others); navigating to a
|
||||||
|
drive root hides the `'..'` row; the path bar shows consistent `\` paths.
|
||||||
|
4. local↔remote transfers both directions; self-copy guard still blocks
|
||||||
|
same-path copies.
|
||||||
|
5. External editor: default app opens via `cmd /c start`; the Choose… picker
|
||||||
|
starts in Program Files and accepts an .exe; remote temp-edit → save →
|
||||||
|
auto-upload works (directory watcher on NTFS).
|
||||||
|
6. Context menus: Send to Other Panel is the first row item; Show in Explorer,
|
||||||
|
Move to Recycle Bin (item restorable from the bin), Copy Path; Permissions
|
||||||
|
ABSENT on local rows, present on remote.
|
||||||
|
7. Ctrl+T/W/F/S and Ctrl+digit tab jumps; tooltips read "Ctrl+…".
|
||||||
|
8. connections.json ACL: `icacls connections.json` shows only the current
|
||||||
|
user after saving a connection.
|
||||||
|
9. Editor/manager/Settings windows open, modal bounce works, Ctrl+, opens
|
||||||
|
Settings and About shows from its About section.
|
||||||
|
10. Known-issue check: HiDPI blur on a scaled display (seen on 1.18.1 — 2.x's
|
||||||
|
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
|
||||||
@@ -27,7 +38,8 @@ export default {
|
|||||||
bundleCEF: false,
|
bundleCEF: false,
|
||||||
},
|
},
|
||||||
win: {
|
win: {
|
||||||
bundleCEF: false,
|
bundleCEF: false, // system WebView2 keeps the bundle small
|
||||||
|
icon: "assets/icon.ico",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} satisfies ElectrobunConfig;
|
} satisfies ElectrobunConfig;
|
||||||
|
|||||||
@@ -3,16 +3,19 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"workspaces": ["ui"],
|
"workspaces": [
|
||||||
|
"ui"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "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 run build:ui && bunx electrobun build",
|
"build": "bun scripts/gen-version.ts && bunx electrobun prepare && bun run build:ui && bunx electrobun build",
|
||||||
"start": "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 && bunx electrobun prepare",
|
||||||
|
"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"
|
||||||
},
|
},
|
||||||
@@ -20,7 +23,11 @@
|
|||||||
"@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",
|
||||||
"typescript": "~5.9.3"
|
"typescript": "~5.9.3"
|
||||||
},
|
},
|
||||||
"trustedDependencies": ["@parcel/watcher"]
|
"trustedDependencies": [
|
||||||
|
"@parcel/watcher"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
// Regenerate every raster icon asset from the SVG sources (dev-time only).
|
// Regenerate every raster icon asset from the SVG sources (dev-time only).
|
||||||
// bun scripts/gen-icons.ts
|
// bun scripts/gen-icons.ts
|
||||||
// Produces: assets/icon.png (1024), assets/icon.iconset/* (macOS app icon),
|
// Produces: assets/icon.png (1024), assets/icon.iconset/* (macOS app icon),
|
||||||
// assets/tray/tray-44-template.png.
|
// assets/icon.ico (Windows), assets/tray/tray-44-template.png (macOS tray),
|
||||||
|
// assets/tray/tray-32-color.png (Windows tray).
|
||||||
import { mkdir } from "node:fs/promises";
|
import { mkdir } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { Resvg } from "@resvg/resvg-js";
|
import { Resvg } from "@resvg/resvg-js";
|
||||||
|
import pngToIco from "png-to-ico";
|
||||||
|
|
||||||
const root = path.join(import.meta.dir, "..");
|
const root = path.join(import.meta.dir, "..");
|
||||||
const assets = path.join(root, "assets");
|
const assets = path.join(root, "assets");
|
||||||
@@ -27,8 +29,13 @@ for (const size of [16, 32, 128, 256, 512]) {
|
|||||||
await writePng(path.join(assets, "icon.iconset", `icon_${size}x${size}@2x.png`), logo, size * 2);
|
await writePng(path.join(assets, "icon.iconset", `icon_${size}x${size}@2x.png`), logo, size * 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- tray base (22pt status item @2x) ----
|
// ---- Windows app icon (.ico: 16/32/48/256, used by installer + taskbar) ----
|
||||||
|
const icoPngs = [16, 32, 48, 256].map((size) => Buffer.from(renderPng(logo, size)));
|
||||||
|
await Bun.write(path.join(assets, "icon.ico"), await pngToIco(icoPngs));
|
||||||
|
|
||||||
|
// ---- tray: macOS 22pt template @2x; Windows small colored icon ----
|
||||||
const trayGlyph = await Bun.file(path.join(assets, "tray", "tray-glyph.svg")).text();
|
const trayGlyph = await Bun.file(path.join(assets, "tray", "tray-glyph.svg")).text();
|
||||||
await writePng(path.join(assets, "tray", "tray-44-template.png"), trayGlyph, 44);
|
await writePng(path.join(assets, "tray", "tray-44-template.png"), trayGlyph, 44);
|
||||||
|
await writePng(path.join(assets, "tray", "tray-32-color.png"), logo, 32);
|
||||||
|
|
||||||
console.log("icons regenerated: app iconset + tray template");
|
console.log("icons regenerated: app iconset + icon.ico + tray images");
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Writes src/shared/version.gen.ts (gitignored) with the "Build n" version:
|
||||||
|
// n = commit count since the repo root, Sublime Text style. Regenerated by
|
||||||
|
// postinstall and by every dev/build/release script — never committed, since
|
||||||
|
// committing it would bump the very count it records.
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
function git(args: string[]): string | null {
|
||||||
|
try {
|
||||||
|
const proc = Bun.spawnSync(["git", ...args], {
|
||||||
|
cwd: import.meta.dir,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "ignore",
|
||||||
|
});
|
||||||
|
if (!proc.success) return null;
|
||||||
|
return proc.stdout.toString().trim() || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const number = Number(git(["rev-list", "--count", "HEAD"]) ?? 0);
|
||||||
|
const commit = git(["rev-parse", "--short", "HEAD"]) ?? "dev";
|
||||||
|
const date = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
let electrobun = "unknown";
|
||||||
|
try {
|
||||||
|
const pkg = await Bun.file(
|
||||||
|
path.join(import.meta.dir, "..", "node_modules", "electrobun", "package.json"),
|
||||||
|
).json();
|
||||||
|
electrobun = pkg.version;
|
||||||
|
} catch {
|
||||||
|
// not installed yet (postinstall ordering) — fine
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = `// GENERATED by scripts/gen-version.ts — do not edit, do not commit.
|
||||||
|
export const BUILD = {
|
||||||
|
number: ${number},
|
||||||
|
commit: ${JSON.stringify(commit)},
|
||||||
|
date: ${JSON.stringify(date)},
|
||||||
|
electrobun: ${JSON.stringify(electrobun)},
|
||||||
|
} as const;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const target = path.join(import.meta.dir, "..", "src", "shared", "version.gen.ts");
|
||||||
|
await Bun.write(target, out);
|
||||||
|
console.log(`[version] Build ${number} (${commit}) -> ${path.relative(process.cwd(), target)}`);
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// Build one release artifact for one target, on the matching host:
|
||||||
|
// bun scripts/release.ts [--target mac-arm64|win-x64|linux-x64]
|
||||||
|
// Default target = the host. electrobun cannot cross-compile (Hutch builds for
|
||||||
|
// its host OS and arch only; packaging needs host tools: codesign/hdiutil on
|
||||||
|
// mac, rcedit/PowerShell on Windows), so win-x64 must run on a Windows machine.
|
||||||
|
// There is no mac-x64: Electrobun 2.x publishes no macOS x64 core at all.
|
||||||
|
// Artifacts land in release/<target>/miniSCP-Build<N>-<target><ext>.
|
||||||
|
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const ROOT = path.join(import.meta.dir, "..");
|
||||||
|
const TARGETS = ["mac-arm64", "win-x64", "linux-x64"] as const;
|
||||||
|
type Target = (typeof TARGETS)[number];
|
||||||
|
|
||||||
|
function hostTarget(): Target {
|
||||||
|
if (process.platform === "win32") return "win-x64";
|
||||||
|
if (process.platform === "linux") return "linux-x64";
|
||||||
|
return "mac-arm64";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message: string): never {
|
||||||
|
console.error(`\n[release] ERROR: ${message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(cmd: string[], opts: { cwd?: string } = {}): void {
|
||||||
|
console.log(`[release] $ ${cmd.join(" ")}`);
|
||||||
|
const proc = Bun.spawnSync(cmd, {
|
||||||
|
cwd: opts.cwd ?? ROOT,
|
||||||
|
stdout: "inherit",
|
||||||
|
stderr: "inherit",
|
||||||
|
});
|
||||||
|
if (!proc.success) fail(`command failed: ${cmd.join(" ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- target selection + host guards ----
|
||||||
|
|
||||||
|
const argIndex = process.argv.indexOf("--target");
|
||||||
|
const arg = argIndex >= 0 ? process.argv[argIndex + 1] : hostTarget();
|
||||||
|
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") {
|
||||||
|
fail("win-x64 must be built on a Windows machine/runner — electrobun cannot cross-compile.");
|
||||||
|
}
|
||||||
|
if (requested === "linux-x64" && process.platform !== "linux") {
|
||||||
|
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).");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- prepare: version, devkit, icons, UI bundle ----
|
||||||
|
|
||||||
|
run(["bun", "scripts/gen-version.ts"]);
|
||||||
|
run(["bunx", "electrobun", "prepare"]);
|
||||||
|
run(["bun", "scripts/gen-icons.ts"]);
|
||||||
|
run(["bun", "run", "--cwd", "ui", "build"]);
|
||||||
|
|
||||||
|
const { BUILD } = await import("../src/shared/version.gen");
|
||||||
|
const { BRANDING } = await import("../src/shared/branding");
|
||||||
|
const buildName = `Build${BUILD.number}`;
|
||||||
|
|
||||||
|
// ---- electrobun build ----
|
||||||
|
|
||||||
|
// Start from an empty artifacts/: site/deploy.sh publishes it verbatim (the
|
||||||
|
// updater contract), so an earlier build's files must not ride along.
|
||||||
|
const artifactsDir = path.join(ROOT, "artifacts");
|
||||||
|
rmSync(artifactsDir, { recursive: true, force: true });
|
||||||
|
run(["bunx", "electrobun", "build", "--env=stable"]);
|
||||||
|
|
||||||
|
// ---- collect artifacts ----
|
||||||
|
|
||||||
|
const [osName, archName] =
|
||||||
|
requested === "win-x64" ? ["win", "x64"] : requested === "linux-x64" ? ["linux", "x64"] : ["macos", "arm64"];
|
||||||
|
// 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);
|
||||||
|
mkdirSync(outDir, { recursive: true });
|
||||||
|
|
||||||
|
const produced = readdirSync(artifactsDir).filter(
|
||||||
|
(f) => f.startsWith(prefix) && f.includes(BRANDING.appName),
|
||||||
|
);
|
||||||
|
if (!produced.length) fail(`no ${prefix}* installer found in artifacts/ — did the build run?`);
|
||||||
|
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const file of produced) {
|
||||||
|
const ext = [".dmg", "-Setup.zip", "-Setup.tar.gz"].find((e) => file.endsWith(e));
|
||||||
|
if (!ext) continue;
|
||||||
|
const name = `${BRANDING.appName}-${buildName}-${requested}${ext}`;
|
||||||
|
cpSync(path.join(artifactsDir, file), path.join(outDir, name));
|
||||||
|
out.push(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows: also zip the assembled app folder — the portable build (unzip
|
||||||
|
// anywhere and run) next to the Setup installer. Best-effort: the folder
|
||||||
|
// layout is confirmed by the first real 2.x Windows build.
|
||||||
|
if (requested === "win-x64") {
|
||||||
|
const appDir = path.join(ROOT, "build", "stable-win-x64", BRANDING.appName);
|
||||||
|
if (existsSync(appDir)) {
|
||||||
|
const portable = path.join(outDir, `${BRANDING.appName}-${buildName}-win-x64-portable.zip`);
|
||||||
|
run(["powershell", "-Command", `Compress-Archive -Force -Path '${appDir}\\*' -DestinationPath '${portable}'`]);
|
||||||
|
out.push(path.basename(portable));
|
||||||
|
} else {
|
||||||
|
console.warn(`[release] WARN: portable skipped — app folder not found at ${appDir}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!out.length) fail("artifacts were found but none matched a known package extension.");
|
||||||
|
console.log(`\n[release] ${BRANDING.appName} ${BRANDING.buildLabel} (${BUILD.commit}) — ${requested}`);
|
||||||
|
for (const name of out) console.log(`[release] release/${requested}/${name}`);
|
||||||
@@ -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,91 +0,0 @@
|
|||||||
import { ContextMenu, Utils, type ApplicationMenuItemConfig } from "electrobun/bun";
|
|
||||||
|
|
||||||
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;
|
|
||||||
const ctx = this.context;
|
|
||||||
if (!action || !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));
|
|
||||||
}
|
|
||||||
|
|
||||||
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 in Finder", 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" });
|
|
||||||
items.push({ label: "Permissions", action: "ctx-chmod" });
|
|
||||||
items.push({ type: "separator" });
|
|
||||||
items.push({ label: "Copy Path", action: "ctx-copy-path" });
|
|
||||||
if (ctx.isLocal) {
|
|
||||||
items.push({ label: "Reveal in Finder", action: "ctx-reveal" });
|
|
||||||
}
|
|
||||||
items.push({ type: "separator" });
|
|
||||||
if (ctx.isLocal) {
|
|
||||||
items.push({
|
|
||||||
label: "Move to Trash",
|
|
||||||
action: "ctx-trash",
|
|
||||||
tooltip: "Finder's Restore is not available for items trashed from here",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
items.push({ label: "Delete", action: "ctx-delete" });
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { Utils, type BrowserWindow } from "electrobun/main";
|
||||||
|
import { BRANDING } from "../shared/branding";
|
||||||
|
import type { EditorOpenContext } from "../shared/rpc";
|
||||||
|
import type { AppSettings } from "../shared/types";
|
||||||
|
import { writeFileText, type EditManager } from "./edits";
|
||||||
|
import type { SessionRegistry } from "./fs/registry";
|
||||||
|
import { createEditorRPC } from "./rpc-handlers";
|
||||||
|
import { openSecondaryWindow, WindowRegistry } from "./windows";
|
||||||
|
import type { SettingsStore } from "./store/settings";
|
||||||
|
|
||||||
|
const EDITOR_WIDTH = 900;
|
||||||
|
const EDITOR_HEIGHT = 680;
|
||||||
|
|
||||||
|
interface EditorRecord {
|
||||||
|
win: BrowserWindow;
|
||||||
|
ctx: EditorOpenContext;
|
||||||
|
dirty: boolean;
|
||||||
|
/** Last mirrored content — what the native-close Save fallback writes. */
|
||||||
|
draft: string | null;
|
||||||
|
/** Set by editorClose: the webview already resolved its dirty prompt. */
|
||||||
|
suppressPrompt: boolean;
|
||||||
|
rpc: ReturnType<typeof createEditorRPC>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditorWindowsDeps {
|
||||||
|
registry: SessionRegistry;
|
||||||
|
settings: SettingsStore;
|
||||||
|
edits: EditManager;
|
||||||
|
/** The once-resolved dev/prod view url. */
|
||||||
|
url: string;
|
||||||
|
/** Live settings snapshot — baked into new windows' preloads (theme flash). */
|
||||||
|
currentSettings: () => AppSettings;
|
||||||
|
mainFrame: () => { x: number; y: number };
|
||||||
|
/** Late-bound senders targeting the MAIN window's webview. */
|
||||||
|
notifyMain: { pathRefresh: (info: { sessionId: string; dir: string }) => void };
|
||||||
|
/** An editor saved settings; `key` identifies it so the rebroadcast skips it. */
|
||||||
|
onSettingsSaved: (settings: AppSettings, key: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One native window per edited file (key: sessionId+path). The webview owns
|
||||||
|
* the styled dirty prompt for closes it can see (⌘W, Close button); the
|
||||||
|
* native traffic-light close is NOT cancellable in electrobun, so the draft
|
||||||
|
* mirrored via editorState lets us offer a native Save/Discard box after the
|
||||||
|
* window is already gone. A failed fallback save parks the draft in the
|
||||||
|
* edits leftovers dir (offered back at next boot).
|
||||||
|
*/
|
||||||
|
export class EditorWindows {
|
||||||
|
private windows: WindowRegistry<EditorRecord>;
|
||||||
|
|
||||||
|
constructor(private deps: EditorWindowsDeps) {
|
||||||
|
this.windows = new WindowRegistry(deps.mainFrame);
|
||||||
|
}
|
||||||
|
|
||||||
|
open(ctx: EditorOpenContext): void {
|
||||||
|
const key = `${ctx.sessionId}\0${ctx.path}`;
|
||||||
|
if (this.windows.activateExisting(key)) return;
|
||||||
|
|
||||||
|
const record: Partial<EditorRecord> = { ctx, dirty: false, draft: null, suppressPrompt: false };
|
||||||
|
const rpc = createEditorRPC(
|
||||||
|
{ registry: this.deps.registry, settings: this.deps.settings },
|
||||||
|
{
|
||||||
|
ctx,
|
||||||
|
onDraft: (state) => {
|
||||||
|
record.dirty = state.dirty;
|
||||||
|
record.draft = state.content;
|
||||||
|
this.setDirtyTitle(record as EditorRecord);
|
||||||
|
},
|
||||||
|
onSaved: () => {
|
||||||
|
record.dirty = false;
|
||||||
|
record.draft = null;
|
||||||
|
this.setDirtyTitle(record as EditorRecord);
|
||||||
|
this.deps.notifyMain.pathRefresh({
|
||||||
|
sessionId: ctx.sessionId,
|
||||||
|
dir: this.dirOf(ctx),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onCloseConfirmed: () => {
|
||||||
|
record.suppressPrompt = true;
|
||||||
|
record.win?.close();
|
||||||
|
},
|
||||||
|
onSettingsSaved: (next) => this.deps.onSettingsSaved(next, key),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const win = openSecondaryWindow({
|
||||||
|
title: this.baseTitle(ctx),
|
||||||
|
url: this.deps.url,
|
||||||
|
mode: "editor",
|
||||||
|
settings: this.deps.currentSettings(),
|
||||||
|
ctx,
|
||||||
|
frame: this.windows.nextFrame(EDITOR_WIDTH, EDITOR_HEIGHT),
|
||||||
|
rpc,
|
||||||
|
});
|
||||||
|
record.win = win;
|
||||||
|
record.rpc = rpc;
|
||||||
|
this.windows.set(key, record as EditorRecord);
|
||||||
|
this.windows.track(key, win, (k) => void this.handleNativeClose(k));
|
||||||
|
console.log(`[editor] opened window for ${ctx.sessionId}:${ctx.path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The window is already gone; decide the draft's fate from what we hold. */
|
||||||
|
private async handleNativeClose(key: string): Promise<void> {
|
||||||
|
const record = this.windows.get(key);
|
||||||
|
this.windows.delete(key);
|
||||||
|
if (!record || !record.dirty || record.suppressPrompt || record.draft === null) return;
|
||||||
|
|
||||||
|
const { ctx, draft } = record;
|
||||||
|
const { response } = await Utils.showMessageBox({
|
||||||
|
type: "warning",
|
||||||
|
title: BRANDING.appName,
|
||||||
|
message: `Save changes to "${ctx.name}"?`,
|
||||||
|
detail: "The editor window was closed with unsaved changes.",
|
||||||
|
buttons: ["Save", "Discard"],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 1,
|
||||||
|
});
|
||||||
|
if (response !== 0) return;
|
||||||
|
try {
|
||||||
|
await writeFileText(this.deps.registry.get(ctx.sessionId), ctx.path, draft);
|
||||||
|
this.deps.notifyMain.pathRefresh({ sessionId: ctx.sessionId, dir: this.dirOf(ctx) });
|
||||||
|
} catch (error) {
|
||||||
|
await this.deps.edits.rescueDraft(ctx.name, draft);
|
||||||
|
await Utils.showMessageBox({
|
||||||
|
type: "error",
|
||||||
|
title: BRANDING.appName,
|
||||||
|
message: `Couldn't save "${ctx.name}".`,
|
||||||
|
detail: `${error instanceof Error ? error.message : error}\n\nA local copy was kept and will be offered at the next launch.`,
|
||||||
|
buttons: ["OK"],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dirtyCount(): number {
|
||||||
|
let count = 0;
|
||||||
|
this.windows.forEach((rec) => {
|
||||||
|
if (rec.dirty) count++;
|
||||||
|
});
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
anyFocused(): boolean {
|
||||||
|
return this.windows.anyFocused();
|
||||||
|
}
|
||||||
|
|
||||||
|
focusedKey(): string | null {
|
||||||
|
return this.windows.focusedKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ask a window to run its styled close flow (⌘W routing, main close). */
|
||||||
|
requestClose(key: string): void {
|
||||||
|
this.windows.get(key)?.rpc.send.editorCloseRequest({});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Main window closed: clean editors just close, dirty ones get to ask. */
|
||||||
|
closeAllForAppExit(): void {
|
||||||
|
this.windows.forEach((rec, key) => {
|
||||||
|
if (rec.dirty) this.requestClose(key);
|
||||||
|
else rec.win.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
notifySessionClosed(info: { sessionId: string; reason: string }): void {
|
||||||
|
this.windows.forEach((rec) => {
|
||||||
|
if (rec.ctx.sessionId === info.sessionId) rec.rpc.send.sessionClosed(info);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `exceptKey`: the editor that saved — it already shows the new values. */
|
||||||
|
broadcastSettings(settings: AppSettings, exceptKey?: string): void {
|
||||||
|
this.windows.forEach((rec, key) => {
|
||||||
|
if (key !== exceptKey) rec.rpc.send.settingsChanged({ settings });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private baseTitle(ctx: EditorOpenContext): string {
|
||||||
|
return `${ctx.name} — ${ctx.sessionTitle}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setDirtyTitle(record: EditorRecord): void {
|
||||||
|
record.win.setTitle(`${record.dirty ? "• " : ""}${this.baseTitle(record.ctx)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private dirOf(ctx: EditorOpenContext): string {
|
||||||
|
return this.deps.registry.get(ctx.sessionId).dirname(ctx.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,14 +48,25 @@ export function writeFileText(
|
|||||||
* Launch a file in an external editor.
|
* Launch a file in an external editor.
|
||||||
*
|
*
|
||||||
* PORTABILITY: this is the only platform-specific piece of the edit feature.
|
* PORTABILITY: this is the only platform-specific piece of the edit feature.
|
||||||
* macOS delivers the file to any GUI app uniformly via `open [-a app]`.
|
* macOS delivers the file to any GUI app via `open [-a app]`; Windows via
|
||||||
* A Windows port should use `cmd /c start "" [app] file` and a Linux port
|
* `cmd /c start` (empty "" arg = window title slot); Linux via the app
|
||||||
* `xdg-open file` (or `app file` when an editor is configured) — swap the
|
* directly or `xdg-open`. Everything else (temp download, directory watch,
|
||||||
* spawn below and the .app picker in pickEditorApp; everything else
|
* auto-upload) is platform-neutral. Windows path pending first smoke test.
|
||||||
* (temp download, directory watch, auto-upload) is platform-neutral.
|
|
||||||
*/
|
*/
|
||||||
function launchEditor(filePath: string, editorApp: string): void {
|
function launchEditor(filePath: string, editorApp: string): void {
|
||||||
const args = editorApp ? ["open", "-a", editorApp, filePath] : ["open", filePath];
|
let args: string[];
|
||||||
|
switch (process.platform) {
|
||||||
|
case "darwin":
|
||||||
|
args = editorApp ? ["open", "-a", editorApp, filePath] : ["open", filePath];
|
||||||
|
break;
|
||||||
|
case "win32":
|
||||||
|
args = editorApp
|
||||||
|
? ["cmd", "/c", "start", "", editorApp, filePath]
|
||||||
|
: ["cmd", "/c", "start", "", filePath];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
args = editorApp ? [editorApp, filePath] : ["xdg-open", filePath];
|
||||||
|
}
|
||||||
Bun.spawn(args, { stdout: "ignore", stderr: "ignore" });
|
Bun.spawn(args, { stdout: "ignore", stderr: "ignore" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +145,21 @@ export class EditManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Park unsaveable editor content (window closed, session dead) in the
|
||||||
|
* leftovers dir so the next-boot prompt offers it back to the user.
|
||||||
|
*/
|
||||||
|
async rescueDraft(name: string, content: string): Promise<void> {
|
||||||
|
const dir = path.join(this.editsDir, crypto.randomUUID().slice(0, 8));
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(dir, "meta.json"),
|
||||||
|
JSON.stringify({ name, rescuedAt: new Date().toISOString() }),
|
||||||
|
);
|
||||||
|
await fs.writeFile(path.join(dir, name), content, "utf8");
|
||||||
|
console.log(`[edit] rescued unsaved draft of ${name} -> ${dir}`);
|
||||||
|
}
|
||||||
|
|
||||||
/** Session gone (disconnect, tab close, death): stop its watchers, drop temps. */
|
/** Session gone (disconnect, tab close, death): stop its watchers, drop temps. */
|
||||||
stopForSession(sessionId: string): void {
|
stopForSession(sessionId: string): void {
|
||||||
const [mine, rest] = [
|
const [mine, rest] = [
|
||||||
|
|||||||
@@ -11,6 +11,58 @@ function typeOf(st: Stats): EntryType {
|
|||||||
return st.isDirectory() ? "dir" : "file";
|
return st.isDirectory() ? "dir" : "file";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* uid/gid -> names, cached per process (a listing usually has 1-3 owners).
|
||||||
|
*
|
||||||
|
* PORTABILITY: POSIX-only lookups (`id -nu` works on macOS and Linux; group
|
||||||
|
* names use dscacheutil on macOS, getent elsewhere). On Windows these return
|
||||||
|
* undefined and the Owner/Group columns show "—".
|
||||||
|
*/
|
||||||
|
const idNameCache = new Map<string, string | undefined>();
|
||||||
|
|
||||||
|
function resolveId(kind: "user" | "group", id: number): string | undefined {
|
||||||
|
if (process.platform === "win32") return undefined;
|
||||||
|
const cacheKey = `${kind}:${id}`;
|
||||||
|
if (idNameCache.has(cacheKey)) return idNameCache.get(cacheKey);
|
||||||
|
let name: string | undefined;
|
||||||
|
try {
|
||||||
|
const cmd =
|
||||||
|
kind === "user"
|
||||||
|
? ["id", "-nu", String(id)]
|
||||||
|
: process.platform === "darwin"
|
||||||
|
? ["dscacheutil", "-q", "group", "-a", "gid", String(id)]
|
||||||
|
: ["getent", "group", String(id)];
|
||||||
|
const proc = Bun.spawnSync(cmd, { stdout: "pipe", stderr: "ignore" });
|
||||||
|
if (proc.success) {
|
||||||
|
const out = proc.stdout.toString();
|
||||||
|
if (kind === "user") name = out.trim() || undefined;
|
||||||
|
else if (process.platform === "darwin") name = /^name:\s*(.+)$/m.exec(out)?.[1]?.trim();
|
||||||
|
else name = out.split(":")[0]?.trim() || undefined;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// lookup tool missing — fall through to the numeric id
|
||||||
|
}
|
||||||
|
name ??= String(id);
|
||||||
|
idNameCache.set(cacheKey, name);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive roots for the Windows panel source picker ("C:\", "D:\", …).
|
||||||
|
* PORTABILITY: win32-only concept; empty elsewhere ("/" is always reachable).
|
||||||
|
*/
|
||||||
|
export function listLocalDrives(): string[] {
|
||||||
|
if (process.platform !== "win32") return [];
|
||||||
|
try {
|
||||||
|
const proc = Bun.spawnSync(["fsutil", "fsinfo", "drives"], { stdout: "pipe", stderr: "ignore" });
|
||||||
|
if (!proc.success) return [];
|
||||||
|
// output: "Drives: C:\ D:\ ..."
|
||||||
|
return [...proc.stdout.toString().matchAll(/[A-Za-z]:\\/g)].map((m) => m[0]);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toEntry(name: string, full: string, st: Stats): FileEntry {
|
function toEntry(name: string, full: string, st: Stats): FileEntry {
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
@@ -19,6 +71,8 @@ function toEntry(name: string, full: string, st: Stats): FileEntry {
|
|||||||
size: st.size,
|
size: st.size,
|
||||||
mtime: st.mtimeMs,
|
mtime: st.mtimeMs,
|
||||||
mode: st.mode & 0o7777,
|
mode: st.mode & 0o7777,
|
||||||
|
owner: resolveId("user", st.uid),
|
||||||
|
group: resolveId("group", st.gid),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
@@ -10,6 +11,18 @@ const S_IFMT = 0o170000;
|
|||||||
const S_IFDIR = 0o040000;
|
const S_IFDIR = 0o040000;
|
||||||
const S_IFLNK = 0o120000;
|
const S_IFLNK = 0o120000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owner/group from the ls-style longname openssh sends with each entry
|
||||||
|
* ("-rw-r--r-- 1 root wheel 1024 Jan 1 00:00 name"). Servers aren't
|
||||||
|
* required to use this format — bail to undefined when it doesn't parse.
|
||||||
|
*/
|
||||||
|
function ownerFromLongname(longname: string | undefined): { owner?: string; group?: string } {
|
||||||
|
if (!longname || !/^[-dlbcps]/.test(longname)) return {};
|
||||||
|
const fields = longname.split(/\s+/);
|
||||||
|
if (fields.length < 5) return {};
|
||||||
|
return { owner: fields[2], group: fields[3] };
|
||||||
|
}
|
||||||
|
|
||||||
function typeFromMode(mode: number): EntryType {
|
function typeFromMode(mode: number): EntryType {
|
||||||
const fmt = mode & S_IFMT;
|
const fmt = mode & S_IFMT;
|
||||||
if (fmt === S_IFDIR) return "dir";
|
if (fmt === S_IFDIR) return "dir";
|
||||||
@@ -51,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": {
|
||||||
@@ -61,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;
|
||||||
@@ -132,6 +145,7 @@ export class SftpProvider implements FileSystemProvider {
|
|||||||
size: e.attrs.size,
|
size: e.attrs.size,
|
||||||
mtime: e.attrs.mtime * 1000, // sftp attrs are seconds
|
mtime: e.attrs.mtime * 1000, // sftp attrs are seconds
|
||||||
mode: e.attrs.mode & 0o7777,
|
mode: e.attrs.mode & 0o7777,
|
||||||
|
...ownerFromLongname(e.longname),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,16 +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 { effectiveConcurrency } from "../shared/types";
|
import type { EditorOpenContext } from "../shared/rpc";
|
||||||
import { PanelContextMenu } from "./context-menu";
|
import { effectiveConcurrency, type AppSettings } from "../shared/types";
|
||||||
import { EditManager } from "./edits";
|
import { EditManager } from "./edits";
|
||||||
|
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 { 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, chromeOptions, showWhenReady } from "./windows";
|
||||||
|
|
||||||
const PROD_URL = "views://app/index.html";
|
const PROD_URL = "views://app/index.html";
|
||||||
|
|
||||||
@@ -40,15 +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) => {},
|
||||||
|
settingsChanged: (_settings: AppSettings) => {},
|
||||||
|
openConnectionManager: () => {},
|
||||||
|
openSettings: () => {},
|
||||||
|
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();
|
||||||
|
|
||||||
const isMac = process.platform === "darwin";
|
// 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
|
||||||
|
// fresh via the saveSettings flow below.
|
||||||
|
let currentSettings = await settings.load();
|
||||||
|
setBackendLocale(currentSettings.locale); // native menus/errors follow the UI language
|
||||||
|
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
title: BRANDING.appName,
|
title: BRANDING.appName,
|
||||||
@@ -59,23 +77,110 @@ 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.
|
// lets the UI pick the fused layout + theme before first paint
|
||||||
...(isMac
|
preload: buildPreload("main", currentSettings),
|
||||||
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
|
||||||
: {}),
|
|
||||||
// lets the UI pick the fused layout before first paint
|
|
||||||
preload: `window.__APP_PLATFORM=${JSON.stringify(process.platform)};`,
|
|
||||||
rpc,
|
rpc,
|
||||||
|
// revealed on first dom-ready — avoids the unthemed native flash
|
||||||
|
hidden: true,
|
||||||
|
});
|
||||||
|
showWhenReady(win);
|
||||||
|
|
||||||
|
const editors = new EditorWindows({
|
||||||
|
registry,
|
||||||
|
settings,
|
||||||
|
edits,
|
||||||
|
url,
|
||||||
|
currentSettings: () => currentSettings,
|
||||||
|
mainFrame: () => win.getFrame(),
|
||||||
|
notifyMain: { pathRefresh: (info) => rpc.send.pathRefresh(info) },
|
||||||
|
onSettingsSaved: (s, key) => settingsSaved(s, { editor: key }),
|
||||||
|
});
|
||||||
|
const manager = new ManagerWindow({
|
||||||
|
store,
|
||||||
|
settings,
|
||||||
|
url,
|
||||||
|
currentSettings: () => currentSettings,
|
||||||
|
mainFrame: () => win.getFrame(),
|
||||||
|
notifyMain: {
|
||||||
|
managerState: (info) => rpc.send.managerState(info),
|
||||||
|
connectionsChanged: () => rpc.send.connectionsChanged({}),
|
||||||
|
menuAction: (info) => rpc.send.menuAction(info),
|
||||||
|
},
|
||||||
|
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);
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
if (localeChanged) {
|
||||||
|
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.openSettings = () => settingsWin.open();
|
||||||
|
|
||||||
|
function showAbout(): void {
|
||||||
|
const { build } = BRANDING;
|
||||||
|
void Utils.showMessageBox({
|
||||||
|
type: "info",
|
||||||
|
title: BRANDING.appName,
|
||||||
|
message: BRANDING.appName,
|
||||||
|
detail: `${BRANDING.buildLabel} (${build.commit}) · ${build.date}\n\nElectrobun ${build.electrobun} · Bun ${Bun.version}`,
|
||||||
|
buttons: ["OK"],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
events.showAbout = showAbout;
|
||||||
|
// clean editors close with the main window; dirty ones get to ask first,
|
||||||
|
// and the modal manager has nothing to guard
|
||||||
|
win.on("close", () => {
|
||||||
|
manager.close();
|
||||||
|
settingsWin.close();
|
||||||
|
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;
|
||||||
win.on("focus", () => (windowFocused = true));
|
win.on("focus", () => {
|
||||||
|
windowFocused = true;
|
||||||
|
// modal bounce: the main window can't take focus while the manager is open
|
||||||
|
if (manager.isOpen()) manager.activate();
|
||||||
|
});
|
||||||
win.on("blur", () => (windowFocused = false));
|
win.on("blur", () => (windowFocused = false));
|
||||||
const notifiedStates = new Map<string, string>();
|
const notifiedStates = new Map<string, string>();
|
||||||
|
|
||||||
@@ -85,7 +190,12 @@ engine.onUpdate = (snapshot) => {
|
|||||||
const previous = notifiedStates.get(snapshot.id);
|
const previous = notifiedStates.get(snapshot.id);
|
||||||
if (previous !== snapshot.state) {
|
if (previous !== snapshot.state) {
|
||||||
notifiedStates.set(snapshot.id, snapshot.state);
|
notifiedStates.set(snapshot.id, snapshot.state);
|
||||||
if (!windowFocused && (snapshot.state === "completed" || snapshot.state === "failed")) {
|
if (
|
||||||
|
!windowFocused &&
|
||||||
|
!editors.anyFocused() &&
|
||||||
|
!settingsWin.isFocused() &&
|
||||||
|
(snapshot.state === "completed" || snapshot.state === "failed")
|
||||||
|
) {
|
||||||
Utils.showNotification({
|
Utils.showNotification({
|
||||||
title: snapshot.state === "completed" ? "Transfer complete" : "Transfer failed",
|
title: snapshot.state === "completed" ? "Transfer complete" : "Transfer failed",
|
||||||
body:
|
body:
|
||||||
@@ -102,36 +212,124 @@ engine.onUpdate = (snapshot) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// don't quit out from under running transfers
|
// 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) => {
|
||||||
if (quitConfirmed || !engine.hasActiveJobs()) return;
|
const transfers = engine.activeJobCount();
|
||||||
|
const dirtyEditors = editors.dirtyCount();
|
||||||
|
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 count = engine.activeJobCount();
|
const parts = [
|
||||||
|
transfers > 0 && `${transfers} transfer${transfers === 1 ? " is" : "s are"} still running`,
|
||||||
|
dirtyEditors > 0 &&
|
||||||
|
`${dirtyEditors} file${dirtyEditors === 1 ? " has" : "s have"} unsaved changes`,
|
||||||
|
].filter(Boolean);
|
||||||
const { response } = await Utils.showMessageBox({
|
const { response } = await Utils.showMessageBox({
|
||||||
type: "warning",
|
type: "warning",
|
||||||
title: BRANDING.appName,
|
title: BRANDING.appName,
|
||||||
message: `${count} transfer${count === 1 ? " is" : "s are"} still running.`,
|
message: `${parts.join(" and ")}.`,
|
||||||
detail: "Quitting now will abort them.",
|
detail: "Quitting now will abort transfers and discard unsaved changes.",
|
||||||
buttons: ["Quit Anyway", "Cancel"],
|
buttons: ["Quit Anyway", "Cancel"],
|
||||||
defaultId: 1,
|
defaultId: 1,
|
||||||
cancelId: 1,
|
cancelId: 1,
|
||||||
});
|
});
|
||||||
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();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
events.sessionClosed = (info) => rpc.send.sessionClosed(info);
|
events.sessionClosed = (info) => {
|
||||||
events.connectionsChanged = () => void applyApplicationMenu(store);
|
rpc.send.sessionClosed(info);
|
||||||
void settings.load().then((s) => engine.setConcurrency(effectiveConcurrency(s)));
|
editors.notifySessionClosed(info);
|
||||||
|
};
|
||||||
|
events.connectionsChanged = () => void applyApplicationMenu(store, () => currentSettings.keybindings);
|
||||||
|
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) rpc.send.menuAction({ action });
|
if (!action) return;
|
||||||
|
// Their pages claim close-window (⌘W) themselves — macOS hands key
|
||||||
|
// equivalents to the page first — so a close-tab that still arrives while
|
||||||
|
// 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 (settingsWin.isFocused()) {
|
||||||
|
settingsWin.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const focusedEditor = editors.focusedKey();
|
||||||
|
if (focusedEditor) {
|
||||||
|
editors.requestClose(focusedEditor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (action === "manage-connections") {
|
||||||
|
manager.open();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === "settings") {
|
||||||
|
settingsWin.open();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === "about") {
|
||||||
|
showAbout();
|
||||||
|
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
|
||||||
|
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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- 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})`);
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type { BrowserWindow } from "electrobun/main";
|
||||||
|
import type { AppSettings } from "../shared/types";
|
||||||
|
import { createManagerRPC } from "./rpc-handlers";
|
||||||
|
import type { ConnectionStore } from "./store/connections";
|
||||||
|
import type { SettingsStore } from "./store/settings";
|
||||||
|
import { openSecondaryWindow } from "./windows";
|
||||||
|
|
||||||
|
const MANAGER_WIDTH = 760;
|
||||||
|
const MANAGER_HEIGHT = 520;
|
||||||
|
|
||||||
|
export interface ManagerWindowDeps {
|
||||||
|
store: ConnectionStore;
|
||||||
|
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 };
|
||||||
|
/** Late-bound senders targeting the MAIN window's webview. */
|
||||||
|
notifyMain: {
|
||||||
|
managerState: (info: { open: boolean }) => void;
|
||||||
|
connectionsChanged: () => void;
|
||||||
|
menuAction: (info: { action: string }) => void;
|
||||||
|
};
|
||||||
|
/** Rebuilds the native app menu after store changes. */
|
||||||
|
rebuildMenu: () => void;
|
||||||
|
/** The manager saved settings — rebroadcast them to every other window. */
|
||||||
|
onSettingsSaved: (settings: AppSettings) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single connection-manager window, modal to the main window. electrobun
|
||||||
|
* has no native modality, so it's approximated from two sides: index.ts
|
||||||
|
* bounces the main window's focus back here while open, and the main webview
|
||||||
|
* shows a click-swallowing overlay (driven by the managerState message).
|
||||||
|
* Editor windows are deliberately unaffected.
|
||||||
|
*/
|
||||||
|
export class ManagerWindow {
|
||||||
|
private win: BrowserWindow | null = null;
|
||||||
|
private rpc: ReturnType<typeof createManagerRPC> | null = null;
|
||||||
|
|
||||||
|
constructor(private deps: ManagerWindowDeps) {}
|
||||||
|
|
||||||
|
isOpen(): boolean {
|
||||||
|
return this.win !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
activate(): void {
|
||||||
|
this.win?.activate();
|
||||||
|
}
|
||||||
|
|
||||||
|
open(): void {
|
||||||
|
if (this.win) {
|
||||||
|
this.win.activate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rpc = createManagerRPC(
|
||||||
|
{ store: this.deps.store, settings: this.deps.settings },
|
||||||
|
{
|
||||||
|
onChanged: () => {
|
||||||
|
this.deps.rebuildMenu();
|
||||||
|
this.deps.notifyMain.connectionsChanged();
|
||||||
|
},
|
||||||
|
// connect/open-stack run in the main window; the manager's job is done
|
||||||
|
onConnect: (id) => {
|
||||||
|
this.deps.notifyMain.menuAction({ action: `connect:${id}` });
|
||||||
|
this.close();
|
||||||
|
},
|
||||||
|
onOpenStack: (id) => {
|
||||||
|
this.deps.notifyMain.menuAction({ action: `open-stack:${id}` });
|
||||||
|
this.close();
|
||||||
|
},
|
||||||
|
onClose: () => this.close(),
|
||||||
|
onSettingsSaved: (next) => this.deps.onSettingsSaved(next),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const main = this.deps.mainFrame();
|
||||||
|
this.win = openSecondaryWindow({
|
||||||
|
title: "Connections",
|
||||||
|
url: this.deps.url,
|
||||||
|
mode: "manager",
|
||||||
|
settings: this.deps.currentSettings(),
|
||||||
|
frame: {
|
||||||
|
x: Math.max(0, main.x + (main.width - MANAGER_WIDTH) / 2),
|
||||||
|
y: Math.max(0, main.y + (main.height - MANAGER_HEIGHT) / 2),
|
||||||
|
width: MANAGER_WIDTH,
|
||||||
|
height: MANAGER_HEIGHT,
|
||||||
|
},
|
||||||
|
rpc: this.rpc,
|
||||||
|
});
|
||||||
|
this.win.on("close", () => {
|
||||||
|
this.win = null;
|
||||||
|
this.rpc = null;
|
||||||
|
this.deps.notifyMain.managerState({ open: false });
|
||||||
|
});
|
||||||
|
this.deps.notifyMain.managerState({ open: true });
|
||||||
|
console.log("[manager] window opened");
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.win?.close(); // close event does the cleanup + notification
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastSettings(settings: AppSettings): void {
|
||||||
|
this.rpc?.send.settingsChanged({ settings });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,37 @@
|
|||||||
import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/bun";
|
import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/main";
|
||||||
|
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}`,
|
||||||
@@ -26,35 +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: 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" },
|
||||||
{ 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" },
|
||||||
@@ -68,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,9 +1,10 @@
|
|||||||
import { BrowserView, Utils } from "electrobun/bun";
|
import { BrowserView, Updater, Utils } from "electrobun/main";
|
||||||
import type { AppRPC } from "../shared/rpc";
|
import type { AppRPC, EditorOpenContext } from "../shared/rpc";
|
||||||
import { effectiveConcurrency, 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 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";
|
||||||
@@ -22,11 +23,18 @@ 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;
|
||||||
connectionsChanged: () => void;
|
connectionsChanged: () => void;
|
||||||
|
openEditor: (ctx: EditorOpenContext) => void;
|
||||||
|
settingsChanged: (settings: AppSettings) => void;
|
||||||
|
openConnectionManager: () => void;
|
||||||
|
openSettings: () => void;
|
||||||
|
showAbout: () => void;
|
||||||
|
/** "Restart to apply" — lives next to the before-quit guard it must get past. */
|
||||||
|
applyUpdate: () => Promise<void>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,13 +46,122 @@ async function wrap<T extends object>(fn: () => Promise<T> | T): Promise<Result<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Connection-store requests, shared by the main and manager window rpcs. */
|
||||||
|
function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void) {
|
||||||
|
return {
|
||||||
|
getConnections: () => wrap(async () => ({ store: await store.load() })),
|
||||||
|
|
||||||
|
upsertConnection: ({ connection }: { connection: Parameters<ConnectionStore["upsertConnection"]>[0] }) =>
|
||||||
|
wrap(async () => {
|
||||||
|
const id = await store.upsertConnection(connection);
|
||||||
|
onChanged();
|
||||||
|
return { id };
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteConnection: ({ id }: { id: string }) =>
|
||||||
|
wrap(async () => {
|
||||||
|
await store.deleteConnection(id);
|
||||||
|
onChanged();
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
|
||||||
|
upsertStack: ({ stack }: { stack: Parameters<ConnectionStore["upsertStack"]>[0] }) =>
|
||||||
|
wrap(async () => {
|
||||||
|
const id = await store.upsertStack(stack);
|
||||||
|
onChanged();
|
||||||
|
return { id };
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteStack: ({ id }: { id: string }) =>
|
||||||
|
wrap(async () => {
|
||||||
|
await store.deleteStack(id);
|
||||||
|
onChanged();
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
|
||||||
|
importWinScp: () =>
|
||||||
|
wrap(async () => {
|
||||||
|
const { homedir } = await import("node:os");
|
||||||
|
const picked = await Utils.openFileDialog({
|
||||||
|
startingFolder: homedir(),
|
||||||
|
allowedFileTypes: "*",
|
||||||
|
canChooseFiles: true,
|
||||||
|
canChooseDirectory: false,
|
||||||
|
allowsMultipleSelection: false,
|
||||||
|
});
|
||||||
|
const file = picked.find((p) => p.trim().length > 0);
|
||||||
|
// dialog cancelled — all-zero result, UI shows nothing
|
||||||
|
if (!file) return { connections: 0, stacks: 0, skipped: 0 };
|
||||||
|
const text = await (await import("node:fs/promises")).readFile(file, "utf8");
|
||||||
|
const result = await importWinScpIni(text, store);
|
||||||
|
onChanged();
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
|
||||||
|
getSshConfigHosts: () => wrap(async () => ({ hosts: loadSshConfigHosts() })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = () =>
|
||||||
|
wrap(async () => {
|
||||||
|
const { homedir } = await import("node:os");
|
||||||
|
const { join } = await import("node:path");
|
||||||
|
const picked = await Utils.openFileDialog({
|
||||||
|
startingFolder: join(homedir(), ".ssh"),
|
||||||
|
allowedFileTypes: "*",
|
||||||
|
canChooseFiles: true,
|
||||||
|
canChooseDirectory: false,
|
||||||
|
allowsMultipleSelection: false,
|
||||||
|
});
|
||||||
|
const path = picked.find((p) => p.trim().length > 0) ?? null;
|
||||||
|
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>({
|
||||||
@@ -84,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);
|
||||||
@@ -137,66 +260,12 @@ export function createAppRPC({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
// ---- connection store ----
|
// ---- connection store ----
|
||||||
getConnections: () => wrap(async () => ({ store: await store.load() })),
|
...connectionStoreHandlers(store, () => events.connectionsChanged()),
|
||||||
|
|
||||||
upsertConnection: ({ connection }) =>
|
|
||||||
wrap(async () => {
|
|
||||||
const id = await store.upsertConnection(connection);
|
|
||||||
events.connectionsChanged();
|
|
||||||
return { id };
|
|
||||||
}),
|
|
||||||
|
|
||||||
deleteConnection: ({ id }) =>
|
|
||||||
wrap(async () => {
|
|
||||||
await store.deleteConnection(id);
|
|
||||||
events.connectionsChanged();
|
|
||||||
return {};
|
|
||||||
}),
|
|
||||||
|
|
||||||
upsertStack: ({ stack }) =>
|
|
||||||
wrap(async () => {
|
|
||||||
const id = await store.upsertStack(stack);
|
|
||||||
events.connectionsChanged();
|
|
||||||
return { id };
|
|
||||||
}),
|
|
||||||
|
|
||||||
deleteStack: ({ id }) =>
|
|
||||||
wrap(async () => {
|
|
||||||
await store.deleteStack(id);
|
|
||||||
events.connectionsChanged();
|
|
||||||
return {};
|
|
||||||
}),
|
|
||||||
|
|
||||||
importWinScp: () =>
|
|
||||||
wrap(async () => {
|
|
||||||
const { homedir } = await import("node:os");
|
|
||||||
const picked = await Utils.openFileDialog({
|
|
||||||
startingFolder: homedir(),
|
|
||||||
allowedFileTypes: "*",
|
|
||||||
canChooseFiles: true,
|
|
||||||
canChooseDirectory: false,
|
|
||||||
allowsMultipleSelection: false,
|
|
||||||
});
|
|
||||||
const file = picked.find((p) => p.trim().length > 0);
|
|
||||||
// dialog cancelled — all-zero result, UI shows nothing
|
|
||||||
if (!file) return { connections: 0, stacks: 0, skipped: 0 };
|
|
||||||
const text = await (await import("node:fs/promises")).readFile(file, "utf8");
|
|
||||||
const result = await importWinScpIni(text, store);
|
|
||||||
events.connectionsChanged();
|
|
||||||
return result;
|
|
||||||
}),
|
|
||||||
|
|
||||||
getSshConfigHosts: () => wrap(async () => ({ hosts: loadSshConfigHosts() })),
|
|
||||||
|
|
||||||
// ---- 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));
|
|
||||||
return {};
|
|
||||||
}),
|
|
||||||
|
|
||||||
// ---- file editing ----
|
// ---- file editing ----
|
||||||
readFile: ({ sessionId, path }) =>
|
readFile: ({ sessionId, path }) =>
|
||||||
@@ -213,32 +282,9 @@ export function createAppRPC({
|
|||||||
editExternal: ({ sessionId, path }) =>
|
editExternal: ({ sessionId, path }) =>
|
||||||
wrap(() => edits.editExternal(sessionId, path)),
|
wrap(() => edits.editExternal(sessionId, path)),
|
||||||
|
|
||||||
pickEditorApp: () =>
|
pickEditorApp: pickEditorAppHandler,
|
||||||
wrap(async () => {
|
|
||||||
const picked = await Utils.openFileDialog({
|
|
||||||
startingFolder: "/Applications",
|
|
||||||
allowedFileTypes: "app",
|
|
||||||
canChooseFiles: true,
|
|
||||||
canChooseDirectory: false,
|
|
||||||
allowsMultipleSelection: false,
|
|
||||||
});
|
|
||||||
const path = picked.find((p) => p.trim().length > 0) ?? null;
|
|
||||||
return { path };
|
|
||||||
}),
|
|
||||||
|
|
||||||
pickKeyFile: () =>
|
pickKeyFile: pickKeyFileHandler,
|
||||||
wrap(async () => {
|
|
||||||
const { homedir } = await import("node:os");
|
|
||||||
const picked = await Utils.openFileDialog({
|
|
||||||
startingFolder: `${homedir()}/.ssh`,
|
|
||||||
allowedFileTypes: "*",
|
|
||||||
canChooseFiles: true,
|
|
||||||
canChooseDirectory: false,
|
|
||||||
allowsMultipleSelection: false,
|
|
||||||
});
|
|
||||||
const path = picked.find((p) => p.trim().length > 0) ?? null;
|
|
||||||
return { path };
|
|
||||||
}),
|
|
||||||
|
|
||||||
getEditLeftovers: () =>
|
getEditLeftovers: () =>
|
||||||
wrap(async () => ({ names: await edits.listLeftovers() })),
|
wrap(async () => ({ names: await edits.listLeftovers() })),
|
||||||
@@ -249,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 {};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -261,6 +309,187 @@ export function createAppRPC({
|
|||||||
Utils.moveToTrash(path);
|
Utils.moveToTrash(path);
|
||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ---- editor windows ----
|
||||||
|
openEditor: (ctx) =>
|
||||||
|
wrap(async () => {
|
||||||
|
events.openEditor(ctx);
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ---- connection-manager window ----
|
||||||
|
openConnectionManager: () =>
|
||||||
|
wrap(async () => {
|
||||||
|
events.openConnectionManager();
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ---- settings window ----
|
||||||
|
openSettings: () =>
|
||||||
|
wrap(async () => {
|
||||||
|
events.openSettings();
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
|
||||||
|
showAbout: () =>
|
||||||
|
wrap(async () => {
|
||||||
|
events.showAbout();
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
|
||||||
|
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 {};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditorRpcHooks {
|
||||||
|
ctx: EditorOpenContext;
|
||||||
|
/** Draft mirror tick: store dirty flag + content on the window record. */
|
||||||
|
onDraft: (state: { dirty: boolean; content: string }) => void;
|
||||||
|
/** editorSave succeeded: clean the record, retitle, refresh main panels. */
|
||||||
|
onSaved: () => void;
|
||||||
|
/** Webview resolved its dirty prompt: close without the native fallback. */
|
||||||
|
onCloseConfirmed: () => void;
|
||||||
|
/** This window saved settings (e.g. the scale shortcuts) — rebroadcast them. */
|
||||||
|
onSettingsSaved: (next: AppSettings) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC for one editor window. Each window MUST get its own instance (binding a
|
||||||
|
* BrowserView steals the rpc's transport), and the hooks close over that
|
||||||
|
* window's record — no ids on the wire.
|
||||||
|
*/
|
||||||
|
export function createEditorRPC(
|
||||||
|
deps: { registry: SessionRegistry; settings: SettingsStore },
|
||||||
|
hooks: EditorRpcHooks,
|
||||||
|
) {
|
||||||
|
return BrowserView.defineRPC<AppRPC>({
|
||||||
|
maxRequestTime: MAX_REQUEST_TIME_MS,
|
||||||
|
handlers: {
|
||||||
|
requests: {
|
||||||
|
readFile: ({ sessionId, path }) =>
|
||||||
|
wrap(async () => ({
|
||||||
|
content: await readFileText(deps.registry.get(sessionId), path),
|
||||||
|
})),
|
||||||
|
|
||||||
|
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
|
||||||
|
saveSettings: saveSettingsHandler(deps.settings, hooks.onSettingsSaved),
|
||||||
|
|
||||||
|
editorSave: ({ content }) =>
|
||||||
|
wrap(async () => {
|
||||||
|
const { sessionId, path } = hooks.ctx;
|
||||||
|
await writeFileText(deps.registry.get(sessionId), path, content);
|
||||||
|
hooks.onSaved();
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
|
||||||
|
editorClose: () =>
|
||||||
|
wrap(async () => {
|
||||||
|
hooks.onCloseConfirmed();
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
editorState: (state) => hooks.onDraft(state),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManagerRpcHooks {
|
||||||
|
/** Store changed: rebuild the app menu + tell the main window to reload. */
|
||||||
|
onChanged: () => void;
|
||||||
|
/** Connect / open-stack execute in the MAIN window's workspace. */
|
||||||
|
onConnect: (id: string) => void;
|
||||||
|
onOpenStack: (id: string) => 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). */
|
||||||
|
export function createManagerRPC(
|
||||||
|
deps: { store: ConnectionStore; settings: SettingsStore },
|
||||||
|
hooks: ManagerRpcHooks,
|
||||||
|
) {
|
||||||
|
return BrowserView.defineRPC<AppRPC>({
|
||||||
|
maxRequestTime: MAX_REQUEST_TIME_MS,
|
||||||
|
handlers: {
|
||||||
|
requests: {
|
||||||
|
...connectionStoreHandlers(deps.store, hooks.onChanged),
|
||||||
|
pickKeyFile: pickKeyFileHandler,
|
||||||
|
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
|
||||||
|
saveSettings: saveSettingsHandler(deps.settings, hooks.onSettingsSaved),
|
||||||
|
|
||||||
|
managerConnect: ({ id }) =>
|
||||||
|
wrap(async () => {
|
||||||
|
hooks.onConnect(id);
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
|
||||||
|
managerOpenStack: ({ id }) =>
|
||||||
|
wrap(async () => {
|
||||||
|
hooks.onOpenStack(id);
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
|
||||||
|
managerClose: () =>
|
||||||
|
wrap(async () => {
|
||||||
|
hooks.onClose();
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,4 +1,5 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { ConnectionsFile, SavedConnection, Stack } from "../../shared/types";
|
import type { ConnectionsFile, SavedConnection, Stack } from "../../shared/types";
|
||||||
import { appDataDir } from "./paths";
|
import { appDataDir } from "./paths";
|
||||||
@@ -68,5 +69,23 @@ export class ConnectionStore {
|
|||||||
await fs.writeFile(tmp, JSON.stringify(this.data, null, "\t"), { mode: 0o600 });
|
await fs.writeFile(tmp, JSON.stringify(this.data, null, "\t"), { mode: 0o600 });
|
||||||
await fs.rename(tmp, this.file);
|
await fs.rename(tmp, this.file);
|
||||||
await fs.chmod(this.file, 0o600);
|
await fs.chmod(this.file, 0o600);
|
||||||
|
this.restrictOnWindows();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PORTABILITY: 0o600 only toggles the read-only bit on Windows, so the
|
||||||
|
* plaintext passwords in this file would be world-readable there. Replace
|
||||||
|
* inheritance with a current-user-only ACL, best effort.
|
||||||
|
*/
|
||||||
|
private restrictOnWindows(): void {
|
||||||
|
if (process.platform !== "win32") return;
|
||||||
|
try {
|
||||||
|
Bun.spawn(
|
||||||
|
["icacls", this.file, "/inheritance:r", "/grant:r", `${os.userInfo().username}:F`],
|
||||||
|
{ stdout: "ignore", stderr: "ignore" },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// ACL tooling unavailable — file still works, just unprotected
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,21 +1,26 @@
|
|||||||
import { Tray, type BrowserWindow } from "electrobun/bun";
|
import { Tray, type BrowserWindow } from "electrobun/main";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Menu-bar icon: the app glyph as a macOS template image (adapts to
|
* Menu-bar / notification-area icon. Clicking it brings the app window to the
|
||||||
* light/dark). Clicking it brings the app window to the front. No menu is ever
|
* front. No menu is ever set, so clicks arrive directly; no setImage swaps, so
|
||||||
* set, so clicks arrive directly; no setImage swaps, so the template
|
* the image treatment from construction always applies.
|
||||||
* treatment from construction always applies.
|
*
|
||||||
|
* PORTABILITY: macOS uses a monochrome template image (adapts to light/dark
|
||||||
|
* menu bars); Windows ignores `template` and wants a small colored icon.
|
||||||
*/
|
*/
|
||||||
export class AppTray {
|
export class AppTray {
|
||||||
private tray: Tray;
|
private tray: Tray;
|
||||||
|
|
||||||
constructor(win: BrowserWindow) {
|
constructor(win: BrowserWindow) {
|
||||||
|
const isMac = process.platform === "darwin";
|
||||||
this.tray = new Tray({
|
this.tray = new Tray({
|
||||||
title: "",
|
title: "",
|
||||||
image: "views://assets/tray/tray-44-template.png",
|
image: isMac
|
||||||
template: true,
|
? "views://assets/tray/tray-44-template.png"
|
||||||
width: 22,
|
: "views://assets/tray/tray-32-color.png",
|
||||||
height: 22,
|
template: isMac,
|
||||||
|
width: isMac ? 22 : 16,
|
||||||
|
height: isMac ? 22 : 16,
|
||||||
});
|
});
|
||||||
this.tray.on("tray-clicked", () => win.activate());
|
this.tray.on("tray-clicked", () => win.activate());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { BrowserWindow } from "electrobun/main";
|
||||||
|
import type { AppSettings } from "../shared/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The preload globals every window gets. Single composition point: the UI
|
||||||
|
* reads them in ui/src/app/core/platform.ts before first paint (window chrome and
|
||||||
|
* root-component selection both depend on them).
|
||||||
|
*
|
||||||
|
* The persisted theme attributes are baked in here — mirroring the
|
||||||
|
* delete/set logic of SettingsService.applyX — so the FIRST paint is already
|
||||||
|
* themed (the inline <style> in ui/src/index.html consumes them). Without
|
||||||
|
* this, forced themes only apply after the async getSettings RPC and new
|
||||||
|
* windows flash the wrong background.
|
||||||
|
*/
|
||||||
|
export function buildPreload(
|
||||||
|
mode: "main" | "editor" | "manager" | "settings",
|
||||||
|
settings: AppSettings,
|
||||||
|
ctx?: unknown,
|
||||||
|
): string {
|
||||||
|
let script =
|
||||||
|
`window.__APP_PLATFORM=${JSON.stringify(process.platform)};` +
|
||||||
|
`window.__APP_MODE=${JSON.stringify(mode)};`;
|
||||||
|
if (ctx !== undefined) script += `window.__APP_CTX=${JSON.stringify(ctx)};`;
|
||||||
|
|
||||||
|
const dataset: Record<string, string> = {};
|
||||||
|
if (settings.theme !== "system") dataset["theme"] = settings.theme;
|
||||||
|
if (settings.darkTone !== "dark") dataset["darkTone"] = settings.darkTone;
|
||||||
|
// interface scale: mirrors SettingsService.applyUiScale so the FIRST paint
|
||||||
|
// 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)) {
|
||||||
|
script += `document.documentElement.dataset[${JSON.stringify(key)}]=${JSON.stringify(value)};`;
|
||||||
|
}
|
||||||
|
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 —
|
||||||
|
* showing them earlier paints the native (white) surface before the themed
|
||||||
|
* page exists. The fallback timer guarantees a missed event can never leave
|
||||||
|
* a window invisible.
|
||||||
|
*/
|
||||||
|
export function showWhenReady(win: BrowserWindow): void {
|
||||||
|
let shown = false;
|
||||||
|
const show = () => {
|
||||||
|
if (shown) return;
|
||||||
|
shown = true;
|
||||||
|
win.show();
|
||||||
|
};
|
||||||
|
const fallback = setTimeout(show, 800);
|
||||||
|
win.webview.on("dom-ready", () => {
|
||||||
|
clearTimeout(fallback);
|
||||||
|
show();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecondaryWindowOptions {
|
||||||
|
title: string;
|
||||||
|
/** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */
|
||||||
|
url: string;
|
||||||
|
mode: "editor" | "manager" | "settings";
|
||||||
|
/** Current persisted settings — baked into the preload for flash-free paint. */
|
||||||
|
settings: AppSettings;
|
||||||
|
ctx?: unknown;
|
||||||
|
frame: { x: number; y: number; width: number; height: number };
|
||||||
|
/** Per-window rpc instance. NEVER share one across windows — each
|
||||||
|
* BrowserView steals the rpc's transport when it binds. */
|
||||||
|
rpc: NonNullable<ConstructorParameters<typeof BrowserWindow>[0]>["rpc"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** App-chromed secondary window; its frame comes from chromeOptions. */
|
||||||
|
export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
title: opts.title,
|
||||||
|
url: opts.url,
|
||||||
|
frame: opts.frame,
|
||||||
|
...chromeOptions(),
|
||||||
|
preload: buildPreload(opts.mode, opts.settings, opts.ctx),
|
||||||
|
rpc: opts.rpc,
|
||||||
|
hidden: true,
|
||||||
|
});
|
||||||
|
showWhenReady(win);
|
||||||
|
return win;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyed registry of secondary windows: dedupe/activate, cascade placement off
|
||||||
|
* an anchor window, focus tracking, close cleanup. A future "window follows
|
||||||
|
* window" grouping step slots in here — the registry already owns the window
|
||||||
|
* handles and the anchor it would need (anchor.on('move') + setPosition).
|
||||||
|
*/
|
||||||
|
export class WindowRegistry<Rec extends { win: BrowserWindow }> {
|
||||||
|
private records = new Map<string, Rec>();
|
||||||
|
private focused = new Set<string>();
|
||||||
|
private opened = 0;
|
||||||
|
|
||||||
|
constructor(private anchorFrame: () => { x: number; y: number }) {}
|
||||||
|
|
||||||
|
get(key: string): Rec | undefined {
|
||||||
|
return this.records.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True (and focuses the window) when the key already has a live window. */
|
||||||
|
activateExisting(key: string): boolean {
|
||||||
|
const rec = this.records.get(key);
|
||||||
|
if (!rec) return false;
|
||||||
|
rec.win.activate();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: string, rec: Rec): void {
|
||||||
|
this.records.set(key, rec);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cascade off the anchor so stacked windows never hide each other fully. */
|
||||||
|
nextFrame(width: number, height: number): { x: number; y: number; width: number; height: number } {
|
||||||
|
const anchor = this.anchorFrame();
|
||||||
|
const step = (this.opened++ % 8) * 28;
|
||||||
|
return { x: anchor.x + 90 + step, y: anchor.y + 70 + step, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire close/focus/blur for a window. electrobun's emitter has no off(),
|
||||||
|
* so these closures capture only (this, key) — never the record — letting
|
||||||
|
* closed-window content be garbage collected after delete().
|
||||||
|
*/
|
||||||
|
track(key: string, win: BrowserWindow, onClose: (key: string) => void): void {
|
||||||
|
win.on("focus", () => this.focused.add(key));
|
||||||
|
win.on("blur", () => this.focused.delete(key));
|
||||||
|
win.on("close", () => {
|
||||||
|
this.focused.delete(key);
|
||||||
|
onClose(key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(key: string): void {
|
||||||
|
this.records.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
anyFocused(): boolean {
|
||||||
|
return this.focused.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
focusedKey(): string | null {
|
||||||
|
for (const key of this.focused) return key;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
forEach(fn: (rec: Rec, key: string) => void): void {
|
||||||
|
for (const [key, rec] of this.records) fn(rec, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
size(): number {
|
||||||
|
return this.records.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
// The single place to rebrand the app. Everything (window title, bundle name,
|
// The single place to rebrand the app. Everything (window title, bundle name,
|
||||||
// app-data directory, UI header, icon) reads from here.
|
// app-data directory, UI header, icon) reads from here.
|
||||||
|
import { BUILD } from "./version.gen";
|
||||||
|
|
||||||
export const BRANDING = {
|
export const BRANDING = {
|
||||||
appName: "miniSCP",
|
appName: "miniSCP",
|
||||||
identifier: "es.teknes.miniscp",
|
identifier: "es.teknes.miniscp",
|
||||||
// Previous app names: their app-data folders are migrated on boot
|
// Previous app names whose app-data folders migrate on boot after a rename
|
||||||
// (see migrateLegacyDataDir in src/bun/store/paths.ts).
|
// (see migrateLegacyDataDir in src/bun/store/paths.ts).
|
||||||
legacyNames: ["BunSCP"],
|
legacyNames: [] as string[],
|
||||||
version: "0.1.0",
|
// Sublime-style versioning: n = commit count (scripts/gen-version.ts).
|
||||||
|
// `version` stays semver-shaped for electrobun's config/updater.
|
||||||
|
version: `0.1.${BUILD.number}`,
|
||||||
|
buildLabel: `Build ${BUILD.number}`,
|
||||||
|
build: BUILD,
|
||||||
// macOS build converts this .iconset folder into AppIcon.icns.
|
// macOS build converts this .iconset folder into AppIcon.icns.
|
||||||
// Regenerate from assets/icon.png with scripts/gen-iconset.sh.
|
// Regenerate from assets/icon.png with scripts/gen-iconset.sh.
|
||||||
icon: "assets/icon.iconset",
|
icon: "assets/icon.iconset",
|
||||||
|
|||||||
@@ -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("+");
|
||||||
|
}
|
||||||
@@ -11,12 +11,24 @@ import type {
|
|||||||
TransferSnapshot,
|
TransferSnapshot,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
|
/** Everything an editor window needs to know about the file it hosts. */
|
||||||
|
export interface EditorOpenContext {
|
||||||
|
sessionId: string;
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
sessionTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Structurally equivalent to electrobun's ElectrobunRPCSchema, written in
|
// Structurally equivalent to electrobun's ElectrobunRPCSchema, written in
|
||||||
// resolved form so this file stays importable from the Angular bundle without
|
// resolved form so this file stays importable from the Angular bundle without
|
||||||
// pulling in bun-only type modules.
|
// pulling in bun-only type modules.
|
||||||
//
|
//
|
||||||
// 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 every window kind (main, editor, manager, settings):
|
||||||
|
// electrobun allows partial handler sets, and messages nobody listens to are
|
||||||
|
// silently dropped, so each side only implements/hears what applies to it.
|
||||||
export type AppRPC = {
|
export type AppRPC = {
|
||||||
bun: {
|
bun: {
|
||||||
requests: {
|
requests: {
|
||||||
@@ -31,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: {
|
||||||
@@ -110,20 +125,55 @@ 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 About box (name, Build n, commit, engine versions). */
|
||||||
|
showAbout: { params: Record<string, never>; response: Result };
|
||||||
|
/** Windows drive roots for the local panel ("C:\", …); empty elsewhere. */
|
||||||
|
listDrives: { params: Record<string, never>; response: Result<{ drives: string[] }> };
|
||||||
|
// editor windows
|
||||||
|
/** Main window only: open (or focus) a native editor window. */
|
||||||
|
openEditor: { params: EditorOpenContext; response: Result };
|
||||||
|
/** Editor windows only — handled by that window's own rpc closure. */
|
||||||
|
editorSave: { params: { content: string }; response: Result };
|
||||||
|
editorClose: { params: Record<string, never>; response: Result };
|
||||||
|
// connection-manager window
|
||||||
|
/** Main window: open (or focus) the single connection-manager window. */
|
||||||
|
openConnectionManager: { params: Record<string, never>; response: Result };
|
||||||
|
/** Manager window only: connect/open-stack run in the MAIN window. */
|
||||||
|
managerConnect: { params: { id: string }; response: Result };
|
||||||
|
managerOpenStack: { params: { id: string }; 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: {
|
||||||
|
/** Editor → bun draft mirror; safety net for the non-cancellable native close. */
|
||||||
|
editorState: { dirty: boolean; content: string };
|
||||||
};
|
};
|
||||||
messages: Record<never, never>;
|
|
||||||
};
|
};
|
||||||
webview: {
|
webview: {
|
||||||
requests: Record<never, never>;
|
requests: Record<never, never>;
|
||||||
@@ -131,6 +181,26 @@ export type AppRPC = {
|
|||||||
transferUpdate: TransferSnapshot;
|
transferUpdate: TransferSnapshot;
|
||||||
sessionClosed: { sessionId: string; reason: string };
|
sessionClosed: { sessionId: string; reason: string };
|
||||||
menuAction: { action: string };
|
menuAction: { action: string };
|
||||||
|
/** bun → editor windows when the main window saves settings. */
|
||||||
|
settingsChanged: { settings: AppSettings };
|
||||||
|
/** bun → main after an editor save, so matching panels refresh. */
|
||||||
|
pathRefresh: { sessionId: string; dir: string };
|
||||||
|
/** bun → one editor: run the in-window close flow (dirty prompt). */
|
||||||
|
editorCloseRequest: Record<string, never>;
|
||||||
|
/** bun → main: the modal connection manager opened/closed. */
|
||||||
|
managerState: { open: boolean };
|
||||||
|
/** bun → main: the manager window changed the connection store. */
|
||||||
|
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;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,8 +7,13 @@ export interface FileEntry {
|
|||||||
size: number; // bytes
|
size: number; // bytes
|
||||||
mtime: number; // epoch milliseconds
|
mtime: number; // epoch milliseconds
|
||||||
mode: number; // permission bits only (masked & 0o7777)
|
mode: number; // permission bits only (masked & 0o7777)
|
||||||
|
owner?: string; // resolved name when the provider knows it
|
||||||
|
group?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Optional file-list columns (Name is always shown). */
|
||||||
|
export type ColumnKey = "size" | "mtime" | "kind" | "rights" | "owner" | "group";
|
||||||
|
|
||||||
/** Every RPC response uses this envelope; errors never cross the bridge as exceptions. */
|
/** Every RPC response uses this envelope; errors never cross the bridge as exceptions. */
|
||||||
export type Result<T = object> = ({ ok: true } & T) | { ok: false; error: string };
|
export type Result<T = object> = ({ ok: true } & T) | { ok: false; error: string };
|
||||||
|
|
||||||
@@ -57,27 +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. */
|
/** Visible optional file-list columns (both panels; Name always shows). */
|
||||||
accent: AccentTone;
|
columns: ColumnKey[];
|
||||||
/** File-list icons: plain neutral glyphs, or per-category glyphs and colors. */
|
|
||||||
iconColors: "plain" | "multi";
|
|
||||||
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". */
|
||||||
@@ -88,22 +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",
|
columns: ["size", "mtime"],
|
||||||
iconColors: "multi",
|
|
||||||
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": {
|
||||||
@@ -66,4 +69,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,75 +0,0 @@
|
|||||||
<div class="flex h-screen flex-col bg-background text-foreground">
|
|
||||||
@if (!rpc.available) {
|
|
||||||
<div class="flex flex-1 items-center justify-center">
|
|
||||||
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm">
|
|
||||||
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
|
|
||||||
<p class="mt-2 text-sm font-medium">Not connected to the app</p>
|
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
|
||||||
This page is running in a plain browser. Launch the desktop app to use it.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<app-tab-bar />
|
|
||||||
|
|
||||||
<main class="flex min-h-0 flex-1 divide-x divide-border">
|
|
||||||
<app-file-panel side="left" />
|
|
||||||
<app-file-panel side="right" />
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<app-transfer-bar />
|
|
||||||
<app-status-bar />
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- dialogs -->
|
|
||||||
@if (dialogs.manager()) {
|
|
||||||
<app-connection-manager />
|
|
||||||
}
|
|
||||||
@if (dialogs.prompt(); as req) {
|
|
||||||
<app-prompt-dialog [request]="req" />
|
|
||||||
}
|
|
||||||
@if (dialogs.confirm(); as req) {
|
|
||||||
<app-confirm-dialog [request]="req" />
|
|
||||||
}
|
|
||||||
@if (dialogs.chmod(); as req) {
|
|
||||||
<app-chmod-dialog [request]="req" />
|
|
||||||
}
|
|
||||||
@if (dialogs.overwrite(); as req) {
|
|
||||||
<app-overwrite-dialog [request]="req" />
|
|
||||||
}
|
|
||||||
@if (dialogs.editor(); as req) {
|
|
||||||
<app-editor-dialog [request]="req" />
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- toasts -->
|
|
||||||
<div class="pointer-events-none fixed right-3 bottom-8 z-50 flex w-80 flex-col gap-2">
|
|
||||||
@for (t of toast.toasts(); track t.id) {
|
|
||||||
<div
|
|
||||||
(mouseenter)="toast.pause(t.id)"
|
|
||||||
(mouseleave)="toast.resume(t.id)"
|
|
||||||
class="group pointer-events-auto relative flex items-start gap-2 overflow-hidden rounded-lg border border-border bg-card p-3 text-xs text-card-foreground shadow-lg"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
[name]="toastIcon(t.kind)"
|
|
||||||
size="1rem"
|
|
||||||
[class]="t.kind === 'error' ? 'shrink-0 text-destructive' : 'shrink-0 text-success'"
|
|
||||||
/>
|
|
||||||
<span class="min-w-0 flex-1 wrap-break-word">{{ t.text }}</span>
|
|
||||||
<button
|
|
||||||
(click)="toast.dismiss(t.id)"
|
|
||||||
class="shrink-0 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaX" size="0.9rem" />
|
|
||||||
</button>
|
|
||||||
<!-- auto-dismiss countdown; freezes with the timer while hovered -->
|
|
||||||
<div
|
|
||||||
class="toast-countdown absolute inset-x-0 bottom-0 h-0.5 origin-left"
|
|
||||||
[class.bg-destructive]="t.kind === 'error'"
|
|
||||||
[class.bg-success]="t.kind === 'success'"
|
|
||||||
[class.bg-primary]="t.kind === 'info'"
|
|
||||||
[style.animation-duration.ms]="t.ttlMs"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
|
||||||
import { NgIcon } from '@ng-icons/core';
|
|
||||||
import { BRANDING } from '@shared/branding';
|
|
||||||
import { ConnectionManager } from './connection-manager/connection-manager';
|
|
||||||
import { ConnectionsService } from './connections.service';
|
|
||||||
import { ChmodDialog } from './dialogs/chmod-dialog';
|
|
||||||
import { ConfirmDialog } from './dialogs/confirm-dialog';
|
|
||||||
import { EditorDialog } from './dialogs/editor-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 { RpcService } from './rpc.service';
|
|
||||||
import { StatusBar } from './status-bar/status-bar';
|
|
||||||
import { TabBar } from './tab-bar/tab-bar';
|
|
||||||
import { ToastService } from './toast.service';
|
|
||||||
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,
|
|
||||||
EditorDialog,
|
|
||||||
TransferBar,
|
|
||||||
ConnectionManager,
|
|
||||||
],
|
|
||||||
templateUrl: './app.html',
|
|
||||||
host: { '(window:keydown)': 'onWindowKeydown($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);
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
document.title = BRANDING.appName;
|
|
||||||
if (this.rpc.available) {
|
|
||||||
this.rpc.onMenuAction((action) => this.handleMenuAction(action));
|
|
||||||
void this.promptForEditLeftovers();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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({}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Native application-menu actions forwarded from the bun process. */
|
|
||||||
private handleMenuAction(action: string): void {
|
|
||||||
if (action.startsWith('ctx-')) {
|
|
||||||
this.handleContextAction(action);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (action.startsWith('connect:')) {
|
|
||||||
const id = action.slice('connect:'.length);
|
|
||||||
const connection = this.connections.connections().find((c) => c.id === id);
|
|
||||||
if (connection) {
|
|
||||||
void this.panels.connectPanel(
|
|
||||||
this.panels.activeSide(),
|
|
||||||
connection,
|
|
||||||
connection.name,
|
|
||||||
connection.localDir,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
case 'manage-connections':
|
|
||||||
this.dialogs.manager.set(true);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tab (workspace) shortcuts — panel-level keys live on the panels themselves. */
|
|
||||||
onWindowKeydown(event: KeyboardEvent): void {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toastIcon(kind: 'error' | 'info' | 'success'): string {
|
|
||||||
if (kind === 'error') return 'mynaXCircle';
|
|
||||||
if (kind === 'success') return 'mynaCheckCircle';
|
|
||||||
return 'mynaCheck';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,364 +0,0 @@
|
|||||||
<div class="fixed inset-0 z-30 grid place-items-center bg-black/40" (mousedown)="close()">
|
|
||||||
<div
|
|
||||||
class="flex h-[440px] w-[680px] flex-col rounded-lg border border-border bg-card text-card-foreground shadow-xl"
|
|
||||||
(mousedown)="$event.stopPropagation()"
|
|
||||||
>
|
|
||||||
<!-- title bar -->
|
|
||||||
<div class="flex items-center justify-between border-b border-border px-4 py-2.5">
|
|
||||||
<h2 class="text-sm font-semibold">Connections</h2>
|
|
||||||
<button (click)="close()" class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground">
|
|
||||||
<ng-icon name="mynaX" size="1rem" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex min-h-0 flex-1">
|
|
||||||
<!-- stacks + connections list -->
|
|
||||||
<div class="flex w-60 shrink-0 flex-col border-r border-border">
|
|
||||||
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
|
||||||
<button
|
|
||||||
(click)="newConnection()"
|
|
||||||
title="New connection"
|
|
||||||
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaPlus" size="0.85rem" /> Connection
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="newStack()"
|
|
||||||
title="New stack"
|
|
||||||
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaFolderPlus" size="0.85rem" /> Stack
|
|
||||||
</button>
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
<button
|
|
||||||
(click)="importWinScp()"
|
|
||||||
[disabled]="importing()"
|
|
||||||
title="Import sessions from a WinSCP.ini export"
|
|
||||||
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
|
|
||||||
>
|
|
||||||
@if (importing()) {
|
|
||||||
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
|
|
||||||
} @else {
|
|
||||||
<ng-icon name="mynaArchive" size="0.85rem" />
|
|
||||||
}
|
|
||||||
Import
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto py-1">
|
|
||||||
<!-- stacks -->
|
|
||||||
@for (group of store.grouped().stacks; track group.stack.id) {
|
|
||||||
<div
|
|
||||||
(dragover)="onDragOver($event, group.stack.id)"
|
|
||||||
(dragleave)="dropTarget() === group.stack.id && dropTarget.set(null)"
|
|
||||||
(drop)="onDrop($event, group.stack.id)"
|
|
||||||
class="rounded-md"
|
|
||||||
[class.bg-primary/10]="dropTarget() === group.stack.id"
|
|
||||||
[class.ring-1]="dropTarget() === group.stack.id"
|
|
||||||
[class.ring-inset]="dropTarget() === group.stack.id"
|
|
||||||
[class.ring-primary]="dropTarget() === group.stack.id"
|
|
||||||
>
|
|
||||||
<div class="group flex items-center gap-1 px-2 pt-2 pb-0.5">
|
|
||||||
<button
|
|
||||||
(click)="toggleStack(group.stack)"
|
|
||||||
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground hover:text-foreground"
|
|
||||||
[title]="group.stack.collapsed ? 'Expand' : 'Collapse'"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
[name]="group.stack.collapsed ? 'mynaChevronRight' : 'mynaChevronDown'"
|
|
||||||
size="0.8rem"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<span class="truncate text-[11px] font-medium text-muted-foreground uppercase">
|
|
||||||
{{ group.stack.name }}
|
|
||||||
</span>
|
|
||||||
<span class="shrink-0 text-[10px] text-muted-foreground/70 tabular-nums">
|
|
||||||
{{ group.connections.length }}
|
|
||||||
</span>
|
|
||||||
<div class="min-w-0 flex-1"></div>
|
|
||||||
<button
|
|
||||||
(click)="renameStack(group.stack)"
|
|
||||||
title="Rename stack"
|
|
||||||
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaEdit" size="0.75rem" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="deleteStack(group.stack)"
|
|
||||||
title="Delete stack"
|
|
||||||
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-destructive"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaTrash" size="0.75rem" />
|
|
||||||
</button>
|
|
||||||
@if (group.connections.length) {
|
|
||||||
<button
|
|
||||||
(click)="openStack(group.connections)"
|
|
||||||
title="Open stack in tabs"
|
|
||||||
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground hover:text-primary"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaArrowRight" size="0.8rem" />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@if (!group.stack.collapsed) {
|
|
||||||
@for (connection of group.connections; track connection.id) {
|
|
||||||
<button
|
|
||||||
(click)="edit(connection)"
|
|
||||||
draggable="true"
|
|
||||||
(dragstart)="onDragStart($event, connection)"
|
|
||||||
(dragend)="onDragEnd()"
|
|
||||||
class="flex w-full items-center gap-1.5 py-1.5 pr-3 pl-6 text-left text-xs hover:bg-accent"
|
|
||||||
[class.bg-accent]="draft()?.id === connection.id"
|
|
||||||
[class.opacity-50]="draggedId() === connection.id"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
|
||||||
<span class="truncate">{{ connection.name }}</span>
|
|
||||||
</button>
|
|
||||||
} @empty {
|
|
||||||
<div class="py-1 pl-6 text-[11px] text-muted-foreground italic">empty</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- loose connections -->
|
|
||||||
<div
|
|
||||||
(dragover)="onDragOver($event, 'loose')"
|
|
||||||
(dragleave)="dropTarget() === 'loose' && dropTarget.set(null)"
|
|
||||||
(drop)="onDrop($event, null)"
|
|
||||||
class="min-h-8 rounded-md pb-1"
|
|
||||||
[class.bg-primary/10]="dropTarget() === 'loose'"
|
|
||||||
[class.ring-1]="dropTarget() === 'loose'"
|
|
||||||
[class.ring-inset]="dropTarget() === 'loose'"
|
|
||||||
[class.ring-primary]="dropTarget() === 'loose'"
|
|
||||||
>
|
|
||||||
@if (store.grouped().stacks.length && store.grouped().loose.length) {
|
|
||||||
<div class="px-2 pt-2 pb-0.5 text-[11px] font-medium text-muted-foreground uppercase">
|
|
||||||
Connections
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@for (connection of store.grouped().loose; track connection.id) {
|
|
||||||
<button
|
|
||||||
(click)="edit(connection)"
|
|
||||||
draggable="true"
|
|
||||||
(dragstart)="onDragStart($event, connection)"
|
|
||||||
(dragend)="onDragEnd()"
|
|
||||||
class="flex w-full items-center gap-1.5 px-3 py-1.5 text-left text-xs hover:bg-accent"
|
|
||||||
[class.bg-accent]="draft()?.id === connection.id"
|
|
||||||
[class.opacity-50]="draggedId() === connection.id"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
|
||||||
<span class="truncate">{{ connection.name }}</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (!store.connections().length && !store.stacks().length) {
|
|
||||||
<p class="px-3 py-2 text-[11px] text-muted-foreground">
|
|
||||||
No saved connections yet. Create one, or import your WinSCP sessions.
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- edit form -->
|
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
|
||||||
@if (draft(); as d) {
|
|
||||||
<div class="grid flex-1 auto-rows-min grid-cols-2 gap-x-3 gap-y-2.5 overflow-y-auto p-4">
|
|
||||||
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">Name</span>
|
|
||||||
<input
|
|
||||||
[value]="d.name"
|
|
||||||
(input)="patch('name', $any($event.target).value)"
|
|
||||||
placeholder="my server"
|
|
||||||
spellcheck="false"
|
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">Host</span>
|
|
||||||
<input
|
|
||||||
[value]="d.host"
|
|
||||||
(input)="patch('host', $any($event.target).value)"
|
|
||||||
placeholder="192.168.1.10"
|
|
||||||
spellcheck="false"
|
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
|
||||||
<label class="flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">Port</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
[value]="d.port"
|
|
||||||
(input)="patch('port', +$any($event.target).value)"
|
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">Stack</span>
|
|
||||||
<select
|
|
||||||
(change)="patch('stackId', $any($event.target).value || null)"
|
|
||||||
class="h-7 rounded-md border border-input bg-background px-1 outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
>
|
|
||||||
<!-- [selected] per option: binding [value] on the <select> runs
|
|
||||||
before the options render and silently shows "(none)" -->
|
|
||||||
<option value="" [selected]="!d.stackId">(none)</option>
|
|
||||||
@for (stack of store.stacks(); track stack.id) {
|
|
||||||
<option [value]="stack.id" [selected]="d.stackId === stack.id">
|
|
||||||
{{ stack.name }}
|
|
||||||
</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">Username</span>
|
|
||||||
<input
|
|
||||||
[value]="d.username"
|
|
||||||
(input)="patch('username', $any($event.target).value)"
|
|
||||||
spellcheck="false"
|
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">Remote path (optional)</span>
|
|
||||||
<input
|
|
||||||
[value]="d.initialPath"
|
|
||||||
(input)="patch('initialPath', $any($event.target).value)"
|
|
||||||
placeholder="home directory"
|
|
||||||
spellcheck="false"
|
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="col-span-2 flex items-center gap-4 text-xs">
|
|
||||||
<span class="text-muted-foreground">Auth</span>
|
|
||||||
<label class="flex items-center gap-1.5">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="authKind"
|
|
||||||
[checked]="d.authKind === 'password'"
|
|
||||||
(change)="patch('authKind', 'password')"
|
|
||||||
class="accent-primary"
|
|
||||||
/>
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-1.5">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="authKind"
|
|
||||||
[checked]="d.authKind === 'key'"
|
|
||||||
(change)="patch('authKind', 'key')"
|
|
||||||
class="accent-primary"
|
|
||||||
/>
|
|
||||||
Private key
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (d.authKind === 'password') {
|
|
||||||
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">Password</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
[value]="d.password"
|
|
||||||
(input)="patch('password', $any($event.target).value)"
|
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
} @else {
|
|
||||||
<label class="flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">Private key file</span>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
[value]="d.privateKeyPath"
|
|
||||||
(input)="patch('privateKeyPath', $any($event.target).value)"
|
|
||||||
placeholder="~/.ssh/id_ed25519"
|
|
||||||
spellcheck="false"
|
|
||||||
class="h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="pickKeyFile()"
|
|
||||||
class="h-7 shrink-0 rounded-md border border-border px-2 text-[11px] hover:bg-accent"
|
|
||||||
>
|
|
||||||
Choose…
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label class="flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">Passphrase (optional)</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
[value]="d.passphrase"
|
|
||||||
(input)="patch('passphrase', $any($event.target).value)"
|
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (moreOpen()) {
|
|
||||||
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">Local directory (optional)</span>
|
|
||||||
<input
|
|
||||||
[value]="d.localDir"
|
|
||||||
(input)="patch('localDir', $any($event.target).value)"
|
|
||||||
placeholder="~/projects/my-server"
|
|
||||||
spellcheck="false"
|
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
<span class="text-[10px] text-muted-foreground/80">
|
|
||||||
The other panel opens here when it's on Local and hasn't been navigated.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2 border-t border-border px-4 py-2.5">
|
|
||||||
<button
|
|
||||||
(click)="deleteCurrent()"
|
|
||||||
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-destructive"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaTrash" size="0.85rem" /> Delete
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="moreOpen.set(!moreOpen())"
|
|
||||||
class="rounded-md border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
{{ moreOpen() ? 'Less…' : 'More…' }}
|
|
||||||
</button>
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
<button
|
|
||||||
(click)="save()"
|
|
||||||
[disabled]="!valid()"
|
|
||||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="saveAndConnect()"
|
|
||||||
[disabled]="!valid() || busy()"
|
|
||||||
class="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
@if (busy()) {
|
|
||||||
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
|
|
||||||
}
|
|
||||||
Connect
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="grid flex-1 place-items-center">
|
|
||||||
<div class="text-center">
|
|
||||||
<ng-icon name="mynaServers" size="1.75rem" class="text-muted-foreground" />
|
|
||||||
<p class="mt-2 text-xs text-muted-foreground">
|
|
||||||
Select a connection or create a new one.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,251 +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 { DialogsService } from '../dialogs.service';
|
|
||||||
import { PanelsService } from '../panels.service';
|
|
||||||
import { RpcService } from '../rpc.service';
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-connection-manager',
|
|
||||||
imports: [NgIcon],
|
|
||||||
templateUrl: './connection-manager.html',
|
|
||||||
})
|
|
||||||
export class ConnectionManager {
|
|
||||||
readonly store = inject(ConnectionsService);
|
|
||||||
readonly dialogs = inject(DialogsService);
|
|
||||||
readonly panels = inject(PanelsService);
|
|
||||||
private readonly rpc = inject(RpcService);
|
|
||||||
|
|
||||||
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 {
|
|
||||||
this.dialogs.manager.set(false);
|
|
||||||
this.panels.refocus();
|
|
||||||
}
|
|
||||||
|
|
||||||
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> {
|
|
||||||
const d = this.draft();
|
|
||||||
if (!d) return;
|
|
||||||
this.busy.set(true);
|
|
||||||
try {
|
|
||||||
const id = await this.save();
|
|
||||||
if (!id) return;
|
|
||||||
const connection = fromDraft({ ...d, id });
|
|
||||||
const ok = await this.panels.connectPanel(
|
|
||||||
this.panels.activeSide(),
|
|
||||||
connection,
|
|
||||||
connection.name,
|
|
||||||
connection.localDir,
|
|
||||||
);
|
|
||||||
if (ok) this.close();
|
|
||||||
} 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(connections: SavedConnection[]): void {
|
|
||||||
this.panels.openStack(connections);
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
@@ -29,6 +31,8 @@ export class ConnectionsService {
|
|||||||
if (this.rpc.available) {
|
if (this.rpc.available) {
|
||||||
void this.reload();
|
void this.reload();
|
||||||
void this.reloadSshHosts();
|
void this.reloadSshHosts();
|
||||||
|
// the connection-manager window edited the store — pick it up
|
||||||
|
this.rpc.onConnectionsChanged(() => void this.reload());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,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),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Injectable, signal } from '@angular/core';
|
import { Injectable, signal } from '@angular/core';
|
||||||
import type { FileEntry } from '@shared/types';
|
import type { FileEntry } from '@shared/types';
|
||||||
import type { PanelState } from './panels.service';
|
|
||||||
|
|
||||||
export type OverwriteChoice = 'overwrite' | 'skip' | 'overwriteAll' | 'skipAll' | 'cancel';
|
export type OverwriteChoice = 'overwrite' | 'skip' | 'overwriteAll' | 'skipAll' | 'cancel';
|
||||||
|
|
||||||
@@ -34,23 +33,12 @@ export interface ChmodRequest {
|
|||||||
onConfirm: (mode: number) => void;
|
onConfirm: (mode: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EditorRequest {
|
|
||||||
sessionId: string;
|
|
||||||
path: string;
|
|
||||||
name: string;
|
|
||||||
sessionTitle: string;
|
|
||||||
content: string;
|
|
||||||
panel: PanelState; // refreshed after save
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class DialogsService {
|
export class DialogsService {
|
||||||
readonly prompt = signal<PromptRequest | null>(null);
|
readonly prompt = signal<PromptRequest | null>(null);
|
||||||
readonly confirm = signal<ConfirmRequest | null>(null);
|
readonly confirm = signal<ConfirmRequest | null>(null);
|
||||||
readonly chmod = signal<ChmodRequest | null>(null);
|
readonly chmod = signal<ChmodRequest | null>(null);
|
||||||
readonly overwrite = signal<OverwriteRequest | null>(null);
|
readonly overwrite = signal<OverwriteRequest | null>(null);
|
||||||
readonly editor = signal<EditorRequest | null>(null);
|
|
||||||
readonly manager = signal(false);
|
|
||||||
|
|
||||||
/** Ask what to do about one name collision; resolves with the user's choice. */
|
/** Ask what to do about one name collision; resolves with the user's choice. */
|
||||||
askOverwrite(request: Omit<OverwriteRequest, 'resolve'>): Promise<OverwriteChoice> {
|
askOverwrite(request: Omit<OverwriteRequest, 'resolve'>): Promise<OverwriteChoice> {
|
||||||
@@ -59,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,18 +64,36 @@ function categoryOf(name: string): Category | null {
|
|||||||
return EXT_CATEGORY.get(key) ?? null;
|
return EXT_CATEGORY.get(key) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fileGlyph(entry: FileEntry, multi: boolean): string {
|
const KIND_KEYS: Record<Category, string> = {
|
||||||
if (entry.name === '..') return 'mynaArrowUp';
|
image: 'image',
|
||||||
if (entry.type === 'dir') return 'mynaFolder';
|
audio: 'audio',
|
||||||
if (!multi) return 'mynaFile';
|
video: 'video',
|
||||||
if (entry.type === 'symlink') return 'mynaLink';
|
archive: 'archive',
|
||||||
|
code: 'code',
|
||||||
|
doc: 'document',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.type === 'dir') return 'folder';
|
||||||
|
if (entry.type === 'symlink') return 'symlink';
|
||||||
const category = categoryOf(entry.name);
|
const category = categoryOf(entry.name);
|
||||||
return category ? CATEGORIES[category].glyph : 'mynaFile';
|
return category ? KIND_KEYS[category] : 'file';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fileIconClass(entry: FileEntry, multi: boolean): string {
|
export function fileGlyph(entry: FileEntry): Icon16 {
|
||||||
// plain mode: exactly the pre-color look ('..' has type 'dir' -> primary)
|
if (entry.name === '..') return 'arrowUp16';
|
||||||
if (!multi) return entry.type === 'dir' ? PRIMARY : MUTED;
|
if (entry.type === 'dir') return 'folder16';
|
||||||
|
if (entry.type === 'symlink') return 'link16';
|
||||||
|
const category = categoryOf(entry.name);
|
||||||
|
return category ? CATEGORIES[category].glyph : 'file16';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fileIconClass(entry: FileEntry): string {
|
||||||
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Give keyboard focus back to the main surface" signal. Dependency-free so
|
||||||
|
* dialogs can bump it from ANY window kind — in the main window the file
|
||||||
|
* panels listen (via PanelsService.focusTick); elsewhere nobody does, which
|
||||||
|
* is exactly right.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class FocusService {
|
||||||
|
readonly tick = signal(0);
|
||||||
|
|
||||||
|
bump(): void {
|
||||||
|
this.tick.update((n) => n + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
|
||||||
|
/** 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 unit = 0;
|
||||||
|
while (value >= 1024 && unit < SIZE_UNITS.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
unit++;
|
||||||
|
}
|
||||||
|
return `${unit === 0 ? num(value, locale) : num(Number(value.toFixed(1)), locale)} ${SIZE_UNITS[unit]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
DATE_FORMATS.set(key, fmt);
|
||||||
|
}
|
||||||
|
return fmt.format(new Date(epochMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** bytes/sec -> "14.2 MB/s" or "113.8 Mbps" depending on the configured unit. */
|
||||||
|
export function formatSpeed(bytesPerSec: number, unit: 'MBps' | 'Mbps', locale?: string): string {
|
||||||
|
if (unit === 'Mbps') {
|
||||||
|
return `${num(Number(((bytesPerSec * 8) / 1_000_000).toFixed(1)), locale)} Mbps`;
|
||||||
|
}
|
||||||
|
return `${formatSize(bytesPerSec, locale)}/s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seconds -> "0:42" / "3:07" / "1:02:15". */
|
||||||
|
export function formatEta(seconds: number): string {
|
||||||
|
if (!Number.isFinite(seconds) || seconds < 0) return '';
|
||||||
|
const s = Math.round(seconds);
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const sec = s % 60;
|
||||||
|
const mmss = `${m}:${String(sec).padStart(2, '0')}`;
|
||||||
|
return h ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` : mmss;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Separator-aware join for UI-composed paths: remote and POSIX-local dirs use
|
||||||
|
* "/", a Windows-local dir ("C:\Users\x") keeps "\" so paths stay consistent.
|
||||||
|
*/
|
||||||
|
export function joinPath(dir: string, name: string): string {
|
||||||
|
const sep = dir.includes('\\') && !dir.includes('/') ? '\\' : '/';
|
||||||
|
return dir.endsWith(sep) ? dir + name : `${dir}${sep}${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "/" (POSIX) or a drive root like "C:\" — nothing above to navigate to. */
|
||||||
|
export function isRootPath(p: string): boolean {
|
||||||
|
return p === '/' || /^[A-Za-z]:[\\/]?$/.test(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 0o755 -> "rwxr-xr-x" */
|
||||||
|
export function formatMode(mode: number): string {
|
||||||
|
const flags = 'rwxrwxrwx';
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
out += mode & (0o400 >> i) ? flags[i] : '-';
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -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,10 +1,22 @@
|
|||||||
import { Injectable, computed, signal, type WritableSignal } from '@angular/core';
|
import { Injectable, computed, effect, inject, signal, type WritableSignal } from '@angular/core';
|
||||||
import type { ConnectionConfig, FileEntry, SavedConnection } from '@shared/types';
|
import type { ColumnKey, ConnectionConfig, FileEntry, SavedConnection } from '@shared/types';
|
||||||
|
import { fileKindKey } from './file-icons';
|
||||||
|
import { FocusService } from './focus.service';
|
||||||
|
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';
|
||||||
export type SortKey = 'name' | 'size' | 'mtime';
|
/** Any visible column sorts; Name is always available. */
|
||||||
|
export type SortKey = 'name' | ColumnKey;
|
||||||
|
|
||||||
|
/** Display string the text-based columns sort by. */
|
||||||
|
function sortText(entry: FileEntry, key: 'kind' | 'rights' | 'owner' | 'group'): string {
|
||||||
|
if (key === 'kind') return fileKindKey(entry);
|
||||||
|
if (key === 'rights') return formatMode(entry.mode);
|
||||||
|
return (key === 'owner' ? entry.owner : entry.group) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
interface PanelDeps {
|
interface PanelDeps {
|
||||||
rpc: RpcService;
|
rpc: RpcService;
|
||||||
@@ -54,6 +66,7 @@ export class PanelState {
|
|||||||
let cmp = 0;
|
let cmp = 0;
|
||||||
if (key === 'size') cmp = a.size - b.size;
|
if (key === 'size') cmp = a.size - b.size;
|
||||||
else if (key === 'mtime') cmp = a.mtime - b.mtime;
|
else if (key === 'mtime') cmp = a.mtime - b.mtime;
|
||||||
|
else if (key !== 'name') cmp = sortText(a, key).localeCompare(sortText(b, key));
|
||||||
if (cmp === 0) {
|
if (cmp === 0) {
|
||||||
cmp = a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
|
cmp = a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
|
||||||
if (key === 'name') return cmp * dir;
|
if (key === 'name') return cmp * dir;
|
||||||
@@ -61,8 +74,8 @@ export class PanelState {
|
|||||||
return cmp * dir;
|
return cmp * dir;
|
||||||
});
|
});
|
||||||
const cwd = this.cwd();
|
const cwd = this.cwd();
|
||||||
if (cwd && cwd !== '/') {
|
if (cwd && !isRootPath(cwd)) {
|
||||||
list.unshift({ name: '..', path: `${cwd}/..`, type: 'dir', size: 0, mtime: 0, mode: 0 });
|
list.unshift({ name: '..', path: joinPath(cwd, '..'), type: 'dir', size: 0, mtime: 0, mode: 0 });
|
||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
});
|
});
|
||||||
@@ -96,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);
|
||||||
@@ -118,9 +146,12 @@ 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);
|
||||||
this.slowLoad.set(false);
|
if (!silent) {
|
||||||
this.loading.set(false);
|
this.slowLoad.set(false);
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
this.refreshing = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +163,7 @@ export class PanelState {
|
|||||||
|
|
||||||
up(): void {
|
up(): void {
|
||||||
if (!this.cwd()) return;
|
if (!this.cwd()) return;
|
||||||
void this.load(`${this.cwd()}/..`);
|
void this.load(joinPath(this.cwd(), '..'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update the filter and park the cursor on the first real match. */
|
/** Update the filter and park the cursor on the first real match. */
|
||||||
@@ -195,14 +226,16 @@ 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(() => {
|
||||||
const list = this.workspaces();
|
const list = this.workspaces();
|
||||||
return list.find((w) => w.id === this.activeWorkspaceId()) ?? list[0];
|
return list.find((w) => w.id === this.activeWorkspaceId()) ?? list[0];
|
||||||
});
|
});
|
||||||
|
private readonly focus = inject(FocusService);
|
||||||
/** Bumped to tell the active panel to grab keyboard focus again (e.g. after a dialog closes). */
|
/** Bumped to tell the active panel to grab keyboard focus again (e.g. after a dialog closes). */
|
||||||
readonly focusTick = signal(0);
|
readonly focusTick = this.focus.tick;
|
||||||
|
|
||||||
private nextWorkspaceId = 1;
|
private nextWorkspaceId = 1;
|
||||||
|
|
||||||
@@ -217,12 +250,70 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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 }) => {
|
||||||
|
for (const panel of this.allPanels()) {
|
||||||
|
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 });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- panels of the active workspace ----
|
// ---- panels of the active workspace ----
|
||||||
@@ -258,7 +349,20 @@ export class PanelsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
refocus(): void {
|
refocus(): void {
|
||||||
this.focusTick.update((n) => n + 1);
|
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 ----
|
||||||
@@ -307,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);
|
||||||
@@ -326,8 +470,9 @@ export class PanelsService {
|
|||||||
this.newWorkspace();
|
this.newWorkspace();
|
||||||
}
|
}
|
||||||
// connectPanel resolves the panel synchronously (before awaiting), so
|
// connectPanel resolves the panel synchronously (before awaiting), so
|
||||||
// firing these back-to-back binds each connection to its own tab
|
// firing these back-to-back binds each connection to its own tab.
|
||||||
void this.connectPanel('left', connection, connection.name, connection.localDir);
|
// Remote goes right, local stays left — the commander convention.
|
||||||
|
void this.connectPanel('right', connection, connection.name, connection.localDir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,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')),
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Injected by the bun process via BrowserWindow preload (src/bun/windows.ts
|
||||||
|
// buildPreload) before any page script. Brand-neutral names so rebrands never
|
||||||
|
// touch this file.
|
||||||
|
import type { EditorOpenContext } from '@shared/rpc';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__APP_PLATFORM?: string;
|
||||||
|
/** Which root component this window boots. */
|
||||||
|
__APP_MODE?: 'main' | 'editor' | 'manager' | 'settings';
|
||||||
|
/** Window-kind specific context (EditorOpenContext for editor windows). */
|
||||||
|
__APP_CTX?: unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?? '') : '';
|
||||||
|
|
||||||
|
export const IS_MAC = PLATFORM === 'darwin';
|
||||||
|
|
||||||
|
export const IS_WINDOWS = PLATFORM === 'win32';
|
||||||
|
|
||||||
|
export const IS_LINUX = PLATFORM === 'linux';
|
||||||
|
|
||||||
|
export const APP_MODE: 'main' | 'editor' | 'manager' | 'settings' =
|
||||||
|
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
|
||||||
|
|
||||||
|
export const EDITOR_CONTEXT =
|
||||||
|
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…)
|
||||||
|
* everywhere the app doesn't show its own native menu. Text fields keep the
|
||||||
|
* system text menu (cut/copy/paste, spelling).
|
||||||
|
*/
|
||||||
|
export function blockNativeContextMenu(event: Event): void {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
if (target?.closest('input, textarea, [contenteditable="true"]')) return;
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Electroview } from 'electrobun/view';
|
||||||
|
import type { AppRPC } from '@shared/rpc';
|
||||||
|
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.
|
||||||
|
const MAX_REQUEST_TIME_MS = 10 * 60_000;
|
||||||
|
|
||||||
|
export type SessionClosedEvent = { sessionId: string; reason: string };
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class RpcService {
|
||||||
|
/** False when the page is opened in a plain browser instead of the app. */
|
||||||
|
readonly available: boolean;
|
||||||
|
|
||||||
|
// Push messages are delivered through plain callbacks, NOT signals: a signal
|
||||||
|
// consumed from an effect that also writes signals re-triggers itself
|
||||||
|
// (this froze the app), and signal coalescing would drop progress snapshots.
|
||||||
|
private transferListeners = new Set<(s: TransferSnapshot) => void>();
|
||||||
|
private closedListeners = new Set<(e: SessionClosedEvent) => void>();
|
||||||
|
private menuListeners = new Set<(action: string) => void>();
|
||||||
|
private settingsListeners = new Set<(settings: AppSettings) => void>();
|
||||||
|
private pathRefreshListeners = new Set<(e: { sessionId: string; dir: string }) => void>();
|
||||||
|
private editorCloseListeners = new Set<() => void>();
|
||||||
|
private managerStateListeners = new Set<(open: boolean) => void>();
|
||||||
|
private connectionsListeners = new Set<() => void>();
|
||||||
|
private updateStatusListeners = new Set<(status: UpdateStatus) => void>();
|
||||||
|
|
||||||
|
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
|
||||||
|
private ready: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.available = typeof window !== 'undefined' && '__electrobun' in window;
|
||||||
|
if (!this.available) return;
|
||||||
|
|
||||||
|
this.rpc = Electroview.defineRPC<AppRPC>({
|
||||||
|
maxRequestTime: MAX_REQUEST_TIME_MS,
|
||||||
|
handlers: {
|
||||||
|
messages: {
|
||||||
|
transferUpdate: (snapshot) => {
|
||||||
|
for (const listener of this.transferListeners) listener(snapshot);
|
||||||
|
},
|
||||||
|
sessionClosed: (event) => {
|
||||||
|
for (const listener of this.closedListeners) listener(event);
|
||||||
|
},
|
||||||
|
menuAction: ({ action }) => {
|
||||||
|
for (const listener of this.menuListeners) listener(action);
|
||||||
|
},
|
||||||
|
settingsChanged: ({ settings }) => {
|
||||||
|
for (const listener of this.settingsListeners) listener(settings);
|
||||||
|
},
|
||||||
|
pathRefresh: (event) => {
|
||||||
|
for (const listener of this.pathRefreshListeners) listener(event);
|
||||||
|
},
|
||||||
|
editorCloseRequest: () => {
|
||||||
|
for (const listener of this.editorCloseListeners) listener();
|
||||||
|
},
|
||||||
|
managerState: ({ open }) => {
|
||||||
|
for (const listener of this.managerStateListeners) listener(open);
|
||||||
|
},
|
||||||
|
connectionsChanged: () => {
|
||||||
|
for (const listener of this.connectionsListeners) listener();
|
||||||
|
},
|
||||||
|
updateStatus: (status) => {
|
||||||
|
for (const listener of this.updateStatusListeners) listener(status);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const electroview = new Electroview({ rpc: this.rpc });
|
||||||
|
|
||||||
|
// Gate everything below on the RPC WebSocket being open (services fire calls
|
||||||
|
// at startup). v1 silently lost pre-open sends; 2.x Electroview queues them
|
||||||
|
// 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) {
|
||||||
|
this.ready = new Promise((resolve) => {
|
||||||
|
socket.addEventListener('open', () => resolve(), { once: true });
|
||||||
|
socket.addEventListener('error', () => resolve(), { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onTransferUpdate(listener: (snapshot: TransferSnapshot) => void): void {
|
||||||
|
this.transferListeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSessionClosed(listener: (event: SessionClosedEvent) => void): void {
|
||||||
|
this.closedListeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMenuAction(listener: (action: string) => void): void {
|
||||||
|
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 {
|
||||||
|
this.settingsListeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPathRefresh(listener: (event: { sessionId: string; dir: string }) => void): void {
|
||||||
|
this.pathRefreshListeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
onEditorCloseRequest(listener: () => void): void {
|
||||||
|
this.editorCloseListeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
onManagerState(listener: (open: boolean) => void): void {
|
||||||
|
this.managerStateListeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
onConnectionsChanged(listener: () => void): void {
|
||||||
|
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. */
|
||||||
|
get request() {
|
||||||
|
const rpc = this.rpc;
|
||||||
|
if (!rpc) throw new Error('RPC is only available inside the desktop app');
|
||||||
|
const ready = this.ready;
|
||||||
|
return new Proxy({} as (typeof rpc)['request'], {
|
||||||
|
get:
|
||||||
|
(_target, method: string) =>
|
||||||
|
async (params?: unknown) => {
|
||||||
|
await ready;
|
||||||
|
const methods = rpc.request as unknown as Record<
|
||||||
|
string,
|
||||||
|
(p?: unknown) => Promise<unknown>
|
||||||
|
>;
|
||||||
|
return methods[method](params);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire-and-forget message proxy: `rpc.send.editorState({...})`. Waits for the bridge. */
|
||||||
|
get send() {
|
||||||
|
const rpc = this.rpc;
|
||||||
|
if (!rpc) throw new Error('RPC is only available inside the desktop app');
|
||||||
|
const ready = this.ready;
|
||||||
|
return new Proxy({} as (typeof rpc)['send'], {
|
||||||
|
get:
|
||||||
|
(_target, method: string) =>
|
||||||
|
(payload?: unknown) => {
|
||||||
|
void ready.then(() => {
|
||||||
|
const methods = rpc.send as unknown as Record<string, (p?: unknown) => void>;
|
||||||
|
methods[method](payload);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
|
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';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class SettingsService {
|
||||||
|
readonly locale = signal<AppSettings['locale']>(DEFAULT_SETTINGS.locale);
|
||||||
|
readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme);
|
||||||
|
readonly darkTone = signal<AppSettings['darkTone']>(DEFAULT_SETTINGS.darkTone);
|
||||||
|
readonly columns = signal<ColumnKey[]>(DEFAULT_SETTINGS.columns);
|
||||||
|
readonly speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit);
|
||||||
|
readonly uiScale = signal<number>(DEFAULT_SETTINGS.uiScale);
|
||||||
|
readonly transferMode = signal<AppSettings['transferMode']>(DEFAULT_SETTINGS.transferMode);
|
||||||
|
readonly transferConcurrency = signal(DEFAULT_SETTINGS.transferConcurrency);
|
||||||
|
readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted);
|
||||||
|
readonly editor = signal<AppSettings['editor']>(DEFAULT_SETTINGS.editor);
|
||||||
|
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 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). */
|
||||||
|
readonly effectiveDark = computed(
|
||||||
|
() => this.theme() === 'dark' || (this.theme() === 'system' && this.systemDark()),
|
||||||
|
);
|
||||||
|
private readonly systemDark = signal(window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
window
|
||||||
|
.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
.addEventListener('change', (e) => this.systemDark.set(e.matches));
|
||||||
|
if (!this.rpc.available) return;
|
||||||
|
void this.rpc.request.getSettings({}).then((res) => {
|
||||||
|
if (res.ok) this.applyAll(res.settings);
|
||||||
|
});
|
||||||
|
// editor windows: follow settings the main window saves, live
|
||||||
|
this.rpc.onSettingsChanged((settings) => this.applyAll(settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyAll(settings: AppSettings): void {
|
||||||
|
this.loaded = true;
|
||||||
|
this.locale.set(settings.locale);
|
||||||
|
this.theme.set(settings.theme);
|
||||||
|
this.darkTone.set(settings.darkTone);
|
||||||
|
this.columns.set(settings.columns);
|
||||||
|
this.speedUnit.set(settings.speedUnit);
|
||||||
|
this.uiScale.set(settings.uiScale);
|
||||||
|
this.transferMode.set(settings.transferMode);
|
||||||
|
this.transferConcurrency.set(settings.transferConcurrency);
|
||||||
|
this.autoClearCompleted.set(settings.autoClearCompleted);
|
||||||
|
this.editor.set(settings.editor);
|
||||||
|
this.editorApp.set(settings.editorApp);
|
||||||
|
this.keybindings.set(settings.keybindings);
|
||||||
|
this.openFilesOnEnter.set(settings.openFilesOnEnter);
|
||||||
|
this.applyTheme();
|
||||||
|
this.applyDarkTone();
|
||||||
|
this.applyUiScale();
|
||||||
|
this.applyLocale();
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocale(locale: AppSettings['locale']): void {
|
||||||
|
this.locale.set(locale);
|
||||||
|
this.applyLocale();
|
||||||
|
void this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
setTheme(theme: AppSettings['theme']): void {
|
||||||
|
this.theme.set(theme);
|
||||||
|
this.applyTheme();
|
||||||
|
void this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
setDarkTone(tone: AppSettings['darkTone']): void {
|
||||||
|
this.darkTone.set(tone);
|
||||||
|
this.applyDarkTone();
|
||||||
|
void this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleColumn(key: ColumnKey): void {
|
||||||
|
this.columns.update((list) =>
|
||||||
|
list.includes(key) ? list.filter((c) => c !== key) : [...list, key],
|
||||||
|
);
|
||||||
|
void this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
setSpeedUnit(unit: AppSettings['speedUnit']): void {
|
||||||
|
this.speedUnit.set(unit);
|
||||||
|
void this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
setUiScale(scale: number): void {
|
||||||
|
this.uiScale.set(clampUiScale(scale));
|
||||||
|
this.applyUiScale();
|
||||||
|
void this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
setTransferMode(mode: AppSettings['transferMode']): void {
|
||||||
|
this.transferMode.set(mode);
|
||||||
|
void this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
setTransferConcurrency(count: number): void {
|
||||||
|
this.transferConcurrency.set(count);
|
||||||
|
void this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
setAutoClearCompleted(auto: boolean): void {
|
||||||
|
this.autoClearCompleted.set(auto);
|
||||||
|
void this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditor(editor: AppSettings['editor']): void {
|
||||||
|
this.editor.set(editor);
|
||||||
|
void this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditorApp(path: string): void {
|
||||||
|
this.editorApp.set(path);
|
||||||
|
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 {
|
||||||
|
const theme = this.theme();
|
||||||
|
if (theme === 'system') {
|
||||||
|
delete document.documentElement.dataset['theme'];
|
||||||
|
} else {
|
||||||
|
document.documentElement.dataset['theme'] = theme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyDarkTone(): void {
|
||||||
|
const tone = this.darkTone();
|
||||||
|
if (tone === 'dark') {
|
||||||
|
delete document.documentElement.dataset['darkTone'];
|
||||||
|
} else {
|
||||||
|
document.documentElement.dataset['darkTone'] = tone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The whole interface scale is ONE declaration: every size in the app is
|
||||||
|
* rem, so moving the root font-size moves the whole UI. 1 = the
|
||||||
|
* browser-default 16px root; delete-when-default keeps the pristine state
|
||||||
|
* 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 {
|
||||||
|
document.documentElement.style.fontSize = `${16 * scale}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyLocale(): void {
|
||||||
|
this.i18n.locale.set(resolveLocale(this.locale(), navigator.language));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected settings(): AppSettings {
|
||||||
|
return {
|
||||||
|
locale: this.locale(),
|
||||||
|
theme: this.theme(),
|
||||||
|
darkTone: this.darkTone(),
|
||||||
|
columns: this.columns(),
|
||||||
|
speedUnit: this.speedUnit(),
|
||||||
|
uiScale: this.uiScale(),
|
||||||
|
transferMode: this.transferMode(),
|
||||||
|
transferConcurrency: this.transferConcurrency(),
|
||||||
|
autoClearCompleted: this.autoClearCompleted(),
|
||||||
|
editor: this.editor(),
|
||||||
|
editorApp: this.editorApp(),
|
||||||
|
keybindings: this.keybindings(),
|
||||||
|
openFilesOnEnter: this.openFilesOnEnter(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persist(): Promise<void> {
|
||||||
|
if (!this.loaded) return;
|
||||||
|
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])',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||