Compare commits
47
Commits
6c05e39ce1
...
0eb5608873
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0eb5608873 | ||
|
|
9bd237d89c | ||
|
|
bfc2147c77 | ||
|
|
318760efa6 | ||
|
|
141d675bf7 | ||
|
|
f3bec7aeaf | ||
|
|
99bfd3abc2 | ||
|
|
a83264c77d | ||
|
|
e3a5750b01 | ||
|
|
c3d97ce524 | ||
|
|
f7f02083fe | ||
|
|
2bac7678ca | ||
|
|
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 |
@@ -1,14 +1,16 @@
|
|||||||
# Release matrix — INERT until act_runner instances with these labels are
|
# Release matrix — INERT until act_runner instances with these labels are
|
||||||
# registered on git.teknes.es. Each OS builds its own artifacts (electrobun
|
# registered on git.teknes.es. Each OS builds its own artifacts (electrobun
|
||||||
# cannot cross-compile: packaging needs codesign/hdiutil on macOS and
|
# cannot cross-compile: packaging needs codesign/hdiutil on macOS and
|
||||||
# rcedit/PowerShell on Windows).
|
# rcedit/PowerShell on Windows). No mac-x64: Electrobun 2.x has no macOS x64
|
||||||
|
# core.
|
||||||
#
|
#
|
||||||
# Runner prerequisites:
|
# Runner prerequisites:
|
||||||
# macos — arm64 Mac: bun, Xcode CLT, Rosetta 2 (softwareupdate
|
# macos — Apple Silicon Mac: bun, Xcode CLT, network to github.com and the
|
||||||
# --install-rosetta), network to github.com (electrobun downloads
|
# Electrobun/Hutch release hosts (`bun install` runs `electrobun
|
||||||
# its per-arch core binaries on first build)
|
# prepare`; the first build per Electrobun version fills ~/.hutch
|
||||||
# windows — Windows 11 x64: bun, network to github.com; WebView2 runtime is
|
# with Hutch, the core, Cottontail and the pinned Bun, ~200 MB)
|
||||||
# preinstalled on Win 11
|
# windows — Windows 11 x64: bun, the same network access; WebView2 runtime
|
||||||
|
# is preinstalled on Win 11
|
||||||
#
|
#
|
||||||
# Trigger: push a tag like build-62 (git tag build-$(git rev-list --count HEAD)).
|
# Trigger: push a tag like build-62 (git tag build-$(git rev-list --count HEAD)).
|
||||||
name: release
|
name: release
|
||||||
@@ -26,7 +28,6 @@ jobs:
|
|||||||
fetch-depth: 0 # full history — the build number is the commit count
|
fetch-depth: 0 # full history — the build number is the commit count
|
||||||
- run: bun install --frozen-lockfile
|
- run: bun install --frozen-lockfile
|
||||||
- run: bun scripts/release.ts --target mac-arm64
|
- run: bun scripts/release.ts --target mac-arm64
|
||||||
- run: bun scripts/release.ts --target mac-x64
|
|
||||||
- uses: actions/upload-artifact@v3
|
- uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: mac
|
name: mac
|
||||||
|
|||||||
@@ -8,3 +8,6 @@ ui/dist/
|
|||||||
ui/.angular/
|
ui/.angular/
|
||||||
src/shared/version.gen.ts
|
src/shared/version.gen.ts
|
||||||
release/
|
release/
|
||||||
|
# Hutch-generated (electrobun 2.x): the projected SDK devkit and build scratch
|
||||||
|
.hutch/
|
||||||
|
.cottontail-tmp/
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ remote server over SSH/SFTP, so you can move files **local↔remote**,
|
|||||||
Dense reference material lives in [docs/](docs/): [architecture](docs/architecture.md)
|
Dense reference material lives in [docs/](docs/): [architecture](docs/architecture.md)
|
||||||
(processes, windows, RPC, every module/service), [build & release](docs/build-and-release.md)
|
(processes, windows, RPC, every module/service), [build & release](docs/build-and-release.md)
|
||||||
(Build-n versioning, per-target release pipeline, CI), [platform notes](docs/platform-notes.md)
|
(Build-n versioning, per-target release pipeline, CI), [platform notes](docs/platform-notes.md)
|
||||||
(macOS/Windows matrix + smoke tests), and [gotchas](docs/gotchas.md) (known landmines).
|
(macOS/Windows/Linux matrix, runtime deps + smoke tests), and
|
||||||
|
[gotchas](docs/gotchas.md) (known landmines).
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ Dense reference material lives in [docs/](docs/): [architecture](docs/architectu
|
|||||||
- **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 / 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
|
||||||
@@ -52,10 +53,10 @@ Dense reference material lives in [docs/](docs/): [architecture](docs/architectu
|
|||||||
| Layer | Choice |
|
| Layer | Choice |
|
||||||
| ---------- | ------------------------------------------------------------- |
|
| ---------- | ------------------------------------------------------------- |
|
||||||
| Runtime | Bun (main process, all SSH/SFTP + file IO) |
|
| Runtime | Bun (main process, all SSH/SFTP + file IO) |
|
||||||
| Desktop | Electrobun 1.18 (native webview, typed RPC bridge) |
|
| Desktop | Electrobun 2.0 via Hutch (native webview, typed RPC bridge) |
|
||||||
| Frontend | Angular 22 (standalone, signals, zoneless) in `ui/` |
|
| Frontend | Angular 22 (standalone, signals, zoneless) in `ui/` |
|
||||||
| Styling | Tailwind CSS v4, shadcn-style tokens (no component library) |
|
| Styling | Tailwind CSS v4, Oxide-derived tokens (no component library) |
|
||||||
| Icons | @ng-icons/mynaui |
|
| Icons | @ng-icons/tdesign-icons |
|
||||||
| SSH/SFTP | `ssh2` (pure-JS under Bun) |
|
| SSH/SFTP | `ssh2` (pure-JS under Bun) |
|
||||||
|
|
||||||
The repo is a Bun workspace: the Electrobun app at the root, the Angular app in
|
The repo is a Bun workspace: the Electrobun app at the root, the Angular app in
|
||||||
@@ -75,8 +76,17 @@ ui/src/app/ Angular components + signal services
|
|||||||
|
|
||||||
- [Bun](https://bun.sh) ≥ 1.3 (`bun --version`).
|
- [Bun](https://bun.sh) ≥ 1.3 (`bun --version`).
|
||||||
- macOS: **Xcode Command Line Tools** (for the app build).
|
- macOS: **Xcode Command Line Tools** (for the app build).
|
||||||
|
- Linux: **AppIndicator** — the app links it for the tray and will not start
|
||||||
|
without it (`ERR_DLOPEN_FAILED`). `libayatana-appindicator` (Arch),
|
||||||
|
`libayatana-appindicator3-1` (Debian/Ubuntu),
|
||||||
|
`libayatana-appindicator-gtk3` (Fedora), plus system `webkit2gtk`.
|
||||||
|
Details and diagnostics in [platform notes](docs/platform-notes.md).
|
||||||
- No Node install required — a preload shim (`scripts/node-version-shim.ts`)
|
- No Node install required — a preload shim (`scripts/node-version-shim.ts`)
|
||||||
satisfies the Angular CLI's Node version check while running under Bun.
|
satisfies the Angular CLI's Node version check while running under Bun.
|
||||||
|
- Network on the first `bun install`: Electrobun 2.x's npm package is a
|
||||||
|
bootstrap that downloads its build tool (Hutch), the Electrobun core and the
|
||||||
|
pinned Bun runtime into `~/.hutch` (~200 MB), and projects the SDK into the
|
||||||
|
gitignored `.hutch/devkit/`.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
@@ -101,19 +111,19 @@ Versioning is Sublime-style: **Build n**, where n is the commit count
|
|||||||
`src/shared/version.gen.ts` by every build script (and `postinstall`).
|
`src/shared/version.gen.ts` by every build script (and `postinstall`).
|
||||||
|
|
||||||
One artifact per invocation, on the matching host — electrobun cannot
|
One artifact per invocation, on the matching host — electrobun cannot
|
||||||
cross-compile between OSes:
|
cross-compile (Hutch builds for its own OS and arch):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
bun run release -- --target mac-arm64 # on the Mac (native)
|
bun run release -- --target mac-arm64 # on an Apple Silicon Mac
|
||||||
bun run release -- --target mac-x64 # on the same arm64 Mac, via Rosetta 2
|
|
||||||
bun run release -- --target win-x64 # on a Windows 11 x64 machine
|
bun run release -- --target win-x64 # on a Windows 11 x64 machine
|
||||||
|
bun run release -- --target linux-x64 # on a Linux x64 machine
|
||||||
```
|
```
|
||||||
|
|
||||||
Artifacts land in `release/<target>/miniSCP-Build<n>-<target>.*` — a `.dmg`
|
Artifacts land in `release/<target>/miniSCP-Build<n>-<target>.*` — a `.dmg`
|
||||||
per mac arch; on Windows a `-Setup.zip` installer **and** a `-portable.zip`
|
on the Mac; on Windows a `-Setup.zip` installer **and** a `-portable.zip`
|
||||||
(unzip anywhere and run). The mac-x64 build fetches electrobun's x64 CLI once
|
(unzip anywhere and run); on Linux Electrobun's `-Setup.tar.gz` installer.
|
||||||
into `~/.cache/miniscp/` and runs it under Rosetta so the whole pipeline
|
There is no Intel Mac build: Electrobun 2.x publishes no macOS x64 core.
|
||||||
believes it is on an Intel Mac. CI: `.gitea/workflows/release.yml` runs the
|
CI: `.gitea/workflows/release.yml` runs the
|
||||||
same script on a macos + windows runner matrix once act_runners are
|
same script on a macos + windows runner matrix once act_runners are
|
||||||
registered; trigger by pushing a `build-<n>` tag.
|
registered; trigger by pushing a `build-<n>` tag.
|
||||||
|
|
||||||
@@ -128,7 +138,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
|
||||||
@@ -172,7 +182,8 @@ Saved connections live in a plain JSON file in the OS app-data directory
|
|||||||
`Space` select · `Enter` open folder · `Backspace` parent ·
|
`Space` select · `Enter` open folder · `Backspace` parent ·
|
||||||
`Ctrl/Cmd+A` select all · `F5` send to other panel · `F2` rename ·
|
`Ctrl/Cmd+A` select all · `F5` send to other panel · `F2` rename ·
|
||||||
`F7` new folder · `F8`/`Delete` delete · `Cmd/Ctrl+T` new tab ·
|
`F7` new folder · `F8`/`Delete` delete · `Cmd/Ctrl+T` new tab ·
|
||||||
`Cmd/Ctrl+W` close tab · `Ctrl+Tab` next tab.
|
`Cmd/Ctrl+W` close tab · `Ctrl+Tab` next tab · `Cmd/Ctrl+,` settings ·
|
||||||
|
`Cmd/Ctrl+=`/`-`/`0` interface bigger / smaller / 100%.
|
||||||
|
|
||||||
(On Macs, function keys may need `fn` — every action also has a toolbar button.)
|
(On Macs, function keys may need `fn` — every action also has a toolbar button.)
|
||||||
|
|
||||||
@@ -210,6 +221,12 @@ shell-integration wording, drive listing, and the connections-file ACL. The
|
|||||||
application menu ([src/bun/menu.ts](src/bun/menu.ts)) is macOS-only for now;
|
application menu ([src/bun/menu.ts](src/bun/menu.ts)) is macOS-only for now;
|
||||||
the in-app UI covers every menu action.
|
the in-app UI covers every menu action.
|
||||||
|
|
||||||
|
Right-click menus are **HTML, on every platform**
|
||||||
|
([ui/src/app/core/context-menu.service.ts](ui/src/app/core/context-menu.service.ts)) rather than native —
|
||||||
|
electrobun's native context menu is a documented no-op on Linux, and one menu
|
||||||
|
implementation beats two that drift. Only reveal / open-with-default / copy-path
|
||||||
|
cross to the backend, through the `shellItemAction` RPC.
|
||||||
|
|
||||||
## Windows (beta)
|
## Windows (beta)
|
||||||
|
|
||||||
Windows 11+ x64 is supported as a **beta** target (win-arm64 deferred). All
|
Windows 11+ x64 is supported as a **beta** target (win-arm64 deferred). All
|
||||||
@@ -217,11 +234,12 @@ electrobun APIs the app uses are implemented on Windows; known issues are
|
|||||||
upstream:
|
upstream:
|
||||||
|
|
||||||
- **HiDPI blur** ([electrobun#324](https://github.com/blackboardsh/electrobun/issues/324)):
|
- **HiDPI blur** ([electrobun#324](https://github.com/blackboardsh/electrobun/issues/324)):
|
||||||
WebView2 renders at 1x on scaled displays until upstream adds DPI awareness.
|
WebView2 rendered at 1x on scaled displays on Electrobun 1.18; 2.x ships
|
||||||
|
per-monitor DPI setup — to re-check on the first 2.x Windows build.
|
||||||
- **Notifications** use legacy balloon style, not Windows toast.
|
- **Notifications** use legacy balloon style, not Windows toast.
|
||||||
- **Unsigned binaries**: both the Setup.exe installer and the portable exe
|
- **Unsigned binaries**: both the Setup.exe installer and the portable exe
|
||||||
trigger SmartScreen/Mark-of-the-Web warnings on first run. No Start Menu
|
trigger SmartScreen/Mark-of-the-Web warnings on first run. The installer
|
||||||
entry or uninstaller (installer unpacks to `%LOCALAPPDATA%`).
|
unpacks to `%LOCALAPPDATA%` and, since Electrobun 2.x, ships an uninstaller.
|
||||||
- `connections.json` is protected with an `icacls` current-user ACL instead of
|
- `connections.json` is protected with an `icacls` current-user ACL instead of
|
||||||
POSIX 0600 (which is meaningless on Windows).
|
POSIX 0600 (which is meaningless on Windows).
|
||||||
|
|
||||||
@@ -231,20 +249,44 @@ drive root ('..' disappears); local↔remote transfers; external editor launch
|
|||||||
renders colored; Permissions absent on local panels; Ctrl+labels in tooltips;
|
renders colored; Permissions absent on local panels; Ctrl+labels in tooltips;
|
||||||
portable zip runs from any folder.
|
portable zip runs from any folder.
|
||||||
|
|
||||||
|
## Linux (beta)
|
||||||
|
|
||||||
|
Linux x64 is supported as a **beta** target and ships Electrobun's own
|
||||||
|
installer (`-Setup.tar.gz`).
|
||||||
|
One hard requirement: the AppIndicator library listed under
|
||||||
|
[Prerequisites](#prerequisites) — electrobun links it for the tray, so without
|
||||||
|
it the app aborts at startup with `ERR_DLOPEN_FAILED` rather than merely losing
|
||||||
|
its tray icon.
|
||||||
|
|
||||||
|
- **No native menus at all** upstream (both the app menubar and context menus
|
||||||
|
are no-ops). The menubar was already macOS-only and the context menus are now
|
||||||
|
HTML, so nothing is missing in practice.
|
||||||
|
- **Tray visibility** depends on the desktop: KDE, XFCE, MATE and Cinnamon show
|
||||||
|
it; GNOME 3.26+ needs the AppIndicator extension. The app works either way.
|
||||||
|
- **Unsigned**, and no AppImage or `.deb` — unpack the `-Setup.tar.gz` and run
|
||||||
|
its `installer`.
|
||||||
|
- **NVIDIA GPUs** need `WEBKIT_DISABLE_DMABUF_RENDERER=1` or the window renders
|
||||||
|
black. The dev scripts set it automatically when an NVIDIA driver is
|
||||||
|
detected; an installed build needs it set by hand (e.g. in its desktop
|
||||||
|
entry) — see [gotchas](docs/gotchas.md#linux).
|
||||||
|
|
||||||
|
Full dependency table, diagnostics and the first-boot smoke test:
|
||||||
|
[platform notes](docs/platform-notes.md).
|
||||||
|
|
||||||
## Known issues
|
## Known issues
|
||||||
|
|
||||||
- **Rare native crash under load.** A long-running session (multiple remote
|
- **Rare native crash under load.** A long-running session (multiple remote
|
||||||
connections, extended uptime) can hit a Bun panic — a segfault inside Bun
|
connections, extended uptime) could hit a Bun panic — a segfault inside Bun
|
||||||
1.3.13's FFI thread-safe callbacks executing Electrobun's native→worker
|
1.3.13's FFI thread-safe callbacks executing Electrobun's native→worker
|
||||||
bridge (`FFI_Callback_threadsafe_call` → `web_worker.spin` in the decoded
|
bridge (`FFI_Callback_threadsafe_call` → `web_worker.spin` in the decoded
|
||||||
crash report). This is an upstream Bun × Electrobun bug, not app code; a
|
crash report). This is an upstream Bun × Electrobun bug, not app code; a
|
||||||
restart recovers cleanly and in-flight work isn't corrupted. Expected to
|
restart recovers cleanly and in-flight work isn't corrupted. Electrobun 2.0.2
|
||||||
resolve when Electrobun bumps its pinned Bun (currently 1.3.13; 1.3.14+
|
pins Bun 1.4.0, which may have fixed it — not yet confirmed under load. Bun's
|
||||||
available). Bun's own crash reporter link is printed to the console on panic.
|
own crash reporter link is printed to the console on panic.
|
||||||
|
|
||||||
## Not in v1
|
## Not in v1
|
||||||
|
|
||||||
Drag-and-drop, file preview/edit, transfer resume, symlink following
|
Drag-and-drop, file preview/edit, transfer resume, symlink following
|
||||||
(symlinks are listed but skipped by transfers), OS keychain, tab persistence
|
(symlinks are listed but skipped by transfers), OS keychain, tab persistence
|
||||||
across restarts, Windows/Linux packaging (the code is portable; only macOS
|
across restarts, Linux AppImage/.deb packages (the release is Electrobun's
|
||||||
packaging is wired up).
|
Setup installer).
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "bunscp",
|
"name": "bunscp",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electrobun": "1.18.1",
|
|
||||||
"ssh-config": "^5.2.0",
|
"ssh-config": "^5.2.0",
|
||||||
"ssh2": "^1.17.0",
|
"ssh2": "^1.17.0",
|
||||||
},
|
},
|
||||||
@@ -13,6 +12,7 @@
|
|||||||
"@resvg/resvg-js": "^2.6.2",
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/ssh2": "^1.15.5",
|
"@types/ssh2": "^1.15.5",
|
||||||
|
"electrobun": "2.0.2",
|
||||||
"png-to-ico": "^3.0.2",
|
"png-to-ico": "^3.0.2",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
},
|
},
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"@angular/core": "^22.0.0",
|
"@angular/core": "^22.0.0",
|
||||||
"@angular/platform-browser": "^22.0.0",
|
"@angular/platform-browser": "^22.0.0",
|
||||||
"@ng-icons/core": "^34.0.0",
|
"@ng-icons/core": "^34.0.0",
|
||||||
"@ng-icons/mynaui": "^34.0.0",
|
"@ng-icons/tdesign-icons": "^34.0.0",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
},
|
},
|
||||||
@@ -133,8 +133,6 @@
|
|||||||
|
|
||||||
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||||
|
|
||||||
"@babylonjs/core": ["@babylonjs/core@7.54.3", "", {}, "sha512-P5ncXVd8GEUJLhwloP9V0oVwQYIrvZztguVeLlvd5Rx+9aQnenKjpV8auJ6SRsUlAmNZU4pFTKzwF6o2EUfhAw=="],
|
|
||||||
|
|
||||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||||
|
|
||||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||||
@@ -253,8 +251,6 @@
|
|||||||
|
|
||||||
"@lmdb/lmdb-win32-x64": ["@lmdb/lmdb-win32-x64@3.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-JF1BmLCm9kGEVZgYmJq43zeQVdHVgAJnTi/NURWEsy6L1ZrrlSmdltS+D17QN4LODwf+1LMXAA9auIZVXtWwzw=="],
|
"@lmdb/lmdb-win32-x64": ["@lmdb/lmdb-win32-x64@3.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-JF1BmLCm9kGEVZgYmJq43zeQVdHVgAJnTi/NURWEsy6L1ZrrlSmdltS+D17QN4LODwf+1LMXAA9auIZVXtWwzw=="],
|
||||||
|
|
||||||
"@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@1.1.1", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ=="],
|
|
||||||
|
|
||||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||||
|
|
||||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
|
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
|
||||||
@@ -307,7 +303,7 @@
|
|||||||
|
|
||||||
"@ng-icons/core": ["@ng-icons/core@34.0.0", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular-devkit/schematics": ">=21.0.0", "@angular/common": ">=21.0.0", "@angular/core": ">=21.0.0", "@schematics/angular": ">=21.0.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-SrL8dBApSKzXhihEGmiJ/tAj+wSvt6XmZJOAzUTVcgkuV0YuL1WCHVfAwXgQHVG5Te27JkjyXd2n76pNFMuekw=="],
|
"@ng-icons/core": ["@ng-icons/core@34.0.0", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular-devkit/schematics": ">=21.0.0", "@angular/common": ">=21.0.0", "@angular/core": ">=21.0.0", "@schematics/angular": ">=21.0.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-SrL8dBApSKzXhihEGmiJ/tAj+wSvt6XmZJOAzUTVcgkuV0YuL1WCHVfAwXgQHVG5Te27JkjyXd2n76pNFMuekw=="],
|
||||||
|
|
||||||
"@ng-icons/mynaui": ["@ng-icons/mynaui@34.0.0", "", { "dependencies": { "tslib": "^2.3.0" } }, "sha512-Wstqby65RIOmlbBzrHfSEb9+HX87GbOHmIfSh3oEV8Wr6vulnOnQ11S8M3aYT5kuuIe9Bq00OzYwbHuqYBCNrw=="],
|
"@ng-icons/tdesign-icons": ["@ng-icons/tdesign-icons@34.0.0", "", { "dependencies": { "tslib": "^2.3.0" } }, "sha512-FIRho+rAX3ludOeIMP+/Tr0Xp3zi3j/JXT5YooGeoMo8AF3qk0gvG4Cu1kI+8V5r3SxoOia9ktPxt0ADKt3LXg=="],
|
||||||
|
|
||||||
"@npmcli/agent": ["@npmcli/agent@4.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^11.2.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg=="],
|
"@npmcli/agent": ["@npmcli/agent@4.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^11.2.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg=="],
|
||||||
|
|
||||||
@@ -475,8 +471,6 @@
|
|||||||
|
|
||||||
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.2", "@tailwindcss/oxide": "4.3.2", "postcss": "^8.5.15", "tailwindcss": "4.3.2" } }, "sha512-rjVWYCa7Ngbi5AarT6k8TkxUG3Wl1QKzHdIZVsjZSzf36Jmo2IKZt/NHRAwly8oDkbBOH0YTu+CHuf9jPxMc+g=="],
|
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.2", "@tailwindcss/oxide": "4.3.2", "postcss": "^8.5.15", "tailwindcss": "4.3.2" } }, "sha512-rjVWYCa7Ngbi5AarT6k8TkxUG3Wl1QKzHdIZVsjZSzf36Jmo2IKZt/NHRAwly8oDkbBOH0YTu+CHuf9jPxMc+g=="],
|
||||||
|
|
||||||
"@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="],
|
|
||||||
|
|
||||||
"@tufjs/canonical-json": ["@tufjs/canonical-json@2.0.0", "", {}, "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA=="],
|
"@tufjs/canonical-json": ["@tufjs/canonical-json@2.0.0", "", {}, "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA=="],
|
||||||
|
|
||||||
"@tufjs/models": ["@tufjs/models@4.1.0", "", { "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^10.1.1" } }, "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww=="],
|
"@tufjs/models": ["@tufjs/models@4.1.0", "", { "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^10.1.1" } }, "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww=="],
|
||||||
@@ -497,7 +491,7 @@
|
|||||||
|
|
||||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||||
|
|
||||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
"agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="],
|
||||||
|
|
||||||
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||||
|
|
||||||
@@ -513,14 +507,10 @@
|
|||||||
|
|
||||||
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
|
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
|
||||||
|
|
||||||
"ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="],
|
|
||||||
|
|
||||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||||
|
|
||||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.42", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q=="],
|
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.42", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q=="],
|
||||||
|
|
||||||
"basic-ftp": ["basic-ftp@5.3.1", "", {}, "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw=="],
|
|
||||||
|
|
||||||
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
|
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
|
||||||
|
|
||||||
"beasties": ["beasties@0.4.2", "", { "dependencies": { "css-select": "^6.0.0", "css-what": "^7.0.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "htmlparser2": "^10.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.49", "postcss-media-query-parser": "^0.2.3", "postcss-safe-parser": "^7.0.1" } }, "sha512-NvcGjG/7AVUAfRbvrJmHunDQS9uHnE6Q/7AkaPr8oKE8HjOlpjRG5075z/th2Tmlezk3VlaaS8+X9I1RwHJMQw=="],
|
"beasties": ["beasties@0.4.2", "", { "dependencies": { "css-select": "^6.0.0", "css-what": "^7.0.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "htmlparser2": "^10.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.49", "postcss-media-query-parser": "^0.2.3", "postcss-safe-parser": "^7.0.1" } }, "sha512-NvcGjG/7AVUAfRbvrJmHunDQS9uHnE6Q/7AkaPr8oKE8HjOlpjRG5075z/th2Tmlezk3VlaaS8+X9I1RwHJMQw=="],
|
||||||
@@ -583,18 +573,12 @@
|
|||||||
|
|
||||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
|
|
||||||
"cross-spawn-windows-exe": ["cross-spawn-windows-exe@1.2.0", "", { "dependencies": { "@malept/cross-spawn-promise": "^1.1.0", "is-wsl": "^2.2.0", "which": "^2.0.2" } }, "sha512-mkLtJJcYbDCxEG7Js6eUnUNndWjyUZwJ3H7bErmmtOYU/Zb99DyUkpamuIZE0b3bhmJyZ7D90uS6f+CGxRRjOw=="],
|
|
||||||
|
|
||||||
"css-select": ["css-select@6.0.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^7.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "nth-check": "^2.1.1" } }, "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw=="],
|
"css-select": ["css-select@6.0.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^7.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "nth-check": "^2.1.1" } }, "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw=="],
|
||||||
|
|
||||||
"css-what": ["css-what@7.0.0", "", {}, "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ=="],
|
"css-what": ["css-what@7.0.0", "", {}, "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ=="],
|
||||||
|
|
||||||
"data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
|
|
||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
"degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="],
|
|
||||||
|
|
||||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
@@ -611,7 +595,7 @@
|
|||||||
|
|
||||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||||
|
|
||||||
"electrobun": ["electrobun@1.18.1", "", { "dependencies": { "@babylonjs/core": "^7.45.0", "@types/bun": "^1.3.8", "png-to-ico": "^2.1.8", "proxy-agent": "^6.5.0", "rcedit": "^4.0.1", "three": "^0.165.0" }, "bin": { "electrobun": "bin/electrobun.cjs" } }, "sha512-tgZ+WKGskn/1/Y5i1mpVCCkgRa1O31Tz7MIArBTK44GfPzT43uOq9c/HvxdQGrLeZV8ZvjK1lQrwqSXCgh6tvA=="],
|
"electrobun": ["electrobun@2.0.2", "", { "bin": { "electrobun": "bin/electrobun.cjs" } }, "sha512-UDs2iTNs6qYCzdWiFch/0DtxrsijNDr1WFDQfRmSA3Iouv0iEzyCF5mh4uDxkwNnAbuwFyow36EiVTYP4DRzig=="],
|
||||||
|
|
||||||
"electron-to-chromium": ["electron-to-chromium@1.5.389", "", {}, "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg=="],
|
"electron-to-chromium": ["electron-to-chromium@1.5.389", "", {}, "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg=="],
|
||||||
|
|
||||||
@@ -639,14 +623,6 @@
|
|||||||
|
|
||||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||||
|
|
||||||
"escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="],
|
|
||||||
|
|
||||||
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
|
||||||
|
|
||||||
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
|
||||||
|
|
||||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
|
||||||
|
|
||||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||||
|
|
||||||
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
|
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
|
||||||
@@ -695,8 +671,6 @@
|
|||||||
|
|
||||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||||
|
|
||||||
"get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="],
|
|
||||||
|
|
||||||
"glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
|
"glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
|
||||||
|
|
||||||
"glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
|
"glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
|
||||||
@@ -737,8 +711,6 @@
|
|||||||
|
|
||||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||||
|
|
||||||
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
|
||||||
|
|
||||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||||
|
|
||||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
|
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
|
||||||
@@ -751,8 +723,6 @@
|
|||||||
|
|
||||||
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
|
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
|
||||||
|
|
||||||
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
|
|
||||||
|
|
||||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||||
@@ -807,7 +777,7 @@
|
|||||||
|
|
||||||
"log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="],
|
"log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="],
|
||||||
|
|
||||||
"lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
|
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
@@ -859,8 +829,6 @@
|
|||||||
|
|
||||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||||
|
|
||||||
"netmask": ["netmask@2.1.1", "", {}, "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA=="],
|
|
||||||
|
|
||||||
"node-addon-api": ["node-addon-api@6.1.0", "", {}, "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA=="],
|
"node-addon-api": ["node-addon-api@6.1.0", "", {}, "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA=="],
|
||||||
|
|
||||||
"node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="],
|
"node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="],
|
||||||
@@ -903,10 +871,6 @@
|
|||||||
|
|
||||||
"p-map": ["p-map@7.0.5", "", {}, "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA=="],
|
"p-map": ["p-map@7.0.5", "", {}, "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA=="],
|
||||||
|
|
||||||
"pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="],
|
|
||||||
|
|
||||||
"pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="],
|
|
||||||
|
|
||||||
"pacote": ["pacote@21.5.1", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/git": "^7.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "@npmcli/run-script": "^10.0.0", "cacache": "^20.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", "npm-package-arg": "^13.0.0", "npm-packlist": "^10.0.1", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "proc-log": "^6.0.0", "sigstore": "^4.0.0", "ssri": "^13.0.0", "tar": "^7.4.3" }, "bin": { "pacote": "bin/index.js" } }, "sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg=="],
|
"pacote": ["pacote@21.5.1", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/git": "^7.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "@npmcli/run-script": "^10.0.0", "cacache": "^20.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", "npm-package-arg": "^13.0.0", "npm-packlist": "^10.0.1", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "proc-log": "^6.0.0", "sigstore": "^4.0.0", "ssri": "^13.0.0", "tar": "^7.4.3" }, "bin": { "pacote": "bin/index.js" } }, "sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg=="],
|
||||||
|
|
||||||
"parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
|
"parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
|
||||||
@@ -945,18 +909,12 @@
|
|||||||
|
|
||||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||||
|
|
||||||
"proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="],
|
|
||||||
|
|
||||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
|
||||||
|
|
||||||
"qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="],
|
"qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="],
|
||||||
|
|
||||||
"range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="],
|
"range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="],
|
||||||
|
|
||||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||||
|
|
||||||
"rcedit": ["rcedit@4.0.1", "", { "dependencies": { "cross-spawn-windows-exe": "^1.1.0" } }, "sha512-bZdaQi34krFWhrDn+O53ccBDw0MkAT2Vhu75SqhtvhQu4OPyFM4RoVheyYiVQYdjhUi6EJMVWQ0tR6bCIYVkUg=="],
|
|
||||||
|
|
||||||
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||||
|
|
||||||
"reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
|
"reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
|
||||||
@@ -1041,8 +999,6 @@
|
|||||||
|
|
||||||
"tar": ["tar@7.5.19", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw=="],
|
"tar": ["tar@7.5.19", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw=="],
|
||||||
|
|
||||||
"three": ["three@0.165.0", "", {}, "sha512-cc96IlVYGydeceu0e5xq70H8/yoVT/tXBxV/W8A/U6uOq7DXc4/s1Mkmnu6SqoYGhSRWWYFOhVwvq6V0VtbplA=="],
|
|
||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||||
|
|
||||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||||
@@ -1105,10 +1061,10 @@
|
|||||||
|
|
||||||
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
|
||||||
|
|
||||||
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
|
"@npmcli/agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||||
|
|
||||||
"@npmcli/agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
"@npmcli/agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||||
|
|
||||||
"@npmcli/agent/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
|
"@npmcli/agent/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
|
||||||
@@ -1145,18 +1101,18 @@
|
|||||||
|
|
||||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||||
|
|
||||||
"electrobun/png-to-ico": ["png-to-ico@2.1.8", "", { "dependencies": { "@types/node": "^17.0.36", "minimist": "^1.2.6", "pngjs": "^6.0.0" }, "bin": { "png-to-ico": "bin/cli.js" } }, "sha512-Nf+IIn/cZ/DIZVdGveJp86NG5uNib1ZXMiDd/8x32HCTeKSvgpyg6D/6tUBn1QO/zybzoMK0/mc3QRgAyXdv9w=="],
|
|
||||||
|
|
||||||
"hosted-git-info/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
|
"hosted-git-info/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
|
||||||
|
|
||||||
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||||
|
|
||||||
"https-proxy-agent/agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="],
|
"http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||||
|
|
||||||
"log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
|
"log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
|
||||||
|
|
||||||
"log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
"log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
||||||
|
|
||||||
|
"lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
|
|
||||||
"minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
"minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||||
|
|
||||||
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||||
@@ -1165,16 +1121,14 @@
|
|||||||
|
|
||||||
"ora/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
|
"ora/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
|
||||||
|
|
||||||
"pac-proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
|
||||||
|
|
||||||
"path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
|
"path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
|
||||||
|
|
||||||
"png-to-ico/@types/node": ["@types/node@25.9.5", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg=="],
|
"png-to-ico/@types/node": ["@types/node@25.9.5", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg=="],
|
||||||
|
|
||||||
"proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
|
||||||
|
|
||||||
"sass/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
"sass/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||||
|
|
||||||
|
"socks-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||||
|
|
||||||
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||||
|
|
||||||
"ui/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
"ui/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||||
@@ -1187,16 +1141,10 @@
|
|||||||
|
|
||||||
"@angular/compiler-cli/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"@angular/compiler-cli/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
|
||||||
|
|
||||||
"@npmcli/git/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
|
"@npmcli/git/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
|
||||||
|
|
||||||
"@npmcli/promise-spawn/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
|
"@npmcli/promise-spawn/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
|
||||||
|
|
||||||
"electrobun/png-to-ico/@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="],
|
|
||||||
|
|
||||||
"electrobun/png-to-ico/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
|
|
||||||
|
|
||||||
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||||
|
|
||||||
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||||
|
|||||||
+163
-62
@@ -6,14 +6,21 @@ Dense reference for development and debugging. Companion files:
|
|||||||
|
|
||||||
## Process model
|
## Process model
|
||||||
|
|
||||||
- **Bun main process** (`src/bun/`): electrobun runs it inside a WebWorker of
|
- **Bun main process** (`src/bun/`): the native launcher runs electrobun's
|
||||||
its native launcher. Owns every privileged capability: filesystem, SSH,
|
`main.js` on the Bun the 2.x devkit pins (1.4.0, shipped in the bundle —
|
||||||
window management, native menus/dialogs/tray, the connection/settings stores.
|
`build.mainProcess: "bun"`), which starts our bundle in a Worker. Imports the
|
||||||
|
SDK as `electrobun/main` (projected into `.hutch/devkit/`). Owns every
|
||||||
|
privileged capability: filesystem, SSH, window management, native
|
||||||
|
menus/dialogs/tray, the connection/settings stores.
|
||||||
- **Webviews** (`ui/` Angular): one per window, system WebKit on macOS /
|
- **Webviews** (`ui/` Angular): one per window, system WebKit on macOS /
|
||||||
WebView2 on Windows. Zero privileged access — everything goes through RPC.
|
WebView2 on Windows / WebKitGTK on Linux. Zero privileged access — everything
|
||||||
|
goes through RPC.
|
||||||
- **RPC transport**: one WebSocket server in the bun process, multiplexed by
|
- **RPC transport**: one WebSocket server in the bun process, multiplexed by
|
||||||
`webviewId` (each webview auto-connects using natively injected globals).
|
`webviewId` (each webview auto-connects using natively injected globals;
|
||||||
Requests are typed by the `AppRPC` schema in `src/shared/rpc.ts`.
|
packets AES-GCM-encrypted per webview). 2.x Electroview queues sends while
|
||||||
|
its `hostSocket` connects and falls back to a native bridge when the socket
|
||||||
|
is unavailable. Requests are typed by the `AppRPC` schema in
|
||||||
|
`src/shared/rpc.ts`.
|
||||||
|
|
||||||
### The one-schema rule
|
### The one-schema rule
|
||||||
|
|
||||||
@@ -29,8 +36,8 @@ Each `BrowserWindow` MUST receive its own `defineRPC(...)` instance: binding a
|
|||||||
BrowserView calls `rpc.setTransport(...)`, so a shared instance would have its
|
BrowserView calls `rpc.setTransport(...)`, so a shared instance would have its
|
||||||
transport stolen by the most recent window. `createAppRPC` (main),
|
transport stolen by the most recent window. `createAppRPC` (main),
|
||||||
`createEditorRPC` (per editor window, hooks close over that window's record),
|
`createEditorRPC` (per editor window, hooks close over that window's record),
|
||||||
`createManagerRPC` (the single manager window). `rpc.send.*` on an instance
|
`createManagerRPC` (the single manager window), `createSettingsRPC` (the single
|
||||||
targets exactly its own webview.
|
Settings window). `rpc.send.*` on an instance targets exactly its own webview.
|
||||||
|
|
||||||
### The `events` late-binding pattern
|
### The `events` late-binding pattern
|
||||||
|
|
||||||
@@ -38,16 +45,20 @@ targets exactly its own webview.
|
|||||||
exist until after construction). `AppDeps.events` is a mutable object of
|
exist until after construction). `AppDeps.events` is a mutable object of
|
||||||
no-op functions, reassigned in `src/bun/index.ts` after everything exists:
|
no-op functions, reassigned in `src/bun/index.ts` after everything exists:
|
||||||
`sessionClosed`, `connectionsChanged`, `openEditor`, `settingsChanged`,
|
`sessionClosed`, `connectionsChanged`, `openEditor`, `settingsChanged`,
|
||||||
`openConnectionManager`, `showAbout`.
|
`openConnectionManager`, `openSettings`, `showAbout`, `applyUpdate` (the
|
||||||
|
update restart lives in index.ts beside the before-quit guard it must get past —
|
||||||
|
see build-and-release.md "Auto-updater").
|
||||||
|
|
||||||
## Window kinds
|
## Window kinds
|
||||||
|
|
||||||
`src/bun/windows.ts` — `buildPreload(mode, ctx?)` injects `__APP_PLATFORM`,
|
`src/bun/windows.ts` — `buildPreload(mode, settings, ctx?)` injects
|
||||||
`__APP_MODE` (`main|editor|manager`), `__APP_CTX` before any page script.
|
`__APP_PLATFORM`, `__APP_MODE` (`main|editor|manager|settings`), `__APP_CTX`
|
||||||
`ui/src/main.ts` picks the root component from `APP_MODE`; all three roots use
|
before any page script, plus the persisted theme attributes and interface
|
||||||
selector `app-root` (index.html hosts only that tag). `openSecondaryWindow` is
|
scale so the first paint is already right. `ui/src/main.ts` picks the root
|
||||||
the ONLY place that branches platform for secondary chrome: macOS
|
component from `APP_MODE`; all four roots use selector `app-root` (index.html
|
||||||
`hiddenInset` + `trafficLightOffset {x:12,y:13}` (the UI renders an
|
hosts only that tag). `chromeOptions()` is the ONLY place the native frame is
|
||||||
|
decided, spread by the main window and `openSecondaryWindow`: macOS `hiddenInset` +
|
||||||
|
`trafficLightOffset {x:12,y:4}` (2.x semantics — see gotchas; the UI renders an
|
||||||
`h-10 pl-24` drag-region header via `IS_MAC_CHROME`); other platforms keep the
|
`h-10 pl-24` drag-region header via `IS_MAC_CHROME`); other platforms keep the
|
||||||
native titlebar and a compact `h-9` non-drag header.
|
native titlebar and a compact `h-9` non-drag header.
|
||||||
|
|
||||||
@@ -57,15 +68,16 @@ has **no `off()`** — `track()` closures capture only `(this, key)` so closed
|
|||||||
records can be garbage collected. A future "window follows window" grouping
|
records can be garbage collected. A future "window follows window" grouping
|
||||||
belongs here (`anchor.on('move')` + `setPosition`).
|
belongs here (`anchor.on('move')` + `setPosition`).
|
||||||
|
|
||||||
### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/editor-window/`)
|
### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/skins/shadlike/editor-window/`)
|
||||||
|
|
||||||
One window per file, key `sessionId+path` (sessionId, not fsKey: saves route
|
One window per file, key `sessionId+path` (sessionId, not fsKey: saves route
|
||||||
through a concrete session, and `local` is one shared session anyway).
|
through a concrete session, and `local` is one shared session anyway).
|
||||||
Re-opening focuses the existing window. The webview fetches its own content
|
Re-opening focuses the existing window. The webview fetches its own content
|
||||||
(`readFile`) — `openEditor` just creates the window with ctx in the preload.
|
(`readFile`) — `openEditor` just creates the window with ctx in the preload.
|
||||||
|
|
||||||
**Close safety** (electrobun window `close` is NOT cancellable — verified, no
|
**Close safety** (designed when electrobun window `close` was NOT cancellable —
|
||||||
`allow` response like `before-quit` has):
|
1.18.1 had no `allow` response like `before-quit`; 2.x adds a cancellable
|
||||||
|
`will-close` for the titlebar close, not used yet — see gotchas):
|
||||||
1. ⌘W (routed via the app menu → `editorCloseRequest`) / Close button →
|
1. ⌘W (routed via the app menu → `editorCloseRequest`) / Close button →
|
||||||
styled in-window Save/Don't Save/Cancel; the confirm path calls
|
styled in-window Save/Don't Save/Cancel; the confirm path calls
|
||||||
`editorClose` which sets `suppressPrompt` before `win.close()`.
|
`editorClose` which sets `suppressPrompt` before `win.close()`.
|
||||||
@@ -89,7 +101,9 @@ not `findInPage`, which is a shim on WebView2).
|
|||||||
### Connection-manager window (`src/bun/manager-window.ts`)
|
### Connection-manager window (`src/bun/manager-window.ts`)
|
||||||
|
|
||||||
Single instance, centered over main, **modal to main only** (editors stay
|
Single instance, centered over main, **modal to main only** (editors stay
|
||||||
usable). No native modality exists, so three layers approximate it: bun
|
usable). No native modality exists (re-checked on 2.0.2: no parent/owner/sheet
|
||||||
|
option or input lock in any SDK or the core's exports; `styleMask.DocModalWindow`
|
||||||
|
is only the cosmetic NSWindow bit), so three layers approximate it: bun
|
||||||
bounces main's `focus` event back to the manager; the main webview renders a
|
bounces main's `focus` event back to the manager; the main webview renders a
|
||||||
click-swallowing overlay (`managerState` message); main drops its keyboard
|
click-swallowing overlay (`managerState` message); main drops its keyboard
|
||||||
shortcuts and bun drops main-targeted menu actions while open. Connect /
|
shortcuts and bun drops main-targeted menu actions while open. Connect /
|
||||||
@@ -100,6 +114,20 @@ main's `ConnectionsService` reloads; a reload-retry in `App.findConnection`
|
|||||||
covers the save→connect race. `setAlwaysOnTop` is deliberately NOT used (it
|
covers the save→connect race. `setAlwaysOnTop` is deliberately NOT used (it
|
||||||
would float over editor windows).
|
would float over editor windows).
|
||||||
|
|
||||||
|
### 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 Ctrl+, (`openSettings` from main) and, on macOS, the app
|
||||||
|
menu's "Settings…" (⌘,, which the menu intercepts first). Its RPC serves only
|
||||||
|
`getSettings`/`saveSettings`/`pickEditorApp`/`showAbout`. ⌘W reaches bun as
|
||||||
|
the main window's `close-tab` menu action, so bun checks the Settings window's
|
||||||
|
focus FIRST and closes it (see gotchas). Sidebar sections: Appearance,
|
||||||
|
Transfers, Editor, File list (visible columns — the column-header right-click
|
||||||
|
menu toggles the same setting), About (opens the native About box). The
|
||||||
|
native title is `options.title`, retitled on a locale switch.
|
||||||
|
|
||||||
## Bun modules
|
## Bun modules
|
||||||
|
|
||||||
- `fs/provider.ts` — `FileSystemProvider` interface: uniform local/SFTP ops +
|
- `fs/provider.ts` — `FileSystemProvider` interface: uniform local/SFTP ops +
|
||||||
@@ -148,20 +176,18 @@ would float over editor windows).
|
|||||||
- `store/connections.ts` — plaintext-passwords JSON (deliberate v1 choice),
|
- `store/connections.ts` — plaintext-passwords JSON (deliberate v1 choice),
|
||||||
atomic tmp+rename, 0600 + win32 `icacls` ACL. `store/settings.ts` — defaults
|
atomic tmp+rename, 0600 + win32 `icacls` ACL. `store/settings.ts` — defaults
|
||||||
spread-merge on load, so NEW SETTINGS KEYS need no backend change.
|
spread-merge on load, so NEW SETTINGS KEYS need no backend change.
|
||||||
- `context-menu.ts` — native row/empty-space menus + the column picker.
|
|
||||||
Local shell actions (reveal/open/copy-path) run bun-side via Utils; others
|
|
||||||
forward through `menuAction` and the UI applies them to the row it
|
|
||||||
remembered (`FileActionsService.menuTarget`). Labels are per-platform.
|
|
||||||
- `menu.ts` — macOS-only app menu, rebuilt on every connections change (the
|
- `menu.ts` — macOS-only app menu, rebuilt on every connections change (the
|
||||||
Connect submenu mirrors the store). ⌘W is intercepted by the menu, so bun
|
Connect submenu mirrors the store; "Settings…" carries ⌘,). ⌘W is intercepted
|
||||||
routes `close-tab` to the focused editor window first. Labels go through
|
by the menu, so bun routes `close-tab` to a focused Settings or editor window
|
||||||
|
first. Labels go through
|
||||||
backend `i18n.t` and the menu is rebuilt on a locale change too.
|
backend `i18n.t` and the menu is rebuilt on a locale change too.
|
||||||
- `i18n.ts` — backend translator (native menus + thrown error messages, which
|
- `i18n.ts` — backend translator (native menus + thrown error messages, which
|
||||||
no Angular service can reach). `setLocale(setting)` caches the resolved
|
no Angular service can reach). `setLocale(setting)` caches the resolved
|
||||||
locale (from `settings.locale`, `system` via `Intl…resolvedOptions().locale`);
|
locale (from `settings.locale`, `system` via `Intl…resolvedOptions().locale`);
|
||||||
`t(key, params)` shares the same `src/shared/i18n` dictionaries as the UI.
|
`t(key, params)` shares the same `src/shared/i18n` dictionaries as the UI.
|
||||||
Wired in index.ts on boot and on `settingsChanged` (relocalizes + rebuilds
|
Wired in index.ts on boot and on `settingsChanged` (relocalizes + rebuilds
|
||||||
the app menu). `context-menu.ts` labels resolve through `t()` at show-time.
|
the app menu). The context menu no longer needs this — it is Angular and reads
|
||||||
|
`I18nService` directly.
|
||||||
- `tray.ts` — static icon, click = activate main. macOS template image;
|
- `tray.ts` — static icon, click = activate main. macOS template image;
|
||||||
Windows colored icon. No `setImage` calls ever (they lose the template
|
Windows colored icon. No `setImage` calls ever (they lose the template
|
||||||
treatment) and no menu (clicks arrive directly).
|
treatment) and no menu (clicks arrive directly).
|
||||||
@@ -169,14 +195,33 @@ would float over editor windows).
|
|||||||
rpc; manager/editors constructed after win; quit guard; focus tracking for
|
rpc; manager/editors constructed after win; quit guard; focus tracking for
|
||||||
notification suppression across all windows).
|
notification suppression across all windows).
|
||||||
|
|
||||||
## UI services (`ui/src/app/`)
|
## UI layout
|
||||||
|
|
||||||
|
```
|
||||||
|
ui/src/app/
|
||||||
|
core/ app.config.ts, icons.ts, platform.ts, format.ts, file-icons.ts,
|
||||||
|
metrics.ts and every *.service.ts — the non-visual layer
|
||||||
|
skins/shadlike/ the component tree: four window roots (SHAD_ROOTS barrel,
|
||||||
|
picked by main.ts), dialogs, context menu
|
||||||
|
```
|
||||||
|
|
||||||
|
Components reach `core/` through the **`@core/*`** tsconfig alias (alongside
|
||||||
|
`@shared/*` → `src/shared`), which is deliberate: components sit at varying
|
||||||
|
depths and an alias keeps their imports identical wherever a file moves. Do not
|
||||||
|
replace it with relative paths.
|
||||||
|
|
||||||
|
**Where a new rule goes**: behaviour → `core/`; markup and appearance → the
|
||||||
|
component; tokens → `ui/src/styles.css`.
|
||||||
|
|
||||||
|
## UI services (`ui/src/app/core/`)
|
||||||
|
|
||||||
- `rpc.service.ts` — wraps `Electroview.defineRPC`. **Push messages fan out
|
- `rpc.service.ts` — wraps `Electroview.defineRPC`. **Push messages fan out
|
||||||
through plain callback registries, never signals**: a signal consumed from
|
through plain callback registries, never signals**: a signal consumed from
|
||||||
an effect that also writes signals re-triggers itself (this froze the whole
|
an effect that also writes signals re-triggers itself (this froze the whole
|
||||||
app once), and signal coalescing drops progress snapshots. All requests are
|
app once), and signal coalescing drops progress snapshots. All requests are
|
||||||
gated on the socket-open promise — calls fired during boot would otherwise
|
gated on the socket-open promise (`electroview.hostSocket`) — on 1.18.1 calls
|
||||||
be silently lost. `request` and `send` are ready-gated Proxies.
|
fired during boot were silently lost; 2.x Electroview queues them itself, so
|
||||||
|
the gate is belt-and-braces now. `request` and `send` are ready-gated Proxies.
|
||||||
- `panels.service.ts` — `PanelState` (per panel): sessionId, fsKey, cwd,
|
- `panels.service.ts` — `PanelState` (per panel): sessionId, fsKey, cwd,
|
||||||
entries, sort, cursor, selection, type-to-filter. `sorted` computed:
|
entries, sort, cursor, selection, type-to-filter. `sorted` computed:
|
||||||
filter → dirs-first sort (name/size/mtime numeric, kind/rights/owner/group
|
filter → dirs-first sort (name/size/mtime numeric, kind/rights/owner/group
|
||||||
@@ -199,11 +244,15 @@ would float over editor windows).
|
|||||||
enqueue and runs the collision flow (same-path auto-skip, type-mismatch
|
enqueue and runs the collision flow (same-path auto-skip, type-mismatch
|
||||||
skip, overwrite dialog with all/none batch modes).
|
skip, overwrite dialog with all/none batch modes).
|
||||||
- `settings.service.ts` — one signal per setting; **imperative apply pattern**
|
- `settings.service.ts` — one signal per setting; **imperative apply pattern**
|
||||||
(`applyTheme/DarkTone/Accent/TextSize` set `data-*` attributes on
|
(`applyTheme/DarkTone` set `data-*` attributes on `<html>`;
|
||||||
`<html>`); `applyAll` also serves the `settingsChanged` broadcast so editor
|
`applyUiScale` sets its inline `font-size`); `applyAll` also serves the
|
||||||
and manager windows restyle live (they render no settings UI, so no
|
`settingsChanged` broadcast, so every window follows a save made in any
|
||||||
persist echo). New setting = five touches here + types.ts (backend merges
|
other. Every window RPC serves `saveSettings` (editor and manager windows
|
||||||
automatically).
|
save too — the scale shortcuts — and the Settings window is nothing but
|
||||||
|
saves); bun's `settingsSaved()` in index.ts runs the
|
||||||
|
side effects once and rebroadcasts to every window **except the sender**, so
|
||||||
|
there is no echo to fight a held Cmd+. `applyAll` never persists. New setting
|
||||||
|
= five touches here + types.ts (backend merges automatically).
|
||||||
- `focus.service.ts` — dependency-free `tick`/`bump()`; dialogs bump it, the
|
- `focus.service.ts` — dependency-free `tick`/`bump()`; dialogs bump it, the
|
||||||
main window's panels listen. Exists so dialogs don't inject PanelsService
|
main window's panels listen. Exists so dialogs don't inject PanelsService
|
||||||
(whose constructor spawns a workspace — fatal in secondary windows).
|
(whose constructor spawns a workspace — fatal in secondary windows).
|
||||||
@@ -213,19 +262,45 @@ would float over editor windows).
|
|||||||
(dotted lookup, `{param}` interpolation, `{one,other}` plural maps via
|
(dotted lookup, `{param}` interpolation, `{one,other}` plural maps via
|
||||||
`Intl.PluralRules`); missing keys fall back to English then the raw key.
|
`Intl.PluralRules`); missing keys fall back to English then the raw key.
|
||||||
**Reactive by design**: `t()` reads the `locale` signal, so every template
|
**Reactive by design**: `t()` reads the `locale` signal, so every template
|
||||||
binding that calls it re-renders live on a language switch (same call-stack
|
binding that calls it re-renders live on a language switch (call-stack
|
||||||
tracking as `iconClass`) — no pipe. Also exposes locale-aware `date()`/`size()`
|
tracking, see gotchas.md) — no pipe. Also exposes locale-aware `date()`/`size()`
|
||||||
/`speed()` so the file list reformats on switch. `SettingsService.applyLocale`
|
/`speed()` so the file list reformats on switch. `SettingsService.applyLocale`
|
||||||
resolves the `locale` setting (`system` → `navigator.language`) into it, on
|
resolves the `locale` setting (`system` → `navigator.language`) into it, on
|
||||||
boot and on the `settingsChanged` broadcast (so editor/manager windows follow).
|
boot and on the `settingsChanged` broadcast (so editor/manager windows follow).
|
||||||
- `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always
|
- `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always
|
||||||
against an explicit panel; `menuTarget` remembers the right-clicked row.
|
against an explicit panel; `menuTarget` remembers the right-clicked row.
|
||||||
|
- `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,
|
||||||
|
and the header column picker) and holds the open request as a signal; the
|
||||||
|
component renders it at the pointer with edge-flip clamping, arrow/Enter/Escape
|
||||||
|
keys, and close-on-blur/resize/scroll. Picked items are emitted verbatim as
|
||||||
|
`ctx-*` / `col:*` / `tab:*` strings through `RpcService.emitMenuAction`, so they
|
||||||
|
land in the SAME `App.handleMenuAction` dispatcher the macOS app menubar uses —
|
||||||
|
one handler, two sources. The three shell actions the webview cannot perform
|
||||||
|
(reveal, open-with-default, copy path) go back to bun via the `shellItemAction`
|
||||||
|
RPC, which is all that remains of the old native menu.
|
||||||
|
`tabItems()` builds the workspace-tab menu (`tab:new`, and
|
||||||
|
`tab:close|close-others|close-right|disconnect:<id>`), served by
|
||||||
|
`PanelsService.closeOtherWorkspaces` / `closeWorkspacesToRight` /
|
||||||
|
`disconnectWorkspace`. **The target id travels inside the action string**: the
|
||||||
|
right-clicked tab is not necessarily the active one, so nothing on that path
|
||||||
|
may fall back to `activeWorkspaceId`. Illegal entries are `disabled`, not
|
||||||
|
omitted — a menu whose items move between right-clicks is worse than one with
|
||||||
|
a dead row. The disabled styling (`pointer-events: none`) only stops the
|
||||||
|
pointer; the keyboard path never consults it — so `pick()` and the arrow-key
|
||||||
|
walker check the flag themselves.
|
||||||
- `file-icons.ts` — extension → category map driving glyphs, colors
|
- `file-icons.ts` — extension → category map driving glyphs, colors
|
||||||
(fixed `--icon-*` tokens, never accent-tinted) and the Kind column.
|
(fixed `--icon-*` tokens from Oxide's hue scales) and the Kind column.
|
||||||
|
- `icons.ts` — the one icon registry (TDesign, `@ng-icons/tdesign-icons`), passed to
|
||||||
|
`provideIcons` in `app.config.ts`. Icon names are plain strings in templates and
|
||||||
|
the build never checks them: an unregistered name renders nothing (console error
|
||||||
|
only), so every name used anywhere must be listed here.
|
||||||
- `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`,
|
- `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`,
|
||||||
`EDITOR_CONTEXT`, `IS_MAC_CHROME`, `IS_WINDOWS`, `MOD_LABEL`/`ALT_LABEL`)
|
`EDITOR_CONTEXT`, `IS_MAC`/`IS_MAC_CHROME`, `IS_WINDOWS`, `IS_LINUX`,
|
||||||
+ `blockNativeContextMenu` (kills WebKit's Reload/Inspect menu outside
|
`MOD_LABEL`/`ALT_LABEL`) + `blockNativeContextMenu` (kills WebKit's
|
||||||
text fields).
|
Reload/Inspect menu outside text fields — the app draws its own menu instead).
|
||||||
- `updater.service.ts` — auto-updater state for the tab-bar pill. Signals
|
- `updater.service.ts` — auto-updater state for the tab-bar pill. Signals
|
||||||
`state` (`idle→available→downloading→ready`|`error`), `version`, `progress`.
|
`state` (`idle→available→downloading→ready`|`error`), `version`, `progress`.
|
||||||
Subscribes the `updateStatus` push (backend `Updater.checkForUpdate` on
|
Subscribes the `updateStatus` push (backend `Updater.checkForUpdate` on
|
||||||
@@ -237,26 +312,52 @@ would float over editor windows).
|
|||||||
|
|
||||||
## Theming
|
## Theming
|
||||||
|
|
||||||
`ui/src/styles.css`: shadcn-style OKLCH tokens; dark values are duplicated in
|
`ui/src/styles.css`: OKLCH tokens adapted from **Oxide's design system**
|
||||||
`:root[data-theme='dark']` AND a `prefers-color-scheme` media block (system
|
(docs.oxide.computer: a cool neutral scale at hue 260 with almost no chroma,
|
||||||
mode) — every dark token change goes in BOTH. Light is the zinc family; the
|
one green accent, opaque strokes; the comments carry Oxide's step names
|
||||||
dark neutrals are a **warm gray family (hue 107)** anchored on `#1a1a19`
|
n0…n1300 / green-800). Dark values are duplicated in `:root[data-theme='dark']`
|
||||||
(`--background`) — retoning one dark neutral means retoning them all, or warm
|
AND a `prefers-color-scheme` media block (system mode) — every dark token
|
||||||
and cool surfaces clash. Surface elevation ramp, both themes: `--chrome`
|
change goes in BOTH; retone neutrals along the one scale, or warm and cool
|
||||||
(frame strips) sits *below* `--background` (content panes) < `--card`
|
surfaces clash. **There is no accent setting**: `--primary` is Oxide green per
|
||||||
(popovers/dialogs) < `--accent` (hover). Dark ramp `#151514` → `#1a1a19` →
|
theme (green-800 dark, green-500 light), so selection (`bg-primary/15`), the
|
||||||
`#20201f` → `#2e2e2d`. The "darker" tone is true-black AMOLED: `--chrome` AND
|
cursor row (`--cursor-tint`, a `color-mix` of `--primary`), focus rings
|
||||||
`--background` are both `#000` (the frame/content step collapses on purpose),
|
(green-600) and `--success` all read green. **Surface layers**, each its own
|
||||||
elevation survives only via `--card`/`--accent`, and `--border`/`--input` go to
|
shade: `--chrome` (frame — tab/transfer/status bars, the editor/manager/settings
|
||||||
16%/18% white because those lines then carry the whole layout.
|
header+footer strips and sidebars), the file panel's header strip **stepping
|
||||||
`bg-chrome` is on the tab bar, status bar, transfer bar, the editor/manager
|
toward the list** — session strip `--toolbar`, path bar `--toolbar-2`, column
|
||||||
header+footer strips and the manager's connection sidebar; panel-internal
|
headers `--toolbar-3` (`color-mix` steps declared once in `:root`, so every
|
||||||
toolbars deliberately stay on `--background`. Accent tones: one
|
theme follows) — then `--background` (file lists, editor text, forms;
|
||||||
`--tone-h/--tone-c` line per tone + three formula blocks derive `--primary`,
|
`--row-alt` stripes odd file rows, skipped on the cursor/selected row so two
|
||||||
`--ring`, `--cursor-tint`; graphite = attribute removed = pristine zinc.
|
`bg-*` utilities never fight) and `--card` (popovers, dialogs, toasts). The
|
||||||
|
right-click menu is the exception: the darkest surface (`bg-chrome`) behind a
|
||||||
|
faint `border-border/60`, and its highlighted item takes Oxide's selected style —
|
||||||
|
`--primary-surface` fill (Oxide surface-accent: green-200 dark, green-1200
|
||||||
|
light) with `text-primary`; mouse and keyboard share that one highlight
|
||||||
|
(`activeIndex`). The
|
||||||
|
ACTIVE panel is marked by a 0.125rem `--primary` line along its top edge (an
|
||||||
|
inset box-shadow on the session strip, so switching panels never shifts
|
||||||
|
layout). Dark: frame 0.162 < list 0.195 < header 0.275 → 0.248 → 0.221 < card
|
||||||
|
0.29; light steps greys out from near-white content: frame 0.925 < header 0.95
|
||||||
|
→ 0.965 → 0.98 < list = card 0.995. **`--accent` is translucent** (n0 at 5%
|
||||||
|
light, white 7% dark): hover/selected states sit on every layer, and an opaque
|
||||||
|
hover vanishes on a surface of its own lightness. Strokes are opaque
|
||||||
|
(`--border` n300 dark / n900 light, `--input` one step stronger). The
|
||||||
|
"darker" tone is true-black AMOLED: chrome, background, toolbar (+ its steps)
|
||||||
|
and row-alt are all `#000` (the layers collapse on purpose); elevation
|
||||||
|
survives only via `--card`/`--muted`/`--accent` (opaque there). **Square
|
||||||
|
design**: no `rounded-*` utilities and no radius tokens; an `@layer base`
|
||||||
|
block restyles native controls to match — checkboxes/radios (`appearance:
|
||||||
|
none`, a drawn check / square dot), selects (a `currentColor` caret; use
|
||||||
|
`pl-*`, never `px-*`, on a select), hidden number spinners, and square
|
||||||
|
`::-webkit-scrollbar`s (which make macOS scrollbars always visible).
|
||||||
`@theme inline` maps tokens to Tailwind utilities (`bg-primary`,
|
`@theme inline` maps tokens to Tailwind utilities (`bg-primary`,
|
||||||
`text-icon-folder`, `bg-cursor-tint`). Selected rows are `bg-primary/15`
|
`text-icon-folder`, `bg-toolbar-2`, `bg-cursor-tint`). File-type icon colors
|
||||||
(follow the accent); the cursor row uses `--cursor-tint` so graphite stays
|
come from Oxide's hue scales (800 steps dark, 600 light). **Interface scale** (`settings.uiScale`,
|
||||||
pixel-identical to the old neutral hover. Text-size levels set `--row-h`,
|
0.5–4 in 0.1 steps): one root font-size lever — `applyUiScale` sets
|
||||||
`--list-font` etc. per `data-text` attribute, consumed via `h-(--row-h)` /
|
`html { font-size: 16px * scale }`, and since EVERY size in the app is rem
|
||||||
`text-(length:--list-font)` arbitrary utilities.
|
(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.
|
||||||
|
|||||||
+119
-74
@@ -10,74 +10,105 @@ electrobun}`). It is regenerated by `postinstall` and at the start of every
|
|||||||
records, hence gitignored. Fresh checkouts fail `tsc` until `bun install` has
|
records, hence gitignored. Fresh checkouts fail `tsc` until `bun install` has
|
||||||
run once (postinstall creates the file). `BRANDING.version` stays
|
run once (postinstall creates the file). `BRANDING.version` stays
|
||||||
semver-shaped (`0.1.<n>`) for electrobun's config/updater; `BRANDING.buildLabel`
|
semver-shaped (`0.1.<n>`) for electrobun's config/updater; `BRANDING.buildLabel`
|
||||||
("Build n") is what humans see (About box, options popover).
|
("Build n") is what humans see (About box, Settings → About).
|
||||||
|
|
||||||
## Dev loop
|
## Dev loop
|
||||||
|
|
||||||
- `bun run dev` — gen-version → scripts/dev.ts: starts `ng serve` (HMR) and
|
- `bun run dev` — gen-version → `electrobun prepare` → scripts/dev.ts: starts
|
||||||
`electrobun dev --watch`, with `DEV_SERVER_URL` pointing the webviews at the
|
`ng serve` (HMR) and `electrobun dev --watch`, with `DEV_SERVER_URL` pointing
|
||||||
dev server (only honored on the dev channel and only if the server answers).
|
the webviews at the dev server (only honored on the dev channel and only if
|
||||||
- `bun run build` — gen-version → Angular build → `electrobun build`
|
the server answers).
|
||||||
(**dev channel** — fine for boot checks, NOT a release).
|
- `bun run build` — gen-version → `electrobun prepare` → Angular build →
|
||||||
|
`electrobun build` (**dev channel** — fine for boot checks, NOT a release).
|
||||||
- `scripts/node-version-shim.ts` (preloaded via `bunfig.toml`, root AND ui/)
|
- `scripts/node-version-shim.ts` (preloaded via `bunfig.toml`, root AND ui/)
|
||||||
fakes a Node version the Angular CLI accepts under Bun. Both preloads are
|
fakes a Node version the Angular CLI accepts under Bun. Both preloads are
|
||||||
load-bearing.
|
load-bearing.
|
||||||
- `scripts/fix-electrobun-cli.ts` re-signs electrobun's prebuilt CLI binary,
|
- **Electrobun 2.x is driven by Hutch.** The `electrobun` devDependency (exact
|
||||||
whose upstream ad-hoc signature is invalid — macOS SIGKILLs it and the app
|
`2.0.2`) is only a bootstrap: its first command downloads the paired Hutch
|
||||||
silently never launches, with exit code 0 (see [gotchas.md](gotchas.md)). It
|
(0.27.1) from the Electrobun GitHub release, checks its SHA-256 against
|
||||||
runs from `postinstall` **and** at the start of `dev`/`start`/`build`/
|
`hutch-artifacts.json`, caches it under `~/.hutch/npm/electrobun/<ver>/`, and
|
||||||
`release`, because the CLI is downloaded lazily on first use rather than at
|
forwards every `electrobun …` to `hutch electrobun …`. Hutch then fetches the
|
||||||
install time; when the binary is missing it provokes that download first, so
|
Electrobun core, its build-time Cottontail and the pinned Bun 1.4.0 (the
|
||||||
a fresh checkout self-heals. Equally load-bearing — removing it means
|
runtime the app ships) into `~/.hutch` (~200 MB on a fresh machine) and
|
||||||
`bun run start` does nothing at all.
|
projects the SDK into the gitignored `.hutch/devkit/` (plus a gitignored
|
||||||
|
`.cottontail-tmp/` scratch dir). The devDependency version IS the toolchain
|
||||||
|
pin: moving Electrobun = bump it + `bun install`. No Node needed — the
|
||||||
|
bootstrap's `#!/usr/bin/env node` runs under `bunx`.
|
||||||
|
- `bunx electrobun prepare` (re)projects `.hutch/devkit`. It runs from
|
||||||
|
`postinstall` **and** at the start of `dev`/`start`/`build`/`release` (~0.5 s
|
||||||
|
when current — the slot the v1 CLI re-sign step held; that step is gone,
|
||||||
|
Hutch's binaries carry valid signatures). tsc (the root tsconfig extends
|
||||||
|
`.hutch/devkit/tsconfig.json`) and the Angular build / `ng serve` (the
|
||||||
|
`electrobun/view` path in `ui/tsconfig.json`) resolve the SDK there *before*
|
||||||
|
any electrobun command would project it; without it both fail on unresolved
|
||||||
|
`electrobun/*` imports. Bun stays the package manager: Hutch's own resolver
|
||||||
|
(`hutch.lock`, no lifecycle scripts) only runs on `hutch install`, which
|
||||||
|
nothing calls — hence no `hutch.config.ts`.
|
||||||
|
|
||||||
## Release builds
|
## Release builds
|
||||||
|
|
||||||
`bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64]` (default: host).
|
`bun scripts/release.ts [--target mac-arm64|win-x64|linux-x64]`
|
||||||
Steps: gen-version → fix-electrobun-cli → gen-icons → Angular build →
|
(default: host).
|
||||||
`electrobun build --env=stable` → copy/rename artifacts into
|
Steps: gen-version → electrobun prepare → gen-icons → Angular build → empty
|
||||||
|
`artifacts/` → `electrobun build --env=stable` → copy/rename the installers into
|
||||||
`release/<target>/miniSCP-Build<n>-<target>.*`.
|
`release/<target>/miniSCP-Build<n>-<target>.*`.
|
||||||
|
|
||||||
Facts the script encodes:
|
Facts the script encodes:
|
||||||
|
|
||||||
- **`--env=stable` is mandatory for releases** — the CLI defaults to the dev
|
- **`--env=stable` is mandatory for releases** — `electrobun build` defaults to
|
||||||
channel (different app name `miniSCP-dev`, no distributable artifacts).
|
the dev channel (different app name `miniSCP-dev`, no distributable artifacts).
|
||||||
- **No OS cross-compile.** The CLI builds for its host (its `targets` config
|
- **No cross-compile, and no Intel Mac.** Hutch builds for its host OS *and*
|
||||||
field is unread in 1.18.1); packaging needs host tools (codesign/hdiutil on
|
arch only; packaging needs host tools (codesign/hdiutil on macOS,
|
||||||
macOS, rcedit/PowerShell on Windows). Hence one script run per artifact, on
|
rcedit/PowerShell on Windows). Hence one script run per artifact, on the
|
||||||
the matching machine; `win-x64` on a non-Windows host errors out.
|
matching machine; `win-x64` on a non-Windows host and `linux-x64` on a
|
||||||
- **mac-x64 from the arm64 Mac (Rosetta)**: the script downloads electrobun's
|
non-Linux host both error out. **Electrobun 2.x publishes no macOS x64 core
|
||||||
x64 CLI binary once (`electrobun-cli-darwin-x64.tar.gz` from the GitHub
|
(nor an x64 Hutch)**, so mac-x64 was dropped — the v1 trick of running the x64
|
||||||
release matching the installed version, cached at
|
CLI under Rosetta has nothing left to run. `--target mac-x64`, and any build
|
||||||
`~/.cache/miniscp/electrobun-cli-x64-<ver>/`) and runs it directly —
|
on an Intel Mac, fail with that message.
|
||||||
Rosetta translates it, the CLI sees `process.arch === "x64"`, downloads the
|
- **Runtime downloads**: on first use per Electrobun version Hutch fetches the
|
||||||
`dist-macos-x64` core into the electrobun package dir on first use, and
|
platform core from `github.com/blackboardsh/electrobun/releases`
|
||||||
packages a genuine x64 app. Note: Bun's `fetch`→`Bun.write` streaming hung
|
(SHA-256-checked against `electrobun-artifacts.json`), plus Cottontail and the
|
||||||
on the GitHub redirect chain; the script uses `curl -fsSL` deliberately.
|
pinned Bun, into `~/.hutch` — network is a build-time requirement on every
|
||||||
- **Both CLI binaries need re-signing.** The x64 CLI fetched for the Rosetta
|
fresh machine/runner.
|
||||||
path carries the same broken upstream signature as the arm64 one, so
|
- **`artifacts/` is emptied before the build** — `site/deploy.sh` publishes it
|
||||||
`x64Cli()` re-signs it after download *and* on cache reuse; the native path
|
verbatim, so it must hold exactly this build (stale files, e.g. v1-named
|
||||||
is covered by the `fix-electrobun-cli.ts` step up front.
|
installers, would otherwise ship).
|
||||||
- **Core binary downloads**: the installed npm package ships mac-arm64
|
- **Artifact shapes** (stable channel, in `artifacts/`): 2.x installers carry
|
||||||
binaries only; other `dist-<os>-<arch>` cores are fetched from
|
**no channel prefix**; only the updater feed keeps `stable-<os>-<arch>-` (the
|
||||||
`github.com/blackboardsh/electrobun/releases` at build time — network to
|
names v1.18.1+ clients request). macOS → `macos-arm64-miniSCP.dmg` +
|
||||||
GitHub is a build-time requirement on every fresh machine/runner.
|
`stable-macos-arm64-miniSCP.app.tar.zst` + `stable-macos-arm64-update.json`
|
||||||
- **Artifact shapes** (stable channel, in `artifacts/`): macOS →
|
(verified); Windows → `win-x64-miniSCP-Setup.zip` (setup exe + hidden payload)
|
||||||
`stable-macos-<arch>-miniSCP.dmg` + `.app.tar.zst` + `update.json`
|
+ the `stable-win-x64-*` feed, plus the script's own **portable zip** of
|
||||||
(updater feed); Windows → `stable-win-x64-miniSCP-Setup.zip`
|
`build/stable-win-x64/miniSCP` (warns instead of failing if that folder is
|
||||||
(self-extracting installer → `%LOCALAPPDATA%`) plus the script's own
|
missing); Linux → `linux-x64-miniSCP-Setup.tar.gz` (a self-extracting
|
||||||
**portable zip** made from the assembled app folder under
|
`installer` + README) + the `stable-linux-x64-*` feed. The official Linux
|
||||||
`build/stable-win-x64/` (layout to confirm on the first real Windows build —
|
installer IS the release artifact — the v1 portable tarball and its
|
||||||
the step warns instead of failing).
|
`miniSCP.sh` NVIDIA wrapper are gone, so an installed build on NVIDIA needs
|
||||||
|
`WEBKIT_DISABLE_DMABUF_RENDERER=1` from the user (see gotchas.md). The
|
||||||
|
Windows/Linux names come from the 2.x docs — confirm on the first real 2.x
|
||||||
|
build there. A build that can reach the previous release at
|
||||||
|
`release.baseUrl` also emits `stable-<os>-<arch>-<previous-hash>.patch`.
|
||||||
|
- **Linux builds need the AppIndicator dev libraries present to RUN** (not to
|
||||||
|
build) — see platform-notes.md; an installed build started on a machine
|
||||||
|
without them dies at `dlopen`.
|
||||||
- The script filters artifacts by app name, so stale artifacts from older
|
- The script filters artifacts by app name, so stale artifacts from older
|
||||||
brandings don't get collected.
|
brandings don't get collected.
|
||||||
|
- **v1 → 2.x bridge**: Windows users on 0.1.78 (the only feed live on
|
||||||
|
miniscp.com/dl) update through their v1 updater because `app.name`,
|
||||||
|
`app.identifier` and `release.baseUrl` are unchanged, the build is
|
||||||
|
`--env=stable`, and `artifacts/` is published unrenamed. 2.x keeps the v1
|
||||||
|
top-level `update.json` fields (`version`/`hash`/`platform`/`arch`) and the
|
||||||
|
physical `stable` state root, so user data stays put. Unverified until the
|
||||||
|
first 2.x Windows release reaches a v1 install.
|
||||||
|
|
||||||
## CI (Gitea Actions)
|
## CI (Gitea Actions)
|
||||||
|
|
||||||
`.gitea/workflows/release.yml` — tag-triggered (`build-*`), macos + windows
|
`.gitea/workflows/release.yml` — tag-triggered (`build-*`), macos (mac-arm64) +
|
||||||
runner matrix, each running the same release script; inert until act_runners
|
windows (win-x64) runner matrix, each running the same release script; inert
|
||||||
with those labels are registered. Needs `fetch-depth: 0` (the build number is
|
until act_runners with those labels are registered. Needs `fetch-depth: 0` (the
|
||||||
the commit count). Runner prereqs are commented in the workflow file.
|
build number is the commit count); `bun install --frozen-lockfile` already
|
||||||
|
prepares the devkit (postinstall). Runner prereqs are commented in the workflow
|
||||||
|
file.
|
||||||
|
|
||||||
## Download host (`site/` + Caddy)
|
## Download host (`site/` + Caddy)
|
||||||
|
|
||||||
@@ -85,27 +116,31 @@ the commit count). Runner prereqs are commented in the workflow file.
|
|||||||
internal box (plain HTTP `:80`, no domain/TLS). Deploys to `/srv/miniscp/`.
|
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
|
- `site/index.html` — pure static, no JS, tinygrad-minimal. Download buttons link to **fixed
|
||||||
stable filenames** (`/dl/stable-macos-arm64-miniSCP.dmg`, …) so the page never changes per
|
stable filenames** (`/dl/macos-arm64-miniSCP.dmg`, `/dl/win-x64-miniSCP-Setup.zip`, the portable
|
||||||
release. Logo/screenshot `<img>` slots are commented in, images live in `site/assets/`.
|
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
|
- `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`
|
per-type `Cache-Control`: `*.patch` immutable (unique hashed names), `*update.json` `no-cache`
|
||||||
(updater must see new versions), installers (`*.dmg *.zip *.tar.zst`) `max-age=300` (stable
|
(updater must see new versions), installers (`*.dmg *.zip *.tar.zst *.tar.gz`) `max-age=300`
|
||||||
names overwritten each release). Landing page has **no** SPA fallback → unknown paths 404.
|
(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`.
|
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
|
- **One flat folder, no per-build dirs** — this layout IS electrobun's `artifacts/` feed, so it
|
||||||
doubles as the (future) updater `baseUrl`. Publishing = drop this machine's `artifacts/stable-*`
|
doubles as the updater `baseUrl`. Publishing = drop this machine's whole `artifacts/` into
|
||||||
into `/dl/`; installers overwrite in place, `.patch` files accumulate (never delete them, or
|
`/dl/`, unrenamed; installers overwrite in place, `.patch` files accumulate (never delete them,
|
||||||
older installs lose their incremental-update trail). **Tradeoff:** only the latest full
|
or older installs lose their incremental-update trail). **Tradeoff:** only the latest full
|
||||||
installer is downloadable; old full versions are overwritten.
|
installer is downloadable; old full versions are overwritten.
|
||||||
- `site/deploy.sh` — run per build machine after `scripts/release.ts`; copies `artifacts/stable-*`
|
- `site/deploy.sh` — run per build machine after `scripts/release.ts`; copies `artifacts/*`
|
||||||
and renames the build-numbered win portable zip to the stable `stable-win-x64-miniSCP-portable.zip`.
|
verbatim and renames the build-numbered win portable zip to the stable
|
||||||
`MINISCP_DL_DIR` overrides the target (stage locally, then rsync to the server).
|
`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
|
- `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
|
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.
|
into `version.json` at build time, so only builds made after it was added check for updates.
|
||||||
With `generatePatch` (default true), release builds now need **build-time network to
|
With `generatePatch` (default true), release builds need **build-time network to
|
||||||
`miniscp.com/dl`** so electrobun can fetch the currently-published version to diff a `.patch`
|
`miniscp.com/dl`** so Hutch can fetch the currently-published version to diff a `.patch` (a
|
||||||
(the first build after enabling it has nothing to diff and just ships the full artifacts).
|
target with no published feed yet — currently mac and Linux — just ships the full artifacts).
|
||||||
|
|
||||||
## Auto-updater (app side)
|
## Auto-updater (app side)
|
||||||
|
|
||||||
@@ -117,16 +152,21 @@ Wired on top of the download host above. See the "Auto-updater" section in
|
|||||||
`Updater.onStatusChange` streams `download-progress` (0–100) → `updateStatus{downloading}`.
|
`Updater.onStatusChange` streams `download-progress` (0–100) → `updateStatus{downloading}`.
|
||||||
`checkForUpdate` self-guards the `dev` channel, so `bun run dev` never shows the pill.
|
`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` /
|
- **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` (handlers in [rpc-handlers.ts](../src/bun/rpc-handlers.ts) call the electrobun `Updater`;
|
||||||
push message `updateStatus`.
|
`applyUpdate` goes through `events.applyUpdate` in index.ts); push message `updateStatus`.
|
||||||
- **UI**: `UpdaterService` ([ui/src/app/updater.service.ts](../ui/src/app/updater.service.ts)) holds signals
|
- **`applyUpdate()` asks the before-quit guard first (2.x).** With transfers running or dirty
|
||||||
(`state`/`version`/`progress`); the tab-bar "update pill" ([tab-bar.ts](../ui/src/app/tab-bar/tab-bar.ts))
|
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 →
|
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
|
"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
|
`downloadUpdate` response). App-menu "Check for Updates…" ([menu.ts](../src/bun/menu.ts)) routes a
|
||||||
`check-for-updates` menu action into `UpdaterService.check()`.
|
`check-for-updates` menu action into `UpdaterService.check()`.
|
||||||
- **Unsigned is fine:** `applyUpdate()` strips the macOS quarantine xattr before relaunch, so the
|
- **Unsigned was fine on 1.18.1:** `applyUpdate()` stripped the macOS quarantine xattr before
|
||||||
swapped bundle isn't Gatekeeper-blocked.
|
relaunch. 2.x hands the swap to a native post-exit helper (transactional replace, restores the
|
||||||
|
old app on failure) — re-check Gatekeeper on the first unsigned 2.x mac update.
|
||||||
|
|
||||||
## Icons
|
## Icons
|
||||||
|
|
||||||
@@ -138,8 +178,13 @@ Rerun after touching either SVG; outputs are committed (build inputs).
|
|||||||
|
|
||||||
## electrobun config notes
|
## electrobun config notes
|
||||||
|
|
||||||
`electrobun.config.ts`: app identity comes from `BRANDING`; `build.bun.external`
|
`electrobun.config.ts`: app identity comes from `BRANDING`; **`build.mainProcess:
|
||||||
must keep `["cpu-features", "*.node"]` (ssh2's optional native accelerators —
|
"bun"` is load-bearing** — 2.x defaults to Cottontail, which ignores the whole
|
||||||
bundling them breaks the build; runtime falls back to pure JS);
|
`build.bun` block (verified: without the line, `bunx electrobun config
|
||||||
`build.copy` ships the Angular dist and tray assets into `views://`;
|
--env=stable` resolves `mainProcess: "cottontail"` with no externals);
|
||||||
`bundleCEF: false` everywhere (system WebKit / WebView2).
|
`build.bun.external` must keep `["cpu-features", "*.node"]` (ssh2's optional
|
||||||
|
native accelerators — bundling them breaks the build; runtime falls back to pure
|
||||||
|
JS; a 2.x stable bundle keeps both as runtime `require`s); `build.copy` ships the
|
||||||
|
Angular dist and tray assets into `views://`; `bundleCEF: false` everywhere
|
||||||
|
(system WebKit / WebView2). `bunx electrobun config --env=stable` prints the
|
||||||
|
merged config Hutch will build with.
|
||||||
|
|||||||
+238
-33
@@ -20,40 +20,133 @@ touching the adjacent code.
|
|||||||
`[class.shadow-[...]]` (brackets) does NOT parse — use `[style.box-shadow]`.
|
`[class.shadow-[...]]` (brackets) does NOT parse — use `[style.box-shadow]`.
|
||||||
Static class attributes may use arbitrary values freely.
|
Static class attributes may use arbitrary values freely.
|
||||||
- Functions called from templates ARE reactive: signals read inside them
|
- Functions called from templates ARE reactive: signals read inside them
|
||||||
register on the view's consumer (call-stack-based tracking). That's why
|
register on the view's consumer (call-stack-based tracking). That's why a
|
||||||
`iconClass(entry)` reacting to `settings.iconColors()` works.
|
template calling `i18n.t()` re-renders on a language switch — `t()` reads
|
||||||
|
the `locale` signal.
|
||||||
- Dedicated tools > shell: IDE diagnostics between paired template/class
|
- Dedicated tools > shell: IDE diagnostics between paired template/class
|
||||||
edits are often stale mid-sequence — trust `bun run build:ui` (full AOT
|
edits are often stale mid-sequence — trust `bun run build:ui` (full AOT
|
||||||
type-check), not the first red squiggle.
|
type-check), not the first red squiggle.
|
||||||
- Tailwind v4 canonical class names: `wrap-break-word` not `break-words`,
|
- Tailwind v4 canonical class names: `wrap-break-word` not `break-words`,
|
||||||
`accent-primary` not `accent-[var(--primary)]`, `z-60` not `z-[60]`.
|
`accent-primary` not `accent-[var(--primary)]`, `z-60` not `z-[60]`.
|
||||||
Every color used in a template must resolve through `@theme inline`.
|
Every color used in a template must resolve through `@theme inline`.
|
||||||
|
- Stylesheet `url()` must resolve at BUILD time: assets referenced from
|
||||||
|
`styles.css` live under `ui/src/` (esbuild emits them hashed into `media/`
|
||||||
|
and rewrites the url), NOT `ui/public/` — a public path fails the build with
|
||||||
|
"Could not resolve".
|
||||||
|
- **Two `class` attributes on one element: the second is silently dropped.**
|
||||||
|
No warning, no build error — the layout classes simply never apply. This
|
||||||
|
collapsed the tab strip and two window headers at once (`fe028a9`), because
|
||||||
|
a scripted edit appended `class="…"` to elements that already had one.
|
||||||
|
When removing a conditional binding, check what the branch *contained*: the
|
||||||
|
bindings there were genuinely dead, but the layout classes inside them were
|
||||||
|
not, and nothing replaced them.
|
||||||
|
- **A component `template:` is a TS template literal, so no backticks inside
|
||||||
|
it** — not even in an HTML comment. A backtick in a comment terminates the
|
||||||
|
literal and the errors point at the prose, not at the quoting.
|
||||||
|
- **Reach `core/` through the `@core/*` alias, never a relative path.**
|
||||||
|
Components sit at varying depths and get moved; the alias is what let 67
|
||||||
|
imports survive two relocations untouched. A `../../core/...` path is a
|
||||||
|
future merge conflict.
|
||||||
|
- **The build DOES type-check components nothing imports.** ngtsc compiles the
|
||||||
|
whole program, so an unreferenced component still fails `bun run build:ui` on
|
||||||
|
a type or template error (verified by injecting an error into an
|
||||||
|
unreferenced file).
|
||||||
|
- **`overflow-x: auto` also clips vertically.** CSS refuses to pair `visible`
|
||||||
|
with a non-visible axis, so the computed `overflow-y` becomes `auto` too:
|
||||||
|
anything poking out of a horizontally scrolling strip (a raised tab, an
|
||||||
|
underline at `-bottom-px`) is silently clipped. Reserve padding instead.
|
||||||
|
|
||||||
|
## Theming (`ui/src/styles.css`)
|
||||||
|
|
||||||
|
- **A new surface token needs an AMOLED line, or AMOLED stops being
|
||||||
|
all-black.** The two `[data-dark-tone='darker']` blocks re-declare every
|
||||||
|
surface (`--chrome`, `--toolbar`, `--background`, `--row-alt`, …) as `#000`;
|
||||||
|
a token added only to the dark blocks shows through in AMOLED. Tokens derived
|
||||||
|
in `:root` by `color-mix` (`--toolbar-2/-3`) follow on their own.
|
||||||
|
- **Hover/selected color is a translucent overlay (`--accent`).** An opaque
|
||||||
|
hover vanishes on a surface of its own lightness — light's old 0.967 hover
|
||||||
|
on the 0.965 header strip did exactly that. Keep `--accent` alpha-based when
|
||||||
|
retoning surfaces.
|
||||||
|
- **A native control ignores `border-radius` and background until
|
||||||
|
`appearance: none`.** WebKit draws checkboxes, radios and selects itself;
|
||||||
|
the square restyle in the `@layer base` block depends on it. Put `pl-*`,
|
||||||
|
never `px-*`, on a `<select>`: utilities outrank the base layer, so a `px`
|
||||||
|
utility would erase the padding-right the drawn caret lives in.
|
||||||
|
- **A custom `::-webkit-scrollbar` turns off macOS overlay scrollbars.** The
|
||||||
|
moment the pseudo-element is styled, WKWebView draws classic always-visible
|
||||||
|
scrollbars that take layout width. Deliberate here (square design); don't
|
||||||
|
reach for it to "tint" scrollbars without accepting that.
|
||||||
|
|
||||||
|
## The interface scale (root font-size lever)
|
||||||
|
|
||||||
|
- **electrobun's native page zoom is a silent no-op on Linux — do not reach for
|
||||||
|
it.** `webviewSetPageZoom` in the shipped `libNativeWrapper.so` (v1.18.1, and
|
||||||
|
the `_cef.so`, and the copy in our own build output) is a compiled
|
||||||
|
empty-body stub: the disassembly has no `call` instruction, and
|
||||||
|
`webkit_web_view_set_zoom_level` is not even in the library's imports.
|
||||||
|
`webviewGetPageZoom` returns a hardcoded `1.0` from `.rodata`, so the failure
|
||||||
|
is unobservable through the API — set 2.0, read back "1.0 = success". The
|
||||||
|
JSDoc's "(WebKit only)" means macOS WKWebView. This is why the scale is CSS.
|
||||||
|
(Disassembled on 1.18.1; 2.0.2's zoom fixes are CEF-only — re-check the
|
||||||
|
native Linux wrapper before trusting it.)
|
||||||
|
- **Everything the UI sizes with must be rem.** The scale works by setting the
|
||||||
|
root font-size, so a px value freezes while everything around it grows. The
|
||||||
|
px reservoirs were the panel-chrome tokens (`--row-h` etc.) and 93
|
||||||
|
`text-[11px]` arbitraries. 1px hairlines and focus rects stay px deliberately
|
||||||
|
(crisp at any scale).
|
||||||
|
- **px constants in TS that mirror rem-sized CSS are latent scale bugs.** The
|
||||||
|
context menu's `MENU_W = 224` was a hardcoded mirror of `w-56`
|
||||||
|
(14rem): box grew, math didn't, flip fired at the wrong edge. Convert through
|
||||||
|
`@core/metrics` `remPx()` at use time — it reads the LIVE root font-size,
|
||||||
|
and per call, since the scale changes mid-session. The editor's canvas-based
|
||||||
|
`charWidth` is cached keyed on the computed font string for the same reason.
|
||||||
|
|
||||||
## Electrobun
|
## Electrobun
|
||||||
|
|
||||||
- **The prebuilt CLI binary ships with a broken ad-hoc signature**, so macOS
|
- **The `electrobun` npm package (2.x) is a bootstrap whose every export is a
|
||||||
SIGKILLs it at exec and `bun run start` prints nothing and exits **0** —
|
throwing stub** ("Electrobun 2.x APIs come from the Hutch devkit, not
|
||||||
`bin/electrobun.cjs` spawns the real binary and forwards
|
node_modules"). Every `electrobun/*` import must resolve through the
|
||||||
`process.exit(code || 0)`, and a signal death has `code === null`. The binary
|
projected `.hutch/devkit/`: the root tsconfig extends
|
||||||
is a Bun single-file executable whose payload is appended *after* the linker
|
`.hutch/devkit/tsconfig.json`, and `ui/tsconfig.json` maps `electrobun/view`
|
||||||
signed it, so `codesign -v` reports "code or signature have been modified";
|
to `../.hutch/devkit/api/browser/index.ts` (the exact export-map target). Let
|
||||||
the arm64 and x64 CLI tarballs are both affected upstream (confirmed by
|
an import fall through to `node_modules/electrobun` and the webview throws at
|
||||||
hashing a fresh download against the shipped copy — it is not local
|
load. tsc and the UI build fail on unresolved `electrobun/*` imports until
|
||||||
corruption, and re-installing does not help). `scripts/fix-electrobun-cli.ts`
|
`bunx electrobun prepare` has run (postinstall and every dev/build script do
|
||||||
re-seals it with `codesign --force --sign -`. It runs from `postinstall`
|
it). Never point anything into `~/.hutch` — that store is Hutch-owned.
|
||||||
**and** ahead of every electrobun invocation, because the wrapper downloads
|
- **`build.mainProcess` defaults to Cottontail in 2.x**, and Cottontail ignores
|
||||||
the binary **lazily on first use**, not at install time — postinstall alone
|
the whole `build.bun` block — the ssh2 externals included, silently (verified
|
||||||
would no-op on a fresh checkout. It also fixes `.cache/electrobun`, which the
|
with `bunx electrobun config --env=stable`). `mainProcess: "bun"` in
|
||||||
wrapper restores `bin/` from. Never read a silent `exit 0` from the
|
`electrobun.config.ts` is load-bearing.
|
||||||
electrobun CLI as "nothing happened": run `codesign -v` on it first.
|
- **The browser SDK is raw TS compiled inside the Angular program**, so the UI's
|
||||||
- **Window `close` is not cancellable** (response type `{}`; only the
|
strictness flags apply to upstream code: 2.0.2's `createTransport().send`
|
||||||
app-level `before-quit` supports `event.response = {allow:false}`). All
|
returns on one path only, which is why `ui/tsconfig.json` has
|
||||||
unsaved-state guards must either intercept app-controlled close paths
|
`noImplicitReturns: false`. Its `import "./global.d.ts"` also prints "File
|
||||||
(⌘W, buttons) or survive the close (editor draft mirroring).
|
'…/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.
|
||||||
|
- **⌘W in any secondary window arrives as the MAIN window's `close-tab`
|
||||||
|
menu action** (macOS: the app menu intercepts it before the webview sees
|
||||||
|
the key). bun must check the focused secondary window first — the Settings
|
||||||
|
window, then editors — and close THAT; a new window kind that skips this
|
||||||
|
check makes ⌘W close a main-window tab behind it.
|
||||||
- **One rpc instance per window** — BrowserView binding steals the rpc's
|
- **One rpc instance per window** — BrowserView binding steals the rpc's
|
||||||
transport; a shared instance silently sends to the wrong webview.
|
transport; a shared instance silently sends to the wrong webview.
|
||||||
- **RPC before socket open is silently lost.** Everything gates on the
|
- **RPC before socket open was silently lost on 1.18.1.** 2.x Electroview queues
|
||||||
socket-open promise (RpcService `ready`).
|
sends while its `hostSocket` (v1: `bunSocket`) is CONNECTING and falls back to
|
||||||
|
the native bridge; RpcService still gates everything on the socket-open
|
||||||
|
promise (`ready`) as belt-and-braces.
|
||||||
- The emitter has no `off()`. Window event closures must not capture records
|
- The emitter has no `off()`. Window event closures must not capture records
|
||||||
you want garbage collected.
|
you want garbage collected.
|
||||||
- `electrobun build` defaults to the **dev** channel; release builds need
|
- `electrobun build` defaults to the **dev** channel; release builds need
|
||||||
@@ -63,21 +156,119 @@ touching the adjacent code.
|
|||||||
- Tray: never call `setImage` after construction on macOS — the template
|
- Tray: never call `setImage` after construction on macOS — the template
|
||||||
treatment is lost and the icon renders black on dark menu bars.
|
treatment is lost and the icon renders black on dark menu bars.
|
||||||
- `ApplicationMenu` is macOS-only in our usage; accelerators on Windows are
|
- `ApplicationMenu` is macOS-only in our usage; accelerators on Windows are
|
||||||
single-character only. ContextMenu exists on macOS+Windows, not Linux.
|
single-character only.
|
||||||
|
- **`ContextMenu.showContextMenu` is a silent no-op on Linux** (electrobun's own
|
||||||
|
`.hutch/devkit/api/sdks/main/proc/linux.md` says so, still in 2.0.2; the app
|
||||||
|
also prints "Application menus are not supported on Linux" at boot). Because
|
||||||
|
the UI suppresses the webview's own right-click menu, that left EVERY panel
|
||||||
|
context menu dead on Linux — no error, nothing happens. The menu is plain
|
||||||
|
HTML now (`ui/src/app/skins/shadlike/context-menu.ts`) on every platform;
|
||||||
|
don't reintroduce the native one for "nativeness" on mac/win without keeping
|
||||||
|
a Linux path.
|
||||||
- **Window-open flash prevention is three-layer** — remove any one and the
|
- **Window-open flash prevention is three-layer** — remove any one and the
|
||||||
white/inverted blink returns: (1) windows are created `hidden: true` and
|
white/inverted blink returns: (1) windows are created `hidden: true` and
|
||||||
revealed by `showWhenReady` on the webview's first `dom-ready` (800 ms
|
revealed by `showWhenReady` on the webview's first `dom-ready` (800 ms
|
||||||
fallback), (2) the bun preload bakes the persisted `data-theme`/tone/accent
|
fallback), (2) the bun preload bakes the persisted `data-theme`/tone
|
||||||
attributes before first paint (`buildPreload`), (3) `ui/src/index.html`
|
attributes and the interface scale before first paint (`buildPreload`),
|
||||||
carries an inline `<style>` with the html background per theme — its oklch
|
(3) `ui/src/index.html` carries an inline `<style>` with the html background
|
||||||
values MUST stay in sync with the `--background` tokens in styles.css.
|
per theme — its oklch values MUST stay in sync with the `--background` tokens
|
||||||
|
in styles.css. (The win7 branch once replaced (3) with a transparent rule and
|
||||||
|
the modern skin silently lost it — the other two layers do not cover it.)
|
||||||
- Dev vs prod URL: `getMainViewUrl()` resolves the ng-serve dev server only
|
- Dev vs prod URL: `getMainViewUrl()` resolves the ng-serve dev server only
|
||||||
when `DEV_SERVER_URL` is set AND the updater channel is `dev` AND the
|
when `DEV_SERVER_URL` is set AND the updater channel is `dev` AND the
|
||||||
server answers a HEAD request; secondary windows must reuse the resolved
|
server answers a HEAD request; secondary windows must reuse the resolved
|
||||||
URL, never hardcode `views://`.
|
URL, never hardcode `views://`.
|
||||||
- Upstream Bun panic (`FFI_Callback_threadsafe_call` → `web_worker.spin`):
|
- Upstream Bun panic (`FFI_Callback_threadsafe_call` → `web_worker.spin`):
|
||||||
rare segfault under long sessions; upstream electrobun×Bun issue, restart
|
rare segfault under long sessions; upstream electrobun×Bun issue, restart
|
||||||
recovers, nothing app-side to fix.
|
recovers, nothing app-side to fix. Seen on Bun 1.3.13 (Electrobun 1.18.1);
|
||||||
|
2.0.2 ships Bun 1.4.0 — unconfirmed whether that fixed it.
|
||||||
|
- **Preloads and frames are construction-time only.** The preload is a
|
||||||
|
persistent `WebKitUserScript` at DOCUMENT_START, frozen at webview
|
||||||
|
construction — `webview.loadURL()` re-runs the OLD one
|
||||||
|
(`updatePreloadScriptToWebView` is only reachable from electrobun's internal
|
||||||
|
`<electrobun-webview>` handler). `titleBarStyle`/`transparent` have no
|
||||||
|
setters. Anything `buildPreload` bakes is a first-paint snapshot the UI must
|
||||||
|
correct live over RPC. Recreating the main window is not an escape hatch:
|
||||||
|
`exitOnLastWindowClosed`, push closures and the tray capture it, and there
|
||||||
|
is no `off()`.
|
||||||
|
- **`trafficLightOffset` changed meaning in 2.x.** 1.18.1 nudged the buttons
|
||||||
|
by the offset but clamped them inside the stock titlebar, so `{12, 13}` really
|
||||||
|
meant "as low as the titlebar allows" (left 21 / top 18 on macOS 26). 2.x adds
|
||||||
|
the full offset to macOS's default position (9/9 in the 32pt Tahoe titlebar)
|
||||||
|
and grows the titlebar to fit — the same numbers dropped the lights 4pt.
|
||||||
|
`chromeOptions()` now passes `{12, 4}`: left 21 / top 13, the 14pt buttons
|
||||||
|
vertically centred in the 40px `h-10` header (1.18.1's clamped spot, top 18,
|
||||||
|
was 5pt low). The default shifts with macOS's titlebar metrics (28pt / 7
|
||||||
|
before macOS 26), so re-check after an OS release. `win.setWindowButtonPosition(x, top)` is the absolute
|
||||||
|
alternative (re-applied on resize/fullscreen) if per-version drift matters.
|
||||||
|
- **Drag regions ignore `preventDefault`.** The preload starts a window move
|
||||||
|
from a bubble-phase `mousedown` on `document` and never reads
|
||||||
|
`defaultPrevented`; a `pointerdown` handler doesn't stop it either (the
|
||||||
|
compat `mousedown` still fires). `isAppRegionDragTarget` lets a `no-drag`
|
||||||
|
region win — the `.electrobun-webkit-app-region-no-drag` class (or, since
|
||||||
|
2.x, `app-region: no-drag` CSS) on the element is the only reliable
|
||||||
|
suppressor (`.hutch/devkit/api/preload/dragRegions.ts`; re-checked on 2.0.2).
|
||||||
|
- **No resize, maximize-event or double-click-maximize primitives (1.18.1, still
|
||||||
|
absent from 2.0.2's `BrowserWindow`).**
|
||||||
|
`titleBarStyle: "hidden"` drops the whole frame (Cocoa `Titled` — the only
|
||||||
|
way to remove the traffic lights; `gtk_window_set_decorated(false)` on
|
||||||
|
Linux) and with it the WM resize borders; nothing restores them (no
|
||||||
|
`startWindowResize`, no `gtk_window_begin_resize_drag`, no
|
||||||
|
`setResizable`/min-size options — only `setFrame`/`getFrame`). No maximize
|
||||||
|
event exists (derive it from `resize` + `isMaximized()`), and drag regions
|
||||||
|
don't double-click-maximize. A custom-chrome window has to build all three.
|
||||||
|
|
||||||
|
## Linux
|
||||||
|
|
||||||
|
- **`ERR_DLOPEN_FAILED` on `bun run start`** means a missing system library, not
|
||||||
|
broken app code. `libNativeWrapper.so` links `libayatana-appindicator3.so.1`
|
||||||
|
*hard* (for the tray), so the app cannot start at all without it — even though
|
||||||
|
the tray is a minor feature. Diagnose with
|
||||||
|
`ldd build/dev-linux-x64/*/bin/libNativeWrapper.so | grep 'not found'`; the
|
||||||
|
`libasar.so => not found` line in that output is a red herring (it ships beside
|
||||||
|
the wrapper and resolves at load). Install list per distro is in
|
||||||
|
[platform-notes.md](platform-notes.md).
|
||||||
|
- **Black window on NVIDIA + `Failed to create GBM buffer of size WxH: Invalid
|
||||||
|
argument`** (usually with `X11 Error: GLXBadWindow (code 170)`). The app boots,
|
||||||
|
logs "miniSCP started", reports no error — and paints nothing. WebKitGTK's
|
||||||
|
DMABUF renderer can't allocate buffers on the NVIDIA proprietary driver;
|
||||||
|
`WEBKIT_DISABLE_DMABUF_RENDERER=1` is the upstream workaround.
|
||||||
|
- **The app cannot apply this to itself.** WebKit reads the variable when it
|
||||||
|
forks its web process, and **Bun's `process.env` writes do not propagate to
|
||||||
|
child processes** (verified: a spawned child sees it unset). Setting it in
|
||||||
|
`src/bun/index.ts` — even in a module imported before `electrobun/main` —
|
||||||
|
does nothing. It must be in the environment before the app process starts.
|
||||||
|
- Only the dev launch paths inject it: `scripts/linux-gpu.ts` (`launchEnv()`)
|
||||||
|
for `bun run dev` / `bun run start`. **A new dev launch path must call
|
||||||
|
`launchEnv()` or it will render black on NVIDIA.** The release is the
|
||||||
|
official 2.x Setup installer now (the v1 portable tarball and its
|
||||||
|
`miniSCP.sh` wrapper are gone), so an installed build on NVIDIA needs the
|
||||||
|
user to launch it with `WEBKIT_DISABLE_DMABUF_RENDERER=1` (e.g. in the
|
||||||
|
desktop entry's `Exec`).
|
||||||
|
- Gated on `/proc/driver/nvidia` so Intel/AMD keep hardware compositing, and
|
||||||
|
an explicit `WEBKIT_DISABLE_DMABUF_RENDERER` from the user always wins.
|
||||||
|
- A "does it boot?" log check does NOT catch this — grep for the GBM line, or
|
||||||
|
look at the window.
|
||||||
|
- **`bun run start` leaves the old instance running, and
|
||||||
|
`import -window miniSCP` grabs the OLDEST match.** Instances pile up across
|
||||||
|
rebuilds, so every screenshot silently comes from a stale build and the fix
|
||||||
|
you just made looks like it did nothing. Cost three wrong readings in one
|
||||||
|
session, including a "the CSS is in the bundle but does not apply" dead end.
|
||||||
|
Kill first (`pkill -f dev-linux-x64`), confirm with
|
||||||
|
`pgrep -cf 'dev-linux-x64/miniSCP-dev/bin/launcher'`, then launch and capture.
|
||||||
|
Note `pkill -f 'electrobun dev'` also matches the shell running it and kills
|
||||||
|
your own command (exit 144) — match on the build path instead. That is only
|
||||||
|
half the trap: `pkill -f dev-linux-x64` does the same thing, because the
|
||||||
|
pattern appears in the killing command's OWN argv. Break the literal with a
|
||||||
|
character class — `pkill -f 'dev-linux-x[6]4'` — the way `ps | grep [x]yz`
|
||||||
|
has always worked.
|
||||||
|
- **Launching the app steals focus — ask first.** `bun run start` opens a real
|
||||||
|
window that takes focus, which interrupts whatever the user is doing; there
|
||||||
|
is no Xvfb/xvfb-run on this machine and Wayland offers no
|
||||||
|
launch-without-focus. Batch every live check into a single launch.
|
||||||
|
- Benign startup noise on Linux — non-fatal, the app runs normally:
|
||||||
|
`libayatana-appindicator is deprecated. Please use libayatana-appindicator-glib`
|
||||||
|
(upstream electrobun's choice, not ours).
|
||||||
|
|
||||||
## Domain logic
|
## Domain logic
|
||||||
|
|
||||||
@@ -96,6 +287,12 @@ touching the adjacent code.
|
|||||||
- Settings: the backend spread-merges (`{...DEFAULT_SETTINGS, ...data}`) so
|
- Settings: the backend spread-merges (`{...DEFAULT_SETTINGS, ...data}`) so
|
||||||
new keys auto-default, but the UI SettingsService reads field-by-field —
|
new keys auto-default, but the UI SettingsService reads field-by-field —
|
||||||
a new setting silently stays at default until wired in all five spots.
|
a new setting silently stays at default until wired in all five spots.
|
||||||
|
- **Every window's RPC must serve `saveSettings`.** The scale shortcuts
|
||||||
|
shipped in editor and manager windows while only the main RPC served it:
|
||||||
|
the request went unanswered, the change applied locally, and main's next
|
||||||
|
broadcast snapped it back. All three factories now share
|
||||||
|
`saveSettingsHandler`, and `settingsSaved()` in index.ts rebroadcasts to
|
||||||
|
every window but the sender — an echo would drag a held Cmd+ backwards.
|
||||||
- The editor draft mirror sends full content per tick (2 MB cap upstream);
|
- The editor draft mirror sends full content per tick (2 MB cap upstream);
|
||||||
don't shrink the 400 ms debounce.
|
don't shrink the 400 ms debounce.
|
||||||
- `panel.touched` gates localDir auto-navigation; `connectToken` guards
|
- `panel.touched` gates localDir auto-navigation; `connectToken` guards
|
||||||
@@ -115,9 +312,10 @@ touching the adjacent code.
|
|||||||
single-quoted entries** (`'…c'est…'`). Use double quotes for any value
|
single-quoted entries** (`'…c'est…'`). Use double quotes for any value
|
||||||
containing an apostrophe. Plurals are `{ one, other }` maps (not ICU strings);
|
containing an apostrophe. Plurals are `{ one, other }` maps (not ICU strings);
|
||||||
zh/ja supply only `other`.
|
zh/ja supply only `other`.
|
||||||
- **Native menus can't use the Angular service** — `menu.ts`/`context-menu.ts`
|
- **Native menus can't use the Angular service** — `menu.ts` goes through backend
|
||||||
go through backend `i18n.ts`, and the app menu must be rebuilt when the locale
|
`i18n.ts`, and the app menu must be rebuilt when the locale changes (wired in
|
||||||
changes (wired in index.ts `settingsChanged`), or it keeps the old language.
|
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).
|
- `eu`/`zh`/`ja` are AI drafts pending native review (see the i18n README).
|
||||||
- **Silent auto-refresh must snapshot sessionId+cwd and discard a mismatched
|
- **Silent auto-refresh must snapshot sessionId+cwd and discard a mismatched
|
||||||
result.** A normal `refresh()` is masked by `busy` (input blocked), but the
|
result.** A normal `refresh()` is masked by `busy` (input blocked), but the
|
||||||
@@ -133,7 +331,14 @@ touching the adjacent code.
|
|||||||
breaks `ng`.
|
breaks `ng`.
|
||||||
- ssh2's optional native deps must stay `external: ["cpu-features", "*.node"]`
|
- ssh2's optional native deps must stay `external: ["cpu-features", "*.node"]`
|
||||||
in `electrobun.config.ts` — bundling them breaks the build; ssh2 falls back
|
in `electrobun.config.ts` — bundling them breaks the build; ssh2 falls back
|
||||||
to pure JS at runtime.
|
to pure JS at runtime. (Inert without `mainProcess: "bun"` — see Electrobun.)
|
||||||
|
- **2.x installers dropped the `stable-` prefix; the updater feed kept it.**
|
||||||
|
`macos-arm64-miniSCP.dmg` / `win-x64-miniSCP-Setup.zip` /
|
||||||
|
`linux-x64-miniSCP-Setup.tar.gz` next to `stable-<os>-<arch>-update.json`,
|
||||||
|
`.tar.zst` and `.patch`. Anything that globs `artifacts/stable-*` misses the
|
||||||
|
installers — release.ts collects by `<os>-<arch>-`, deploy.sh publishes
|
||||||
|
`artifacts/*` verbatim (release.ts empties `artifacts/` first), and the site
|
||||||
|
links the new names.
|
||||||
- `bun run --cwd ui build` (flag order matters; `bun --cwd ui run build` does
|
- `bun run --cwd ui build` (flag order matters; `bun --cwd ui run build` does
|
||||||
not do what you want).
|
not do what you want).
|
||||||
- `version.gen.ts` is gitignored and regenerated by postinstall + every build
|
- `version.gen.ts` is gitignored and regenerated by postinstall + every build
|
||||||
|
|||||||
+117
-24
@@ -1,35 +1,43 @@
|
|||||||
# Platform notes — macOS / Windows behavior matrix
|
# Platform notes — macOS / Windows / Linux behavior matrix
|
||||||
|
|
||||||
macOS is the primary target; Windows 11+ x64 is a **beta** target (win-arm64
|
macOS (Apple Silicon) is the primary target; Windows 11+ x64 and Linux x64 are
|
||||||
deferred; Linux untargeted — electrobun lacks menus/context menus there).
|
**beta** targets (win-arm64 and linux-arm64 deferred). **Intel Macs were
|
||||||
|
dropped** with the move to Electrobun 2.x, which publishes no macOS x64 core.
|
||||||
Every platform seam carries a `PORTABILITY:` comment at the branch site.
|
Every platform seam carries a `PORTABILITY:` comment at the branch site.
|
||||||
|
|
||||||
## Seam inventory (where platform branches live)
|
## Seam inventory (where platform branches live)
|
||||||
|
|
||||||
| Concern | File | mac | win | other |
|
| Concern | File | mac | win | linux |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Window chrome | `src/bun/windows.ts`, `index.ts` | hiddenInset + traffic-light offset, UI drag header | native titlebar, compact header | same as win |
|
| Window chrome | `src/bun/windows.ts chromeOptions` | hiddenInset + traffic-light offset, UI drag header | native titlebar, compact header | same as win |
|
||||||
|
| Scrollbars | `ui/src/styles.css` (`::-webkit-scrollbar`) | custom square, always visible (styling them turns off overlay scrollbars) | custom square | custom square |
|
||||||
| External editor launch | `src/bun/edits.ts launchEditor` | `open [-a app]` | `cmd /c start "" [app] file` | app directly / `xdg-open` |
|
| External editor launch | `src/bun/edits.ts launchEditor` | `open [-a app]` | `cmd /c start "" [app] file` | app directly / `xdg-open` |
|
||||||
| Editor app picker | `rpc-handlers.ts pickEditorApp` | /Applications, `.app` | %ProgramFiles%, `.exe` | home, any |
|
| Editor app picker | `rpc-handlers.ts pickEditorApp` | /Applications, `.app` | %ProgramFiles%, `.exe` | home, any |
|
||||||
| Owner/group names | `fs/local.ts resolveId` | `id -nu` + `dscacheutil` | undefined → "—" | `id -nu` + `getent` |
|
| Owner/group names | `fs/local.ts resolveId` | `id -nu` + `dscacheutil` | undefined → "—" | `id -nu` + `getent` |
|
||||||
| Drive roots | `fs/local.ts listLocalDrives` | empty | `fsutil fsinfo drives` | empty |
|
| Drive roots | `fs/local.ts listLocalDrives` | empty | `fsutil fsinfo drives` | empty |
|
||||||
| chmod/Permissions | context-menu.ts, file-panel, file-actions | full | hidden for LOCAL panels (only flips read-only); SFTP unaffected | full |
|
| chmod/Permissions | context-menu.service.ts, file-panel, file-actions | full | hidden for LOCAL panels (only flips read-only); SFTP unaffected | full |
|
||||||
| Shell wording | `context-menu.ts` | Reveal in Finder / Move to Trash | Show in Explorer / Move to Recycle Bin | Show in File Manager |
|
| Shell wording | `ui context-menu.service.ts` | Reveal in Finder / Move to Trash | Show in Explorer / Move to Recycle Bin | Show in File Manager |
|
||||||
| Shortcut labels | `ui platform.ts MOD_LABEL/ALT_LABEL` | ⌘ / ⌥ | Ctrl+ / Alt+ | Ctrl+ / Alt+ |
|
| Shortcut labels | `ui platform.ts MOD_LABEL/ALT_LABEL` | ⌘ / ⌥ | Ctrl+ / Alt+ | Ctrl+ / Alt+ |
|
||||||
| Tray | `src/bun/tray.ts` | 22 pt template PNG | 16 px colored PNG | colored |
|
| 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 | none (early return) — About lives in the options popover | none |
|
| App menu | `src/bun/menu.ts` | full menu incl. About | none (early return) — Settings opens from the tab-bar gear (Ctrl+,), About from its About section | none (unsupported upstream) |
|
||||||
| connections.json protection | `store/connections.ts` | chmod 0600 | `icacls` current-user ACL (0600 is meaningless) | 0600 |
|
| connections.json protection | `store/connections.ts` | chmod 0600 | `icacls` current-user ACL (0600 is meaningless) | 0600 |
|
||||||
| App data dir | `store/paths.ts` | ~/Library/Application Support | %APPDATA% | $XDG_CONFIG_HOME/.config |
|
| App data dir | `store/paths.ts` | ~/Library/Application Support | %APPDATA% | $XDG_CONFIG_HOME/.config |
|
||||||
| ssh-agent | `fs/sftp.ts` | $SSH_AUTH_SOCK | `\\.\pipe\openssh-ssh-agent` fallback | $SSH_AUTH_SOCK |
|
| ssh-agent | `fs/sftp.ts` | $SSH_AUTH_SOCK | `\\.\pipe\openssh-ssh-agent` fallback | $SSH_AUTH_SOCK |
|
||||||
| Paths in UI | `ui format.ts joinPath/isRootPath` | "/" | separator detected from the dir string; drive-root regex | "/" |
|
| Paths in UI | `ui format.ts joinPath/isRootPath` | "/" | separator detected from the dir string; drive-root regex | "/" |
|
||||||
|
|
||||||
|
The **context menu is HTML on every platform** (`ui/src/app/skins/shadlike/context-menu.ts` +
|
||||||
|
`context-menu.service.ts`), not a native electrobun menu — see the Linux section
|
||||||
|
below for why. Only the three shell actions it can't do itself (reveal, open with
|
||||||
|
the default app, copy path) cross to bun, via the `shellItemAction` RPC.
|
||||||
|
|
||||||
Remote (SFTP) paths are ALWAYS POSIX regardless of host or server OS
|
Remote (SFTP) paths are ALWAYS POSIX regardless of host or server OS
|
||||||
(`path.posix` throughout `fs/sftp.ts`).
|
(`path.posix` throughout `fs/sftp.ts`).
|
||||||
|
|
||||||
## Memory footprint (expected, not a bug)
|
## Memory footprint (expected, not a bug)
|
||||||
|
|
||||||
Investigated 2026-07 — packaged build. Roughly **~200 MB macOS / ~300 MB
|
Investigated 2026-07 — packaged build. Roughly **~200 MB macOS / ~300 MB
|
||||||
Windows**, which is normal for a Bun + system-WebView app (30 MB is native /
|
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).
|
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.
|
Not reducible to tens of MB without dropping the web UI.
|
||||||
|
|
||||||
@@ -49,18 +57,102 @@ Not reducible to tens of MB without dropping the web UI.
|
|||||||
each), and each open tab keeps both panels' full `entries` arrays; terminal
|
each), and each open tab keeps both panels' full `entries` arrays; terminal
|
||||||
transfers are retained until cleared. None affect normal use.
|
transfers are retained until cleared. None affect normal use.
|
||||||
|
|
||||||
|
## Linux runtime dependencies (REQUIRED — the app will not start without them)
|
||||||
|
|
||||||
|
electrobun's `libNativeWrapper.so` links AppIndicator *hard* for the tray, so a
|
||||||
|
missing library is a hard boot failure (`ERR_DLOPEN_FAILED` out of `bun:ffi`
|
||||||
|
`dlopen`), not a degraded tray. The transitive deps (`libdbusmenu-glib`,
|
||||||
|
`libdbusmenu-gtk3`, `libayatana-indicator3`, `libayatana-ido3`) come in as
|
||||||
|
package dependencies:
|
||||||
|
|
||||||
|
| Distro | Install |
|
||||||
|
|---|---|
|
||||||
|
| Arch / CachyOS | `sudo pacman -S libayatana-appindicator` |
|
||||||
|
| Debian / Ubuntu | `sudo apt install libayatana-appindicator3-1` |
|
||||||
|
| Fedora | `sudo dnf install libayatana-appindicator-gtk3` |
|
||||||
|
|
||||||
|
GTK/WebKit itself comes from the distro too — electrobun uses the system webview
|
||||||
|
(`bundleCEF: false`), so `webkit2gtk` must be present.
|
||||||
|
|
||||||
|
Diagnosing a boot failure:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ldd build/dev-linux-x64/*/bin/libNativeWrapper.so | grep 'not found'
|
||||||
|
```
|
||||||
|
|
||||||
|
`libasar.so => not found` in that output is **expected and harmless** — it ships
|
||||||
|
in the same `bin/` folder and resolves at load time; only an unresolved
|
||||||
|
`libayatana-appindicator3.so.1` is fatal. (Hard link and path verified on
|
||||||
|
1.18.1 — re-check both on the first 2.x Linux build.)
|
||||||
|
|
||||||
|
**Tray visibility** is a separate matter from linking: GNOME 3.26+ hides the
|
||||||
|
legacy tray, so the icon needs the
|
||||||
|
[AppIndicator extension](https://extensions.gnome.org/extension/615/appindicator-support/).
|
||||||
|
KDE Plasma, XFCE, MATE and Cinnamon show it out of the box. The app runs fine
|
||||||
|
either way — the tray is a convenience, and every action it offers exists in-app.
|
||||||
|
|
||||||
|
## Linux known issues (upstream electrobun)
|
||||||
|
|
||||||
|
- **No native menus of any kind.** Application menus and `showContextMenu` are
|
||||||
|
both no-ops (electrobun's `.hutch/devkit/api/sdks/main/proc/linux.md`, still in
|
||||||
|
2.0.2; the app logs
|
||||||
|
"Application menus are not supported on Linux" at boot). The app menubar was
|
||||||
|
already macOS-only, and the panel context menus are now HTML on every platform,
|
||||||
|
so nothing is missing at the UI level — but do not add a feature that depends
|
||||||
|
on a native menu.
|
||||||
|
- **NVIDIA renders black without the DMABUF workaround.** WebKitGTK can't
|
||||||
|
allocate GBM buffers on the proprietary driver, so the window paints nothing
|
||||||
|
while the log looks perfectly healthy. The dev launches
|
||||||
|
(`scripts/linux-gpu.ts`) set `WEBKIT_DISABLE_DMABUF_RENDERER=1` when
|
||||||
|
`/proc/driver/nvidia` exists; an installed build (the official Setup
|
||||||
|
installer) does not — launch it with that variable set (e.g. in the desktop
|
||||||
|
entry's `Exec`). The app can't do it itself (Bun's `process.env` writes don't
|
||||||
|
reach child processes); see [gotchas.md](gotchas.md#linux).
|
||||||
|
- **Benign startup noise**: a deprecation warning pointing at
|
||||||
|
`libayatana-appindicator-glib`. Not fatal.
|
||||||
|
- **Unsigned; Electrobun's own installer**: the release artifact is
|
||||||
|
`linux-x64-miniSCP-Setup.tar.gz` — a self-extracting `installer` + README
|
||||||
|
(2.x does the desktop-entry packaging and ships an uninstaller); no AppImage
|
||||||
|
or `.deb`. Layout unverified until the first 2.x Linux build.
|
||||||
|
|
||||||
|
## First-Linux-boot smoke test
|
||||||
|
|
||||||
|
1. Install the AppIndicator package for your distro (table above), then
|
||||||
|
`bun run start`. The window opens **and actually renders the UI** (a black
|
||||||
|
window on NVIDIA means the DMABUF workaround didn't apply — check for
|
||||||
|
`Failed to create GBM buffer` in the log), and the tray icon appears (or
|
||||||
|
doesn't, on bare GNOME — that's the extension caveat, not a failure).
|
||||||
|
2. Right-click a file row: menu appears at the pointer, Send to Other Panel is
|
||||||
|
first, Permissions is present, Escape / click-outside / arrow keys + Enter all
|
||||||
|
work, and a right-click near the bottom-right flips the menu instead of
|
||||||
|
overflowing.
|
||||||
|
3. Right-click empty panel space (New Folder / New File / Refresh / Show in File
|
||||||
|
Manager) and a column header (checkmarks toggle columns live).
|
||||||
|
4. Copy Path puts the path on the clipboard; Show in File Manager opens the
|
||||||
|
distro's file manager; Open (local file) launches the default app via
|
||||||
|
`xdg-open`.
|
||||||
|
5. local↔remote transfers both directions; external editor round-trip
|
||||||
|
(temp download → save → auto-upload) with the inotify watcher.
|
||||||
|
6. Move to Trash lands the item in the desktop's trash; connections.json is 0600.
|
||||||
|
7. `bun scripts/release.ts --target linux-x64` produces
|
||||||
|
`release/linux-x64/miniSCP-Build<N>-linux-x64-Setup.tar.gz`; unpack it
|
||||||
|
elsewhere, run its `installer`, then launch the installed app (on NVIDIA with
|
||||||
|
`WEBKIT_DISABLE_DMABUF_RENDERER=1`).
|
||||||
|
|
||||||
## Windows known issues (upstream electrobun, documented not fixed)
|
## Windows known issues (upstream electrobun, documented not fixed)
|
||||||
|
|
||||||
- **HiDPI blur** — WebView2 renders at 1x on scaled displays
|
- **HiDPI blur** — WebView2 renders at 1x on scaled displays
|
||||||
(blackboardsh/electrobun#324, open). Most user-visible defect; wait for
|
(blackboardsh/electrobun#324, seen on 1.18.1). Most user-visible defect; 2.x
|
||||||
upstream DPI awareness.
|
lists per-monitor DPI setup among its Windows fixes — re-check on the first
|
||||||
|
2.x Windows build.
|
||||||
- **Notifications** are legacy balloon style (`Shell_NotifyIcon`), not
|
- **Notifications** are legacy balloon style (`Shell_NotifyIcon`), not
|
||||||
Windows toasts.
|
Windows toasts.
|
||||||
- **Unsigned binaries** — Setup.exe and the portable exe both trigger
|
- **Unsigned binaries** — Setup.exe and the portable exe both trigger
|
||||||
SmartScreen / Mark-of-the-Web on first run. No Authenticode support in
|
SmartScreen / Mark-of-the-Web on first run. No Authenticode support (2.x:
|
||||||
electrobun 1.18.1 (signtool post-hook or electrobun-builder are options
|
"Windows release signing is not currently part of Hutch's packaging
|
||||||
later). Installer unpacks to `%LOCALAPPDATA%`, desktop shortcut only — no
|
pipeline"; a signtool post-hook is an option later). Installer unpacks to
|
||||||
Start Menu entry or uninstaller.
|
`%LOCALAPPDATA%`, desktop shortcut only; 2.x installer builds add an
|
||||||
|
uninstaller (`%LOCALAPPDATA%\<identifier>\<install-root>\uninstall.exe`).
|
||||||
- `hiddenInset` degrades to `hidden` on Windows — irrelevant here because
|
- `hiddenInset` degrades to `hidden` on Windows — irrelevant here because
|
||||||
non-mac windows use the native titlebar.
|
non-mac windows use the native titlebar.
|
||||||
- `findInPage` is a `window.find()` shim on WebView2 — irrelevant here, the
|
- `findInPage` is a `window.find()` shim on WebView2 — irrelevant here, the
|
||||||
@@ -71,10 +163,11 @@ Not reducible to tens of MB without dropping the web UI.
|
|||||||
Code paths written blind (no Windows box was available); verify in order:
|
Code paths written blind (no Windows box was available); verify in order:
|
||||||
|
|
||||||
1. Build: `bun scripts/release.ts --target win-x64` on the Windows machine —
|
1. Build: `bun scripts/release.ts --target win-x64` on the Windows machine —
|
||||||
core binaries download from GitHub releases on first run; Setup.zip AND
|
Hutch, the core and the pinned Bun download into `~/.hutch` on first run;
|
||||||
the portable zip appear under `release/win-x64/` (the portable step warns
|
Setup.zip AND the portable zip appear under `release/win-x64/` (the portable
|
||||||
instead of failing if the assembled-app folder layout differs — fix the
|
step warns instead of failing if the assembled-app folder layout differs —
|
||||||
`appDir` path in release.ts if so).
|
fix the `appDir` path in release.ts if so). A v1 0.1.78 install must pick up
|
||||||
|
the published 2.x build through its updater (the bridge release).
|
||||||
2. App boots, tray icon renders colored, window has a native titlebar.
|
2. App boots, tray icon renders colored, window has a native titlebar.
|
||||||
3. Drives dropdown in each panel lists `C:\` (+ others); navigating to a
|
3. Drives dropdown in each panel lists `C:\` (+ others); navigating to a
|
||||||
drive root hides the `'..'` row; the path bar shows consistent `\` paths.
|
drive root hides the `'..'` row; the path bar shows consistent `\` paths.
|
||||||
@@ -89,7 +182,7 @@ Code paths written blind (no Windows box was available); verify in order:
|
|||||||
7. Ctrl+T/W/F/S and Ctrl+digit tab jumps; tooltips read "Ctrl+…".
|
7. Ctrl+T/W/F/S and Ctrl+digit tab jumps; tooltips read "Ctrl+…".
|
||||||
8. connections.json ACL: `icacls connections.json` shows only the current
|
8. connections.json ACL: `icacls connections.json` shows only the current
|
||||||
user after saving a connection.
|
user after saving a connection.
|
||||||
9. Editor/manager windows open, modal bounce works, About shows from the
|
9. Editor/manager/Settings windows open, modal bounce works, Ctrl+, opens
|
||||||
options popover.
|
Settings and About shows from its About section.
|
||||||
10. Known-issue confirmation: HiDPI blur on a scaled display (expected until
|
10. Known-issue check: HiDPI blur on a scaled display (seen on 1.18.1 — 2.x's
|
||||||
upstream fixes it).
|
per-monitor DPI setup may have fixed it).
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export default {
|
|||||||
baseUrl: "https://miniscp.com/dl",
|
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
|
||||||
|
|||||||
+5
-5
@@ -7,16 +7,15 @@
|
|||||||
"ui"
|
"ui"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun scripts/gen-version.ts && bun scripts/fix-electrobun-cli.ts && bun scripts/dev.ts",
|
"dev": "bun scripts/gen-version.ts && bunx electrobun prepare && bun scripts/dev.ts",
|
||||||
"dev:ui": "bun run --cwd ui start",
|
"dev:ui": "bun run --cwd ui start",
|
||||||
"build:ui": "bun run --cwd ui build",
|
"build:ui": "bun run --cwd ui build",
|
||||||
"build": "bun scripts/gen-version.ts && bun scripts/fix-electrobun-cli.ts && bun run build:ui && bunx electrobun build",
|
"build": "bun scripts/gen-version.ts && bunx electrobun prepare && bun run build:ui && bunx electrobun build",
|
||||||
"start": "bun scripts/gen-version.ts && bun scripts/fix-electrobun-cli.ts && bun run build:ui && bunx electrobun dev",
|
"start": "bun scripts/gen-version.ts && bunx electrobun prepare && bun run build:ui && bun scripts/start.ts",
|
||||||
"postinstall": "bun scripts/gen-version.ts && bun scripts/fix-electrobun-cli.ts",
|
"postinstall": "bun scripts/gen-version.ts && bunx electrobun prepare",
|
||||||
"release": "bun scripts/release.ts"
|
"release": "bun scripts/release.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electrobun": "1.18.1",
|
|
||||||
"ssh-config": "^5.2.0",
|
"ssh-config": "^5.2.0",
|
||||||
"ssh2": "^1.17.0"
|
"ssh2": "^1.17.0"
|
||||||
},
|
},
|
||||||
@@ -24,6 +23,7 @@
|
|||||||
"@resvg/resvg-js": "^2.6.2",
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/ssh2": "^1.15.5",
|
"@types/ssh2": "^1.15.5",
|
||||||
|
"electrobun": "2.0.2",
|
||||||
"png-to-ico": "^3.0.2",
|
"png-to-ico": "^3.0.2",
|
||||||
"typescript": "~5.9.3"
|
"typescript": "~5.9.3"
|
||||||
},
|
},
|
||||||
|
|||||||
+3
-1
@@ -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,46 +0,0 @@
|
|||||||
// Re-signs electrobun's prebuilt CLI binary, which ships with a BROKEN ad-hoc
|
|
||||||
// signature: it is a Bun single-file executable whose payload is appended after
|
|
||||||
// the linker signed it, so `codesign -v` reports "code or signature have been
|
|
||||||
// modified" and the macOS kernel SIGKILLs it at exec. The failure is invisible:
|
|
||||||
// electrobun's bin/electrobun.cjs wrapper spawns the real binary and forwards
|
|
||||||
// `process.exit(code || 0)` — a signal death has code === null, so `bun run
|
|
||||||
// start` printed nothing and exited 0 while never launching the app.
|
|
||||||
// `codesign --force --sign -` re-seals it ad-hoc and it runs normally.
|
|
||||||
//
|
|
||||||
// The binary is downloaded LAZILY by that wrapper on first use, not by install,
|
|
||||||
// so this runs both from postinstall and ahead of every electrobun invocation.
|
|
||||||
// Idempotent, and a no-op off macOS.
|
|
||||||
import { existsSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
const ROOT = path.join(import.meta.dir, "..");
|
|
||||||
|
|
||||||
/** true if `bin` has (or now has) a signature the kernel will accept. */
|
|
||||||
export function ensureSigned(bin: string): boolean {
|
|
||||||
if (process.platform !== "darwin" || !existsSync(bin)) return true;
|
|
||||||
const valid = () => Bun.spawnSync(["codesign", "-v", bin], { stderr: "ignore" }).success;
|
|
||||||
if (valid()) return true;
|
|
||||||
const signed = Bun.spawnSync(["codesign", "--force", "--sign", "-", bin], { stderr: "pipe" });
|
|
||||||
if (!signed.success || !valid()) {
|
|
||||||
console.warn(`[electrobun-cli] could not re-sign ${path.relative(ROOT, bin)} — the app will not launch`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
console.log(`[electrobun-cli] re-signed ${path.relative(ROOT, bin)} (upstream ad-hoc signature is invalid)`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (import.meta.main) {
|
|
||||||
const pkgDir = path.join(ROOT, "node_modules", "electrobun");
|
|
||||||
const bin = path.join(pkgDir, "bin", "electrobun");
|
|
||||||
|
|
||||||
// The wrapper downloads the CLI on first run and only then execs it, so one
|
|
||||||
// throwaway invocation leaves a downloaded (still unsigned) binary behind
|
|
||||||
// even though that exec is killed.
|
|
||||||
if (process.platform === "darwin" && existsSync(pkgDir) && !existsSync(bin)) {
|
|
||||||
Bun.spawnSync(["bunx", "electrobun", "--version"], { cwd: ROOT, stdout: "ignore", stderr: "ignore" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// .cache is what the wrapper restores bin/ from, so both copies need fixing.
|
|
||||||
const ok = [bin, path.join(pkgDir, ".cache", "electrobun")].map(ensureSigned).every(Boolean);
|
|
||||||
if (!ok) process.exit(1);
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+38
-61
@@ -1,23 +1,21 @@
|
|||||||
// Build one release artifact for one target, on the matching host:
|
// Build one release artifact for one target, on the matching host:
|
||||||
// bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64]
|
// bun scripts/release.ts [--target mac-arm64|win-x64|linux-x64]
|
||||||
// Default target = the host. electrobun cannot cross-compile OSes (its
|
// Default target = the host. electrobun cannot cross-compile (Hutch builds for
|
||||||
// packaging needs host tools: codesign/hdiutil on mac, rcedit/PowerShell on
|
// its host OS and arch only; packaging needs host tools: codesign/hdiutil on
|
||||||
// Windows), so win-x64 must run on a Windows machine. mac-x64 IS buildable on
|
// mac, rcedit/PowerShell on Windows), so win-x64 must run on a Windows machine.
|
||||||
// an arm64 Mac: we fetch electrobun's x64 CLI binary and run it under
|
// There is no mac-x64: Electrobun 2.x publishes no macOS x64 core at all.
|
||||||
// Rosetta 2 — it sees an x64 host, pulls the x64 core, and packages mac-x64.
|
// Artifacts land in release/<target>/miniSCP-Build<N>-<target><ext>.
|
||||||
// Artifacts land in release/<target>/miniSCP-Build<N>-<target>.<ext>.
|
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
||||||
import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { ensureSigned } from "./fix-electrobun-cli";
|
|
||||||
|
|
||||||
const ROOT = path.join(import.meta.dir, "..");
|
const ROOT = path.join(import.meta.dir, "..");
|
||||||
const TARGETS = ["mac-arm64", "mac-x64", "win-x64"] as const;
|
const TARGETS = ["mac-arm64", "win-x64", "linux-x64"] as const;
|
||||||
type Target = (typeof TARGETS)[number];
|
type Target = (typeof TARGETS)[number];
|
||||||
|
|
||||||
function hostTarget(): Target {
|
function hostTarget(): Target {
|
||||||
if (process.platform === "win32") return "win-x64";
|
if (process.platform === "win32") return "win-x64";
|
||||||
return process.arch === "arm64" ? "mac-arm64" : "mac-x64";
|
if (process.platform === "linux") return "linux-x64";
|
||||||
|
return "mac-arm64";
|
||||||
}
|
}
|
||||||
|
|
||||||
function fail(message: string): never {
|
function fail(message: string): never {
|
||||||
@@ -38,21 +36,25 @@ function run(cmd: string[], opts: { cwd?: string } = {}): void {
|
|||||||
// ---- target selection + host guards ----
|
// ---- target selection + host guards ----
|
||||||
|
|
||||||
const argIndex = process.argv.indexOf("--target");
|
const argIndex = process.argv.indexOf("--target");
|
||||||
const requested = (argIndex >= 0 ? process.argv[argIndex + 1] : hostTarget()) as Target;
|
const arg = argIndex >= 0 ? process.argv[argIndex + 1] : hostTarget();
|
||||||
if (!TARGETS.includes(requested)) fail(`unknown target "${requested}" (${TARGETS.join(", ")})`);
|
if (arg === "mac-x64") fail("mac-x64 was dropped — Electrobun 2.x publishes no macOS x64 core.");
|
||||||
|
if (!TARGETS.includes(arg as Target)) fail(`unknown target "${arg}" (${TARGETS.join(", ")})`);
|
||||||
|
const requested = arg as Target;
|
||||||
|
|
||||||
if (requested === "win-x64" && process.platform !== "win32") {
|
if (requested === "win-x64" && process.platform !== "win32") {
|
||||||
fail("win-x64 must be built on a Windows machine/runner — electrobun cannot cross-compile.");
|
fail("win-x64 must be built on a Windows machine/runner — electrobun cannot cross-compile.");
|
||||||
}
|
}
|
||||||
if (requested.startsWith("mac") && process.platform !== "darwin") {
|
if (requested === "linux-x64" && process.platform !== "linux") {
|
||||||
fail(`${requested} must be built on macOS.`);
|
fail("linux-x64 must be built on a Linux machine/runner — electrobun cannot cross-compile.");
|
||||||
|
}
|
||||||
|
if (requested === "mac-arm64" && (process.platform !== "darwin" || process.arch !== "arm64")) {
|
||||||
|
fail("mac-arm64 must be built on an Apple Silicon Mac (Electrobun 2.x has no macOS x64 build).");
|
||||||
}
|
}
|
||||||
const viaRosetta = requested === "mac-x64" && process.arch === "arm64";
|
|
||||||
|
|
||||||
// ---- prepare: version, icons, UI bundle ----
|
// ---- prepare: version, devkit, icons, UI bundle ----
|
||||||
|
|
||||||
run(["bun", "scripts/gen-version.ts"]);
|
run(["bun", "scripts/gen-version.ts"]);
|
||||||
run(["bun", "scripts/fix-electrobun-cli.ts"]);
|
run(["bunx", "electrobun", "prepare"]);
|
||||||
run(["bun", "scripts/gen-icons.ts"]);
|
run(["bun", "scripts/gen-icons.ts"]);
|
||||||
run(["bun", "run", "--cwd", "ui", "build"]);
|
run(["bun", "run", "--cwd", "ui", "build"]);
|
||||||
|
|
||||||
@@ -60,68 +62,43 @@ const { BUILD } = await import("../src/shared/version.gen");
|
|||||||
const { BRANDING } = await import("../src/shared/branding");
|
const { BRANDING } = await import("../src/shared/branding");
|
||||||
const buildName = `Build${BUILD.number}`;
|
const buildName = `Build${BUILD.number}`;
|
||||||
|
|
||||||
// ---- electrobun build (native, or x64 CLI under Rosetta) ----
|
// ---- electrobun build ----
|
||||||
|
|
||||||
async function x64Cli(): Promise<string> {
|
// Start from an empty artifacts/: site/deploy.sh publishes it verbatim (the
|
||||||
const pkg = await Bun.file(path.join(ROOT, "node_modules", "electrobun", "package.json")).json();
|
// updater contract), so an earlier build's files must not ride along.
|
||||||
const dir = path.join(os.homedir(), ".cache", "miniscp", `electrobun-cli-x64-${pkg.version}`);
|
const artifactsDir = path.join(ROOT, "artifacts");
|
||||||
const bin = path.join(dir, "electrobun");
|
rmSync(artifactsDir, { recursive: true, force: true });
|
||||||
// Same broken upstream ad-hoc signature as the arm64 CLI (see
|
|
||||||
// scripts/fix-electrobun-cli.ts) — unsigned it is SIGKILLed on exec.
|
|
||||||
if (existsSync(bin)) {
|
|
||||||
if (!ensureSigned(bin)) fail(`x64 CLI at ${bin} has an unusable code signature`);
|
|
||||||
return bin;
|
|
||||||
}
|
|
||||||
console.log(`[release] downloading electrobun x64 CLI ${pkg.version} (one-time)…`);
|
|
||||||
mkdirSync(dir, { recursive: true });
|
|
||||||
const url = `https://github.com/blackboardsh/electrobun/releases/download/v${pkg.version}/electrobun-cli-darwin-x64.tar.gz`;
|
|
||||||
const tarball = path.join(dir, "cli.tar.gz");
|
|
||||||
run(["curl", "-fsSL", "-o", tarball, url]);
|
|
||||||
run(["tar", "-xzf", tarball, "-C", dir]);
|
|
||||||
if (!existsSync(bin)) fail(`x64 CLI tarball did not contain "electrobun" (${dir})`);
|
|
||||||
run(["chmod", "+x", bin]);
|
|
||||||
if (!ensureSigned(bin)) fail(`x64 CLI at ${bin} has an unusable code signature`);
|
|
||||||
return bin;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (viaRosetta) {
|
|
||||||
// x64 Mach-O runs under Rosetta transparently; it sees process.arch === x64
|
|
||||||
const rosetta = Bun.spawnSync(["arch", "-x86_64", "true"]);
|
|
||||||
if (!rosetta.success) {
|
|
||||||
fail("Rosetta 2 is not installed — run: softwareupdate --install-rosetta --agree-to-license");
|
|
||||||
}
|
|
||||||
run([await x64Cli(), "build", "--env=stable"]);
|
|
||||||
} else {
|
|
||||||
run(["bunx", "electrobun", "build", "--env=stable"]);
|
run(["bunx", "electrobun", "build", "--env=stable"]);
|
||||||
}
|
|
||||||
|
|
||||||
// ---- collect artifacts ----
|
// ---- collect artifacts ----
|
||||||
|
|
||||||
const [osName, archName] = requested === "win-x64" ? ["win", "x64"] : ["macos", requested.slice(4)];
|
const [osName, archName] =
|
||||||
const prefix = `stable-${osName}-${archName}-`;
|
requested === "win-x64" ? ["win", "x64"] : requested === "linux-x64" ? ["linux", "x64"] : ["macos", "arm64"];
|
||||||
const artifactsDir = path.join(ROOT, "artifacts");
|
// 2.x installers carry no channel prefix (macos-arm64-miniSCP.dmg); only the
|
||||||
|
// update feed (stable-<os>-<arch>-*) keeps it, for v1-compatible clients.
|
||||||
|
const prefix = `${osName}-${archName}-`;
|
||||||
const outDir = path.join(ROOT, "release", requested);
|
const outDir = path.join(ROOT, "release", requested);
|
||||||
mkdirSync(outDir, { recursive: true });
|
mkdirSync(outDir, { recursive: true });
|
||||||
|
|
||||||
const produced = readdirSync(artifactsDir).filter(
|
const produced = readdirSync(artifactsDir).filter(
|
||||||
(f) => f.startsWith(prefix) && f.includes(BRANDING.appName),
|
(f) => f.startsWith(prefix) && f.includes(BRANDING.appName),
|
||||||
);
|
);
|
||||||
if (!produced.length) fail(`no ${prefix}* artifacts found in artifacts/ — did the build run?`);
|
if (!produced.length) fail(`no ${prefix}* installer found in artifacts/ — did the build run?`);
|
||||||
|
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
for (const file of produced) {
|
for (const file of produced) {
|
||||||
const ext = file.endsWith(".dmg") ? ".dmg" : file.endsWith("-Setup.zip") ? "-Setup.zip" : null;
|
const ext = [".dmg", "-Setup.zip", "-Setup.tar.gz"].find((e) => file.endsWith(e));
|
||||||
if (!ext) continue; // skip the raw .app.tar.zst + update.json
|
if (!ext) continue;
|
||||||
const name = `${BRANDING.appName}-${buildName}-${requested}${ext}`;
|
const name = `${BRANDING.appName}-${buildName}-${requested}${ext}`;
|
||||||
cpSync(path.join(artifactsDir, file), path.join(outDir, name));
|
cpSync(path.join(artifactsDir, file), path.join(outDir, name));
|
||||||
out.push(name);
|
out.push(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Windows: also produce a portable zip from the assembled app folder (the
|
// Windows: also zip the assembled app folder — the portable build (unzip
|
||||||
// Setup.exe is an installer; portable = unzip anywhere and run). Best-effort:
|
// anywhere and run) next to the Setup installer. Best-effort: the folder
|
||||||
// the folder layout is confirmed by the first real Windows build.
|
// layout is confirmed by the first real 2.x Windows build.
|
||||||
if (requested === "win-x64") {
|
if (requested === "win-x64") {
|
||||||
const appDir = path.join(ROOT, "build", `stable-${osName}-${archName}`, BRANDING.appName);
|
const appDir = path.join(ROOT, "build", "stable-win-x64", BRANDING.appName);
|
||||||
if (existsSync(appDir)) {
|
if (existsSync(appDir)) {
|
||||||
const portable = path.join(outDir, `${BRANDING.appName}-${buildName}-win-x64-portable.zip`);
|
const portable = path.join(outDir, `${BRANDING.appName}-${buildName}-win-x64-portable.zip`);
|
||||||
run(["powershell", "-Command", `Compress-Archive -Force -Path '${appDir}\\*' -DestinationPath '${portable}'`]);
|
run(["powershell", "-Command", `Compress-Archive -Force -Path '${appDir}\\*' -DestinationPath '${portable}'`]);
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// `bun run start`: launch the app against the prebuilt Angular bundle (no ng
|
||||||
|
// serve / hot reload — that's `bun run dev`). Exists as a script rather than a
|
||||||
|
// bare `bunx electrobun dev` in package.json so the Linux GPU workaround can be
|
||||||
|
// injected into the environment before the app process starts.
|
||||||
|
import { launchEnv } from "./linux-gpu";
|
||||||
|
|
||||||
|
const app = Bun.spawn(["bunx", "electrobun", "dev"], {
|
||||||
|
stdout: "inherit",
|
||||||
|
stderr: "inherit",
|
||||||
|
env: launchEnv(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function shutdown(): void {
|
||||||
|
app.kill();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
|
|
||||||
|
process.exit(await app.exited);
|
||||||
+1
-1
@@ -19,7 +19,7 @@
|
|||||||
header @feed Cache-Control "no-cache"
|
header @feed Cache-Control "no-cache"
|
||||||
|
|
||||||
# Installers/payloads keep stable names but get overwritten each release.
|
# Installers/payloads keep stable names but get overwritten each release.
|
||||||
@pkg path *.dmg *.zip *.tar.zst
|
@pkg path *.dmg *.zip *.tar.zst *.tar.gz
|
||||||
header @pkg Cache-Control "public, max-age=300"
|
header @pkg Cache-Control "public, max-age=300"
|
||||||
|
|
||||||
# browse => directory listing; file_server serves range requests
|
# browse => directory listing; file_server serves range requests
|
||||||
|
|||||||
+7
-5
@@ -42,7 +42,7 @@ electrobun can't cross-compile, so each OS/arch is built on its own machine. On
|
|||||||
machine, after producing artifacts, drop them into the shared `dl/` folder:
|
machine, after producing artifacts, drop them into the shared `dl/` folder:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun scripts/release.ts # or --target mac-x64 / win-x64
|
bun scripts/release.ts # or --target win-x64 / linux-x64
|
||||||
site/deploy.sh # -> /srv/miniscp/dl
|
site/deploy.sh # -> /srv/miniscp/dl
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -56,14 +56,16 @@ rsync -av ./_dl/ user@host:/srv/miniscp/dl/
|
|||||||
That's the whole workflow: **build, drop the artifacts, done.** Installers keep stable names and
|
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.
|
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 (upcoming)
|
> **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.
|
> built-in updater — removing them forces older installs to re-download the full app.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Downloads live under `/dl/` and are browsable at `http://<host>/dl/`.
|
- Downloads live under `/dl/` and are browsable at `http://<host>/dl/`.
|
||||||
- The page links to fixed, stable filenames (`stable-macos-arm64-miniSCP.dmg`, …) so it stays
|
- The page links to fixed, stable filenames (`macos-arm64-miniSCP.dmg`, `win-x64-miniSCP-Setup.zip`,
|
||||||
static and always points at the latest build.
|
…) 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
|
- Builds are currently unsigned — the FAQ on the page covers the macOS `xattr` step and the
|
||||||
Windows SmartScreen prompt.
|
Windows SmartScreen prompt.
|
||||||
- `/dl/` is also the intended `release.baseUrl` for the future electrobun auto-updater.
|
- `/dl/` is also the electrobun auto-updater's `release.baseUrl`.
|
||||||
|
|||||||
+9
-6
@@ -3,7 +3,7 @@ set -euo pipefail
|
|||||||
# Publish this machine's built artifacts into the served flat download folder.
|
# 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.:
|
# Run from the repo root AFTER building for this host's target, e.g.:
|
||||||
# bun scripts/release.ts # or --target mac-x64 / win-x64
|
# bun scripts/release.ts # or --target win-x64 / linux-x64
|
||||||
# site/deploy.sh
|
# site/deploy.sh
|
||||||
#
|
#
|
||||||
# electrobun cannot cross-compile, so each OS/arch is built (and deployed) on its
|
# electrobun cannot cross-compile, so each OS/arch is built (and deployed) on its
|
||||||
@@ -19,12 +19,15 @@ set -euo pipefail
|
|||||||
dl="${MINISCP_DL_DIR:-/srv/miniscp/dl}"
|
dl="${MINISCP_DL_DIR:-/srv/miniscp/dl}"
|
||||||
mkdir -p "$dl"
|
mkdir -p "$dl"
|
||||||
|
|
||||||
# electrobun's flat artifacts (stable-<os>-<arch>-*): installers, update payloads,
|
# electrobun's flat artifacts, verbatim — the updater contract, never rename them:
|
||||||
# update.json, and (once release.baseUrl is set) .patch files.
|
# the update feed (stable-<os>-<arch>-update.json, .tar.zst, .patch) plus the
|
||||||
if compgen -G 'artifacts/stable-*' >/dev/null; then
|
# installers, which 2.x names without the channel prefix (macos-arm64-miniSCP.dmg,
|
||||||
cp -f artifacts/stable-* "$dl"/
|
# 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
|
else
|
||||||
echo "deploy: WARN — no artifacts/stable-* found (did scripts/release.ts run?)" >&2
|
echo "deploy: WARN — artifacts/ is empty (did scripts/release.ts run?)" >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Windows portable zip is a release.ts extra (build-numbered) → give it a stable name.
|
# Windows portable zip is a release.ts extra (build-numbered) → give it a stable name.
|
||||||
|
|||||||
+4
-5
@@ -215,9 +215,8 @@
|
|||||||
<h2 id="download">Download</h2>
|
<h2 id="download">Download</h2>
|
||||||
<p>Always the latest stable build.</p>
|
<p>Always the latest stable build.</p>
|
||||||
<div class="downloads">
|
<div class="downloads">
|
||||||
<a class="dl" href="/dl/stable-macos-arm64-miniSCP.dmg">macOS<small>Apple Silicon</small></a>
|
<a class="dl" href="/dl/macos-arm64-miniSCP.dmg">macOS<small>Apple Silicon</small></a>
|
||||||
<a class="dl" href="/dl/stable-macos-x64-miniSCP.dmg">macOS<small>Intel</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-Setup.zip">Windows<small>Installer</small></a>
|
|
||||||
<a class="dl" href="/dl/stable-win-x64-miniSCP-portable.zip">Windows<small>Portable</small></a>
|
<a class="dl" href="/dl/stable-win-x64-miniSCP-portable.zip">Windows<small>Portable</small></a>
|
||||||
</div>
|
</div>
|
||||||
<p class="browse">Looking for everything? <a href="/dl/">browse all files</a>.</p>
|
<p class="browse">Looking for everything? <a href="/dl/">browse all files</a>.</p>
|
||||||
@@ -226,8 +225,8 @@
|
|||||||
<h2 id="faq">FAQ</h2>
|
<h2 id="faq">FAQ</h2>
|
||||||
<dl class="faqtable">
|
<dl class="faqtable">
|
||||||
<dt>Which platforms are supported?</dt>
|
<dt>Which platforms are supported?</dt>
|
||||||
<dd>macOS (Apple Silicon and Intel) and Windows 11+ (x64). macOS is the primary target;
|
<dd>macOS (Apple Silicon) and Windows 11+ (x64). macOS is the primary target;
|
||||||
Windows is currently in beta.</dd>
|
Windows is currently in beta. Intel Macs are not supported.</dd>
|
||||||
|
|
||||||
<dt>macOS says the app is "damaged and can't be opened".</dt>
|
<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
|
<dd>The builds aren't code-signed yet, so macOS quarantines them on download. Clear the
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
import { ContextMenu, Utils, type ApplicationMenuItemConfig } from "electrobun/bun";
|
|
||||||
import type { ColumnKey } from "../shared/types";
|
|
||||||
import { t } from "./i18n";
|
|
||||||
|
|
||||||
/** Optional list columns, in display order (Name is fixed). */
|
|
||||||
const COLUMN_KEYS: ColumnKey[] = ["size", "mtime", "kind", "rights", "owner", "group"];
|
|
||||||
|
|
||||||
// PORTABILITY: shell-integration wording per OS (resolved at show() for locale)
|
|
||||||
const IS_WIN = process.platform === "win32";
|
|
||||||
const revealLabel = (): string =>
|
|
||||||
t(
|
|
||||||
process.platform === "darwin"
|
|
||||||
? "ctx.revealFinder"
|
|
||||||
: IS_WIN
|
|
||||||
? "ctx.revealExplorer"
|
|
||||||
: "ctx.revealFileManager",
|
|
||||||
);
|
|
||||||
const trashLabel = (): string => t(IS_WIN ? "ctx.moveToRecycleBin" : "ctx.moveToTrash");
|
|
||||||
|
|
||||||
export interface RowMenuContext {
|
|
||||||
path: string;
|
|
||||||
name: string;
|
|
||||||
type: "file" | "dir" | "symlink";
|
|
||||||
isLocal: boolean;
|
|
||||||
hasEntry: boolean; // false = right-click on empty panel space
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Native right-click menus for the file panels. Local-only items run directly
|
|
||||||
* here via Utils (reveal, open with default app, copy path); everything else
|
|
||||||
* forwards to the webview through the menuAction channel — the UI kept its own
|
|
||||||
* context (which panel/row was clicked) when it requested the menu.
|
|
||||||
*/
|
|
||||||
export class PanelContextMenu {
|
|
||||||
private context: RowMenuContext | null = null;
|
|
||||||
/** Forwards actions the UI must handle (ctx-open, ctx-delete, ...). */
|
|
||||||
onAction: (action: string) => void = () => {};
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
ContextMenu.on("context-menu-clicked", (event) => {
|
|
||||||
const action = (event as { data?: { action?: string } }).data?.action;
|
|
||||||
if (!action) return;
|
|
||||||
// column toggles carry no row context
|
|
||||||
if (action.startsWith("col:")) {
|
|
||||||
this.onAction(action);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const ctx = this.context;
|
|
||||||
if (!ctx) return;
|
|
||||||
switch (action) {
|
|
||||||
case "ctx-reveal":
|
|
||||||
Utils.showItemInFolder(ctx.path);
|
|
||||||
break;
|
|
||||||
case "ctx-open-default":
|
|
||||||
Utils.openPath(ctx.path);
|
|
||||||
break;
|
|
||||||
case "ctx-copy-path":
|
|
||||||
Utils.clipboardWriteText(ctx.path);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
if (action.startsWith("ctx-")) this.onAction(action);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
show(context: RowMenuContext): void {
|
|
||||||
this.context = context;
|
|
||||||
ContextMenu.showContextMenu(this.build(context));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Column picker for the list headers: checkable toggles per column. */
|
|
||||||
showColumns(visible: ColumnKey[]): void {
|
|
||||||
ContextMenu.showContextMenu(
|
|
||||||
COLUMN_KEYS.map((key) => ({
|
|
||||||
label: t(`columns.${key}`),
|
|
||||||
action: `col:${key}`,
|
|
||||||
checked: visible.includes(key),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private build(ctx: RowMenuContext): ApplicationMenuItemConfig[] {
|
|
||||||
const items: ApplicationMenuItemConfig[] = [];
|
|
||||||
|
|
||||||
if (!ctx.hasEntry || ctx.name === "..") {
|
|
||||||
// empty space (or the parent row): folder-level actions
|
|
||||||
items.push({ label: t("ctx.newFolder"), action: "ctx-new-folder" });
|
|
||||||
items.push({ label: t("ctx.newFile"), action: "ctx-new-file" });
|
|
||||||
items.push({ type: "separator" });
|
|
||||||
items.push({ label: t("ctx.refresh"), action: "ctx-refresh" });
|
|
||||||
if (ctx.isLocal) {
|
|
||||||
items.push({ type: "separator" });
|
|
||||||
items.push({ label: revealLabel(), action: "ctx-reveal" });
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
items.push({ label: t("ctx.copyToOther"), action: "ctx-copy" });
|
|
||||||
if (ctx.type === "dir") {
|
|
||||||
items.push({ label: t("ctx.open"), action: "ctx-open" });
|
|
||||||
} else {
|
|
||||||
if (ctx.isLocal) {
|
|
||||||
items.push({ label: t("ctx.open"), action: "ctx-open-default", tooltip: t("ctx.openDefault") });
|
|
||||||
}
|
|
||||||
items.push({ label: t("ctx.edit"), action: "ctx-edit", accelerator: "e" });
|
|
||||||
}
|
|
||||||
items.push({ label: t("ctx.rename"), action: "ctx-rename" });
|
|
||||||
// chmod on Windows local files only flips the read-only bit — hide the lie
|
|
||||||
if (!(ctx.isLocal && IS_WIN)) {
|
|
||||||
items.push({ label: t("ctx.permissions"), action: "ctx-chmod" });
|
|
||||||
}
|
|
||||||
items.push({ type: "separator" });
|
|
||||||
items.push({ label: t("ctx.copyPath"), action: "ctx-copy-path" });
|
|
||||||
if (ctx.isLocal) {
|
|
||||||
items.push({ label: revealLabel(), action: "ctx-reveal" });
|
|
||||||
}
|
|
||||||
items.push({ type: "separator" });
|
|
||||||
if (ctx.isLocal) {
|
|
||||||
items.push({
|
|
||||||
label: trashLabel(),
|
|
||||||
action: "ctx-trash",
|
|
||||||
...(process.platform === "darwin" ? { tooltip: t("ctx.trashTooltip") } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
items.push({ label: t("ctx.delete"), action: "ctx-delete" });
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
+68
-22
@@ -1,8 +1,7 @@
|
|||||||
import Electrobun, { ApplicationMenu, BrowserWindow, Updater, Utils } from "electrobun/bun";
|
import Electrobun, { ApplicationMenu, BrowserWindow, Updater, Utils } from "electrobun/main";
|
||||||
import { BRANDING } from "../shared/branding";
|
import { BRANDING } from "../shared/branding";
|
||||||
import type { EditorOpenContext } from "../shared/rpc";
|
import type { EditorOpenContext } from "../shared/rpc";
|
||||||
import { effectiveConcurrency, type AppSettings } from "../shared/types";
|
import { effectiveConcurrency, type AppSettings } from "../shared/types";
|
||||||
import { PanelContextMenu } from "./context-menu";
|
|
||||||
import { EditManager } from "./edits";
|
import { EditManager } from "./edits";
|
||||||
import { EditorWindows } from "./editor-windows";
|
import { EditorWindows } from "./editor-windows";
|
||||||
import { SessionRegistry } from "./fs/registry";
|
import { SessionRegistry } from "./fs/registry";
|
||||||
@@ -11,12 +10,13 @@ import { ManagerWindow } from "./manager-window";
|
|||||||
import { setLocale as setBackendLocale } from "./i18n";
|
import { setLocale as setBackendLocale } from "./i18n";
|
||||||
import { applyApplicationMenu } from "./menu";
|
import { applyApplicationMenu } from "./menu";
|
||||||
import { createAppRPC } from "./rpc-handlers";
|
import { createAppRPC } from "./rpc-handlers";
|
||||||
|
import { SettingsWindow } from "./settings-window";
|
||||||
import { ConnectionStore } from "./store/connections";
|
import { ConnectionStore } from "./store/connections";
|
||||||
import { migrateLegacyDataDir } from "./store/paths";
|
import { migrateLegacyDataDir } from "./store/paths";
|
||||||
import { SettingsStore } from "./store/settings";
|
import { SettingsStore } from "./store/settings";
|
||||||
import { AppTray } from "./tray";
|
import { AppTray } from "./tray";
|
||||||
import { TransferEngine } from "./transfers/engine";
|
import { TransferEngine } from "./transfers/engine";
|
||||||
import { buildPreload, showWhenReady } from "./windows";
|
import { buildPreload, chromeOptions, showWhenReady } from "./windows";
|
||||||
|
|
||||||
const PROD_URL = "views://app/index.html";
|
const PROD_URL = "views://app/index.html";
|
||||||
|
|
||||||
@@ -46,7 +46,6 @@ 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 watcher = new LocalDirWatcher();
|
||||||
const events = {
|
const events = {
|
||||||
sessionClosed: (_info: { sessionId: string; reason: string }) => {},
|
sessionClosed: (_info: { sessionId: string; reason: string }) => {},
|
||||||
@@ -54,9 +53,11 @@ const events = {
|
|||||||
openEditor: (_ctx: EditorOpenContext) => {},
|
openEditor: (_ctx: EditorOpenContext) => {},
|
||||||
settingsChanged: (_settings: AppSettings) => {},
|
settingsChanged: (_settings: AppSettings) => {},
|
||||||
openConnectionManager: () => {},
|
openConnectionManager: () => {},
|
||||||
|
openSettings: () => {},
|
||||||
showAbout: () => {},
|
showAbout: () => {},
|
||||||
|
applyUpdate: async () => {},
|
||||||
};
|
};
|
||||||
const rpc = createAppRPC({ registry, engine, store, settings, edits, contextMenu, watcher, events });
|
const rpc = createAppRPC({ registry, engine, store, settings, edits, watcher, events });
|
||||||
// late-bound like events: a watched local dir changed -> tell the main webview
|
// late-bound like events: a watched local dir changed -> tell the main webview
|
||||||
watcher.onChange = (dir) => rpc.send.pathRefresh({ sessionId: "local", dir });
|
watcher.onChange = (dir) => rpc.send.pathRefresh({ sessionId: "local", dir });
|
||||||
const url = await getMainViewUrl();
|
const url = await getMainViewUrl();
|
||||||
@@ -67,8 +68,6 @@ const url = await getMainViewUrl();
|
|||||||
let currentSettings = await settings.load();
|
let currentSettings = await settings.load();
|
||||||
setBackendLocale(currentSettings.locale); // native menus/errors follow the UI language
|
setBackendLocale(currentSettings.locale); // native menus/errors follow the UI language
|
||||||
|
|
||||||
const isMac = process.platform === "darwin";
|
|
||||||
|
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
title: BRANDING.appName,
|
title: BRANDING.appName,
|
||||||
url,
|
url,
|
||||||
@@ -78,12 +77,8 @@ const win = new BrowserWindow({
|
|||||||
x: 120,
|
x: 120,
|
||||||
y: 120,
|
y: 120,
|
||||||
},
|
},
|
||||||
// macOS: fuse the tab bar with the titlebar — traffic lights overlay the
|
// frame decision lives in chromeOptions (windows.ts); constructor-only
|
||||||
// UI's h-10 tab row (offset centers them vertically). Other platforms keep
|
...chromeOptions(),
|
||||||
// the native titlebar and the tab bar stays a separate row.
|
|
||||||
...(isMac
|
|
||||||
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
|
||||||
: {}),
|
|
||||||
// lets the UI pick the fused layout + theme before first paint
|
// lets the UI pick the fused layout + theme before first paint
|
||||||
preload: buildPreload("main", currentSettings),
|
preload: buildPreload("main", currentSettings),
|
||||||
rpc,
|
rpc,
|
||||||
@@ -100,6 +95,7 @@ const editors = new EditorWindows({
|
|||||||
currentSettings: () => currentSettings,
|
currentSettings: () => currentSettings,
|
||||||
mainFrame: () => win.getFrame(),
|
mainFrame: () => win.getFrame(),
|
||||||
notifyMain: { pathRefresh: (info) => rpc.send.pathRefresh(info) },
|
notifyMain: { pathRefresh: (info) => rpc.send.pathRefresh(info) },
|
||||||
|
onSettingsSaved: (s, key) => settingsSaved(s, { editor: key }),
|
||||||
});
|
});
|
||||||
const manager = new ManagerWindow({
|
const manager = new ManagerWindow({
|
||||||
store,
|
store,
|
||||||
@@ -113,20 +109,45 @@ const manager = new ManagerWindow({
|
|||||||
menuAction: (info) => rpc.send.menuAction(info),
|
menuAction: (info) => rpc.send.menuAction(info),
|
||||||
},
|
},
|
||||||
rebuildMenu: () => void applyApplicationMenu(store),
|
rebuildMenu: () => void applyApplicationMenu(store),
|
||||||
|
onSettingsSaved: (s) => settingsSaved(s, "manager"),
|
||||||
|
});
|
||||||
|
const settingsWin = new SettingsWindow({
|
||||||
|
settings,
|
||||||
|
url,
|
||||||
|
currentSettings: () => currentSettings,
|
||||||
|
mainFrame: () => win.getFrame(),
|
||||||
|
onSettingsSaved: (s) => settingsSaved(s, "settings"),
|
||||||
|
showAbout: () => showAbout(),
|
||||||
});
|
});
|
||||||
|
|
||||||
events.openEditor = (ctx) => editors.open(ctx);
|
events.openEditor = (ctx) => editors.open(ctx);
|
||||||
events.settingsChanged = (s) => {
|
/**
|
||||||
|
* Every settings save lands here, whichever window made it (editor and manager
|
||||||
|
* windows save too — the scale shortcuts — and the Settings window is nothing
|
||||||
|
* but saves): side effects run once, then every OTHER window follows. The
|
||||||
|
* sender is skipped — it already shows the new values, and an echo arriving
|
||||||
|
* behind a newer local change (a held Cmd+) would drag it backwards.
|
||||||
|
*/
|
||||||
|
function settingsSaved(
|
||||||
|
s: AppSettings,
|
||||||
|
sender: "main" | "manager" | "settings" | { editor: string },
|
||||||
|
): void {
|
||||||
|
engine.setConcurrency(effectiveConcurrency(s));
|
||||||
const localeChanged = s.locale !== currentSettings.locale;
|
const localeChanged = s.locale !== currentSettings.locale;
|
||||||
currentSettings = s; // future windows' preloads bake the fresh theme
|
currentSettings = s; // future windows' preloads bake the fresh theme
|
||||||
if (localeChanged) {
|
if (localeChanged) {
|
||||||
setBackendLocale(s.locale);
|
setBackendLocale(s.locale);
|
||||||
void applyApplicationMenu(store); // relabel the native menu in the new language
|
void applyApplicationMenu(store); // relabel the native menu in the new language
|
||||||
|
settingsWin.retitle();
|
||||||
}
|
}
|
||||||
editors.broadcastSettings(s);
|
if (sender !== "main") rpc.send.settingsChanged({ settings: s });
|
||||||
manager.broadcastSettings(s);
|
if (sender !== "manager") manager.broadcastSettings(s);
|
||||||
};
|
if (sender !== "settings") settingsWin.broadcastSettings(s);
|
||||||
|
editors.broadcastSettings(s, typeof sender === "object" ? sender.editor : undefined);
|
||||||
|
}
|
||||||
|
events.settingsChanged = (s) => settingsSaved(s, "main");
|
||||||
events.openConnectionManager = () => manager.open();
|
events.openConnectionManager = () => manager.open();
|
||||||
|
events.openSettings = () => settingsWin.open();
|
||||||
|
|
||||||
function showAbout(): void {
|
function showAbout(): void {
|
||||||
const { build } = BRANDING;
|
const { build } = BRANDING;
|
||||||
@@ -145,11 +166,11 @@ events.showAbout = showAbout;
|
|||||||
// and the modal manager has nothing to guard
|
// and the modal manager has nothing to guard
|
||||||
win.on("close", () => {
|
win.on("close", () => {
|
||||||
manager.close();
|
manager.close();
|
||||||
|
settingsWin.close();
|
||||||
editors.closeAllForAppExit();
|
editors.closeAllForAppExit();
|
||||||
});
|
});
|
||||||
|
|
||||||
new AppTray(win);
|
new AppTray(win);
|
||||||
contextMenu.onAction = (action) => rpc.send.menuAction({ action });
|
|
||||||
|
|
||||||
// notify on finished transfers while the window is in the background
|
// notify on finished transfers while the window is in the background
|
||||||
let windowFocused = true;
|
let windowFocused = true;
|
||||||
@@ -170,6 +191,7 @@ engine.onUpdate = (snapshot) => {
|
|||||||
if (
|
if (
|
||||||
!windowFocused &&
|
!windowFocused &&
|
||||||
!editors.anyFocused() &&
|
!editors.anyFocused() &&
|
||||||
|
!settingsWin.isFocused() &&
|
||||||
(snapshot.state === "completed" || snapshot.state === "failed")
|
(snapshot.state === "completed" || snapshot.state === "failed")
|
||||||
) {
|
) {
|
||||||
Utils.showNotification({
|
Utils.showNotification({
|
||||||
@@ -190,11 +212,24 @@ engine.onUpdate = (snapshot) => {
|
|||||||
|
|
||||||
// don't quit out from under running transfers or unsaved editors
|
// don't quit out from under running transfers or unsaved editors
|
||||||
let quitConfirmed = false;
|
let quitConfirmed = false;
|
||||||
|
// Updater.applyUpdate() asks this guard first (2.x), so remember the quit is
|
||||||
|
// update-initiated: "Quit Anyway" must re-run the apply, or a plain quit would
|
||||||
|
// drop the downloaded update.
|
||||||
|
let applyingUpdate = false;
|
||||||
|
events.applyUpdate = async () => {
|
||||||
|
applyingUpdate = true;
|
||||||
|
try {
|
||||||
|
await Updater.applyUpdate();
|
||||||
|
} finally {
|
||||||
|
applyingUpdate = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
Electrobun.events.on("before-quit", (event) => {
|
Electrobun.events.on("before-quit", (event) => {
|
||||||
const transfers = engine.activeJobCount();
|
const transfers = engine.activeJobCount();
|
||||||
const dirtyEditors = editors.dirtyCount();
|
const dirtyEditors = editors.dirtyCount();
|
||||||
if (quitConfirmed || (transfers === 0 && dirtyEditors === 0)) return;
|
if (quitConfirmed || (transfers === 0 && dirtyEditors === 0)) return;
|
||||||
(event as { response: { allow: boolean } }).response = { allow: false };
|
(event as { response: { allow: boolean } }).response = { allow: false };
|
||||||
|
const updating = applyingUpdate; // read now: it clears once the vetoed apply returns
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const parts = [
|
const parts = [
|
||||||
transfers > 0 && `${transfers} transfer${transfers === 1 ? " is" : "s are"} still running`,
|
transfers > 0 && `${transfers} transfer${transfers === 1 ? " is" : "s are"} still running`,
|
||||||
@@ -212,7 +247,10 @@ Electrobun.events.on("before-quit", (event) => {
|
|||||||
});
|
});
|
||||||
if (response === 0) {
|
if (response === 0) {
|
||||||
quitConfirmed = true;
|
quitConfirmed = true;
|
||||||
Utils.quit();
|
// the trailing quit is a no-op once the apply is quitting, and still
|
||||||
|
// quits if it bails (no prepared download)
|
||||||
|
if (updating) void Updater.applyUpdate().finally(() => Utils.quit());
|
||||||
|
else Utils.quit();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
@@ -227,10 +265,14 @@ void applyApplicationMenu(store);
|
|||||||
ApplicationMenu.on("application-menu-clicked", (event) => {
|
ApplicationMenu.on("application-menu-clicked", (event) => {
|
||||||
const action = (event as { data?: { action?: string } }).data?.action;
|
const action = (event as { data?: { action?: string } }).data?.action;
|
||||||
if (!action) return;
|
if (!action) return;
|
||||||
// ⌘W while an editor window is focused closes that editor (with its dirty
|
// ⌘W while the Settings window or an editor window is focused closes THAT
|
||||||
// prompt), not a main-window tab. Other menu actions deliberately still
|
// window (an editor with its dirty prompt), not a main-window tab. Other
|
||||||
// target the main window.
|
// menu actions deliberately still target the main window.
|
||||||
if (action === "close-tab") {
|
if (action === "close-tab") {
|
||||||
|
if (settingsWin.isFocused()) {
|
||||||
|
settingsWin.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const focusedEditor = editors.focusedKey();
|
const focusedEditor = editors.focusedKey();
|
||||||
if (focusedEditor) {
|
if (focusedEditor) {
|
||||||
editors.requestClose(focusedEditor);
|
editors.requestClose(focusedEditor);
|
||||||
@@ -241,6 +283,10 @@ ApplicationMenu.on("application-menu-clicked", (event) => {
|
|||||||
manager.open();
|
manager.open();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action === "settings") {
|
||||||
|
settingsWin.open();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (action === "about") {
|
if (action === "about") {
|
||||||
showAbout();
|
showAbout();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { BrowserWindow } from "electrobun/bun";
|
import type { BrowserWindow } from "electrobun/main";
|
||||||
import type { AppSettings } from "../shared/types";
|
import type { AppSettings } from "../shared/types";
|
||||||
import { createManagerRPC } from "./rpc-handlers";
|
import { createManagerRPC } from "./rpc-handlers";
|
||||||
import type { ConnectionStore } from "./store/connections";
|
import type { ConnectionStore } from "./store/connections";
|
||||||
@@ -24,6 +24,8 @@ export interface ManagerWindowDeps {
|
|||||||
};
|
};
|
||||||
/** Rebuilds the native app menu after store changes. */
|
/** Rebuilds the native app menu after store changes. */
|
||||||
rebuildMenu: () => void;
|
rebuildMenu: () => void;
|
||||||
|
/** The manager saved settings — rebroadcast them to every other window. */
|
||||||
|
onSettingsSaved: (settings: AppSettings) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,6 +72,7 @@ export class ManagerWindow {
|
|||||||
this.close();
|
this.close();
|
||||||
},
|
},
|
||||||
onClose: () => this.close(),
|
onClose: () => this.close(),
|
||||||
|
onSettingsSaved: (next) => this.deps.onSettingsSaved(next),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -1,4 +1,4 @@
|
|||||||
import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/bun";
|
import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/main";
|
||||||
import { BRANDING } from "../shared/branding";
|
import { BRANDING } from "../shared/branding";
|
||||||
import type { SavedConnection } from "../shared/types";
|
import type { SavedConnection } from "../shared/types";
|
||||||
import { t } from "./i18n";
|
import { t } from "./i18n";
|
||||||
@@ -36,6 +36,8 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
|
|||||||
submenu: [
|
submenu: [
|
||||||
{ label: t("menu.about", { app: BRANDING.appName }), action: "about" },
|
{ label: t("menu.about", { app: BRANDING.appName }), action: "about" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
|
{ label: t("menu.settings"), action: "settings", accelerator: "," },
|
||||||
|
{ type: "separator" },
|
||||||
{ label: t("menu.checkForUpdates"), action: "check-for-updates" },
|
{ label: t("menu.checkForUpdates"), action: "check-for-updates" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "hide" },
|
{ role: "hide" },
|
||||||
|
|||||||
+89
-42
@@ -1,7 +1,6 @@
|
|||||||
import { BrowserView, Updater, Utils } from "electrobun/bun";
|
import { BrowserView, Updater, Utils } from "electrobun/main";
|
||||||
import type { AppRPC, EditorOpenContext } from "../shared/rpc";
|
import type { AppRPC, EditorOpenContext } from "../shared/rpc";
|
||||||
import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types";
|
import type { AppSettings, Result } from "../shared/types";
|
||||||
import type { PanelContextMenu } from "./context-menu";
|
|
||||||
import { readFileText, writeFileText, type EditManager } from "./edits";
|
import { readFileText, writeFileText, type EditManager } from "./edits";
|
||||||
import { listLocalDrives } from "./fs/local";
|
import { listLocalDrives } from "./fs/local";
|
||||||
import type { SessionRegistry } from "./fs/registry";
|
import type { SessionRegistry } from "./fs/registry";
|
||||||
@@ -24,7 +23,6 @@ export interface AppDeps {
|
|||||||
store: ConnectionStore;
|
store: ConnectionStore;
|
||||||
settings: SettingsStore;
|
settings: SettingsStore;
|
||||||
edits: EditManager;
|
edits: EditManager;
|
||||||
contextMenu: PanelContextMenu;
|
|
||||||
watcher: LocalDirWatcher;
|
watcher: LocalDirWatcher;
|
||||||
/** Assigned after the rpc object exists (it needs rpc.send). */
|
/** Assigned after the rpc object exists (it needs rpc.send). */
|
||||||
events: {
|
events: {
|
||||||
@@ -33,7 +31,10 @@ export interface AppDeps {
|
|||||||
openEditor: (ctx: EditorOpenContext) => void;
|
openEditor: (ctx: EditorOpenContext) => void;
|
||||||
settingsChanged: (settings: AppSettings) => void;
|
settingsChanged: (settings: AppSettings) => void;
|
||||||
openConnectionManager: () => void;
|
openConnectionManager: () => void;
|
||||||
|
openSettings: () => void;
|
||||||
showAbout: () => void;
|
showAbout: () => void;
|
||||||
|
/** "Restart to apply" — lives next to the before-quit guard it must get past. */
|
||||||
|
applyUpdate: () => Promise<void>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +102,22 @@ function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void)
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* saveSettings for every window kind: persist, then hand the new object to the
|
||||||
|
* composition root (index.ts), which applies the side effects once and
|
||||||
|
* rebroadcasts to every OTHER window. Known ceiling: whole-object writes — two
|
||||||
|
* windows saving in the same instant, the later one wins. Upgrade path: send a
|
||||||
|
* patch and merge it bun-side.
|
||||||
|
*/
|
||||||
|
const saveSettingsHandler =
|
||||||
|
(store: SettingsStore, onSaved: (next: AppSettings) => void) =>
|
||||||
|
({ settings: next }: { settings: AppSettings }) =>
|
||||||
|
wrap(async () => {
|
||||||
|
await store.save(next);
|
||||||
|
onSaved(next);
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
const pickKeyFileHandler = () =>
|
const pickKeyFileHandler = () =>
|
||||||
wrap(async () => {
|
wrap(async () => {
|
||||||
const { homedir } = await import("node:os");
|
const { homedir } = await import("node:os");
|
||||||
@@ -116,13 +133,34 @@ const pickKeyFileHandler = () =>
|
|||||||
return { path };
|
return { path };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** External-editor picker, shared by the main and settings window rpcs. */
|
||||||
|
const pickEditorAppHandler = () =>
|
||||||
|
wrap(async () => {
|
||||||
|
// PORTABILITY: apps live in different places per OS
|
||||||
|
const { homedir } = await import("node:os");
|
||||||
|
const [startingFolder, allowedFileTypes] =
|
||||||
|
process.platform === "darwin"
|
||||||
|
? ["/Applications", "app"]
|
||||||
|
: process.platform === "win32"
|
||||||
|
? [process.env.ProgramFiles ?? "C:\\Program Files", "exe"]
|
||||||
|
: [homedir(), "*"];
|
||||||
|
const picked = await Utils.openFileDialog({
|
||||||
|
startingFolder,
|
||||||
|
allowedFileTypes,
|
||||||
|
canChooseFiles: true,
|
||||||
|
canChooseDirectory: false,
|
||||||
|
allowsMultipleSelection: false,
|
||||||
|
});
|
||||||
|
const path = picked.find((p) => p.trim().length > 0) ?? null;
|
||||||
|
return { path };
|
||||||
|
});
|
||||||
|
|
||||||
export function createAppRPC({
|
export function createAppRPC({
|
||||||
registry,
|
registry,
|
||||||
engine,
|
engine,
|
||||||
store,
|
store,
|
||||||
settings,
|
settings,
|
||||||
edits,
|
edits,
|
||||||
contextMenu,
|
|
||||||
watcher,
|
watcher,
|
||||||
events,
|
events,
|
||||||
}: AppDeps) {
|
}: AppDeps) {
|
||||||
@@ -227,13 +265,7 @@ export function createAppRPC({
|
|||||||
// ---- settings ----
|
// ---- settings ----
|
||||||
getSettings: () => wrap(async () => ({ settings: await settings.load() })),
|
getSettings: () => wrap(async () => ({ settings: await settings.load() })),
|
||||||
|
|
||||||
saveSettings: ({ settings: next }) =>
|
saveSettings: saveSettingsHandler(settings, (next) => events.settingsChanged(next)),
|
||||||
wrap(async () => {
|
|
||||||
await settings.save(next);
|
|
||||||
engine.setConcurrency(effectiveConcurrency(next));
|
|
||||||
events.settingsChanged(next);
|
|
||||||
return {};
|
|
||||||
}),
|
|
||||||
|
|
||||||
// ---- file editing ----
|
// ---- file editing ----
|
||||||
readFile: ({ sessionId, path }) =>
|
readFile: ({ sessionId, path }) =>
|
||||||
@@ -250,26 +282,7 @@ export function createAppRPC({
|
|||||||
editExternal: ({ sessionId, path }) =>
|
editExternal: ({ sessionId, path }) =>
|
||||||
wrap(() => edits.editExternal(sessionId, path)),
|
wrap(() => edits.editExternal(sessionId, path)),
|
||||||
|
|
||||||
pickEditorApp: () =>
|
pickEditorApp: pickEditorAppHandler,
|
||||||
wrap(async () => {
|
|
||||||
// PORTABILITY: apps live in different places per OS
|
|
||||||
const { homedir } = await import("node:os");
|
|
||||||
const [startingFolder, allowedFileTypes] =
|
|
||||||
process.platform === "darwin"
|
|
||||||
? ["/Applications", "app"]
|
|
||||||
: process.platform === "win32"
|
|
||||||
? [process.env.ProgramFiles ?? "C:\\Program Files", "exe"]
|
|
||||||
: [homedir(), "*"];
|
|
||||||
const picked = await Utils.openFileDialog({
|
|
||||||
startingFolder,
|
|
||||||
allowedFileTypes,
|
|
||||||
canChooseFiles: true,
|
|
||||||
canChooseDirectory: false,
|
|
||||||
allowsMultipleSelection: false,
|
|
||||||
});
|
|
||||||
const path = picked.find((p) => p.trim().length > 0) ?? null;
|
|
||||||
return { path };
|
|
||||||
}),
|
|
||||||
|
|
||||||
pickKeyFile: pickKeyFileHandler,
|
pickKeyFile: pickKeyFileHandler,
|
||||||
|
|
||||||
@@ -282,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 {};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -295,12 +310,6 @@ export function createAppRPC({
|
|||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
showColumnsMenu: ({ visible }) =>
|
|
||||||
wrap(async () => {
|
|
||||||
contextMenu.showColumns(visible);
|
|
||||||
return {};
|
|
||||||
}),
|
|
||||||
|
|
||||||
// ---- editor windows ----
|
// ---- editor windows ----
|
||||||
openEditor: (ctx) =>
|
openEditor: (ctx) =>
|
||||||
wrap(async () => {
|
wrap(async () => {
|
||||||
@@ -315,6 +324,13 @@ export function createAppRPC({
|
|||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ---- settings window ----
|
||||||
|
openSettings: () =>
|
||||||
|
wrap(async () => {
|
||||||
|
events.openSettings();
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
|
||||||
showAbout: () =>
|
showAbout: () =>
|
||||||
wrap(async () => {
|
wrap(async () => {
|
||||||
events.showAbout();
|
events.showAbout();
|
||||||
@@ -338,7 +354,7 @@ export function createAppRPC({
|
|||||||
|
|
||||||
applyUpdate: () =>
|
applyUpdate: () =>
|
||||||
wrap(async () => {
|
wrap(async () => {
|
||||||
await Updater.applyUpdate(); // relaunches + quits; may never resolve
|
await events.applyUpdate(); // relaunches + quits; may never resolve
|
||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -354,6 +370,8 @@ export interface EditorRpcHooks {
|
|||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
/** Webview resolved its dirty prompt: close without the native fallback. */
|
/** Webview resolved its dirty prompt: close without the native fallback. */
|
||||||
onCloseConfirmed: () => void;
|
onCloseConfirmed: () => void;
|
||||||
|
/** This window saved settings (e.g. the scale shortcuts) — rebroadcast them. */
|
||||||
|
onSettingsSaved: (next: AppSettings) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -375,6 +393,7 @@ export function createEditorRPC(
|
|||||||
})),
|
})),
|
||||||
|
|
||||||
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
|
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
|
||||||
|
saveSettings: saveSettingsHandler(deps.settings, hooks.onSettingsSaved),
|
||||||
|
|
||||||
editorSave: ({ content }) =>
|
editorSave: ({ content }) =>
|
||||||
wrap(async () => {
|
wrap(async () => {
|
||||||
@@ -404,6 +423,8 @@ export interface ManagerRpcHooks {
|
|||||||
onConnect: (id: string) => void;
|
onConnect: (id: string) => void;
|
||||||
onOpenStack: (id: string) => void;
|
onOpenStack: (id: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
/** This window saved settings (e.g. the scale shortcuts) — rebroadcast them. */
|
||||||
|
onSettingsSaved: (next: AppSettings) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** RPC for the single connection-manager window (per-window instance). */
|
/** RPC for the single connection-manager window (per-window instance). */
|
||||||
@@ -418,6 +439,7 @@ export function createManagerRPC(
|
|||||||
...connectionStoreHandlers(deps.store, hooks.onChanged),
|
...connectionStoreHandlers(deps.store, hooks.onChanged),
|
||||||
pickKeyFile: pickKeyFileHandler,
|
pickKeyFile: pickKeyFileHandler,
|
||||||
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
|
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
|
||||||
|
saveSettings: saveSettingsHandler(deps.settings, hooks.onSettingsSaved),
|
||||||
|
|
||||||
managerConnect: ({ id }) =>
|
managerConnect: ({ id }) =>
|
||||||
wrap(async () => {
|
wrap(async () => {
|
||||||
@@ -440,3 +462,28 @@ export function createManagerRPC(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SettingsRpcHooks {
|
||||||
|
/** Every edit here is a settings save — rebroadcast it to the other windows. */
|
||||||
|
onSettingsSaved: (next: AppSettings) => void;
|
||||||
|
showAbout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 {};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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. On macOS ⌘W reaches bun as the main window's close-tab
|
||||||
|
* menu action, so index.ts asks isFocused() first and closes this window
|
||||||
|
* instead of a main-window tab.
|
||||||
|
*/
|
||||||
|
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(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const main = this.deps.mainFrame();
|
||||||
|
this.win = openSecondaryWindow({
|
||||||
|
title: t("options.title"),
|
||||||
|
url: this.deps.url,
|
||||||
|
mode: "settings",
|
||||||
|
settings: this.deps.currentSettings(),
|
||||||
|
frame: {
|
||||||
|
x: Math.max(0, main.x + (main.width - SETTINGS_WIDTH) / 2),
|
||||||
|
y: Math.max(0, main.y + (main.height - SETTINGS_HEIGHT) / 2),
|
||||||
|
width: SETTINGS_WIDTH,
|
||||||
|
height: SETTINGS_HEIGHT,
|
||||||
|
},
|
||||||
|
rpc: this.rpc,
|
||||||
|
});
|
||||||
|
this.win.on("focus", () => (this.focused = true));
|
||||||
|
this.win.on("blur", () => (this.focused = false));
|
||||||
|
this.win.on("close", () => {
|
||||||
|
this.win = null;
|
||||||
|
this.rpc = null;
|
||||||
|
this.focused = false;
|
||||||
|
});
|
||||||
|
console.log("[settings] window opened");
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.win?.close(); // the close event does the cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The native title follows a language switch (the header row is the UI's own). */
|
||||||
|
retitle(): void {
|
||||||
|
this.win?.setTitle(t("options.title"));
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastSettings(settings: AppSettings): void {
|
||||||
|
this.rpc?.send.settingsChanged({ settings });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
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 { 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 +13,18 @@ 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));
|
||||||
|
return settings;
|
||||||
} catch {
|
} catch {
|
||||||
return { ...DEFAULT_SETTINGS };
|
return { ...DEFAULT_SETTINGS };
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { Tray, type BrowserWindow } from "electrobun/bun";
|
import { Tray, type BrowserWindow } from "electrobun/main";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Menu-bar / notification-area icon. Clicking it brings the app window to the
|
* Menu-bar / notification-area icon. Clicking it brings the app window to the
|
||||||
|
|||||||
+31
-19
@@ -1,11 +1,9 @@
|
|||||||
import { BrowserWindow } from "electrobun/bun";
|
import { BrowserWindow } from "electrobun/main";
|
||||||
import type { AppSettings } from "../shared/types";
|
import type { AppSettings } from "../shared/types";
|
||||||
|
|
||||||
const isMac = process.platform === "darwin";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The preload globals every window gets. Single composition point: the UI
|
* The preload globals every window gets. Single composition point: the UI
|
||||||
* reads them in ui/src/app/platform.ts before first paint (window chrome and
|
* reads them in ui/src/app/core/platform.ts before first paint (window chrome and
|
||||||
* root-component selection both depend on them).
|
* root-component selection both depend on them).
|
||||||
*
|
*
|
||||||
* The persisted theme attributes are baked in here — mirroring the
|
* The persisted theme attributes are baked in here — mirroring the
|
||||||
@@ -15,7 +13,7 @@ const isMac = process.platform === "darwin";
|
|||||||
* windows flash the wrong background.
|
* windows flash the wrong background.
|
||||||
*/
|
*/
|
||||||
export function buildPreload(
|
export function buildPreload(
|
||||||
mode: "main" | "editor" | "manager",
|
mode: "main" | "editor" | "manager" | "settings",
|
||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
ctx?: unknown,
|
ctx?: unknown,
|
||||||
): string {
|
): string {
|
||||||
@@ -27,14 +25,37 @@ export function buildPreload(
|
|||||||
const dataset: Record<string, string> = {};
|
const dataset: Record<string, string> = {};
|
||||||
if (settings.theme !== "system") dataset["theme"] = settings.theme;
|
if (settings.theme !== "system") dataset["theme"] = settings.theme;
|
||||||
if (settings.darkTone !== "dark") dataset["darkTone"] = settings.darkTone;
|
if (settings.darkTone !== "dark") dataset["darkTone"] = settings.darkTone;
|
||||||
if (settings.accent !== "graphite") dataset["accent"] = settings.accent;
|
// interface scale: mirrors SettingsService.applyUiScale so the FIRST paint
|
||||||
if (settings.textSize !== "default") dataset["text"] = settings.textSize;
|
// is already scaled — without this every window opens at 100% and snaps.
|
||||||
|
// Written as an inline style, not a data attribute: the scale is continuous.
|
||||||
|
if (settings.uiScale !== 1) {
|
||||||
|
script += `document.documentElement.style.fontSize=${JSON.stringify(`${16 * settings.uiScale}px`)};`;
|
||||||
|
}
|
||||||
for (const [key, value] of Object.entries(dataset)) {
|
for (const [key, value] of Object.entries(dataset)) {
|
||||||
script += `document.documentElement.dataset[${JSON.stringify(key)}]=${JSON.stringify(value)};`;
|
script += `document.documentElement.dataset[${JSON.stringify(key)}]=${JSON.stringify(value)};`;
|
||||||
}
|
}
|
||||||
return script;
|
return script;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native chrome for every window — the ONE place the frame is decided; the main
|
||||||
|
* window (src/bun/index.ts) and openSecondaryWindow both spread this. macOS
|
||||||
|
* fuses the titlebar with the app's own header row (hiddenInset: the traffic
|
||||||
|
* lights overlay it, which is what IS_MAC_CHROME reserves pl-24 for); other
|
||||||
|
* platforms keep the native titlebar. Constructor-only: there is no API to
|
||||||
|
* re-decorate a live window.
|
||||||
|
*
|
||||||
|
* electrobun 2.x adds trafficLightOffset to macOS's default button position
|
||||||
|
* (9/9 in the 32pt macOS 26 titlebar) and grows the titlebar to fit — 1.18.1
|
||||||
|
* clamped the shift inside the stock titlebar instead. {12, 4} puts the 14pt
|
||||||
|
* buttons at left 21 / top 13: vertically centred in the 40px h-10 header.
|
||||||
|
*/
|
||||||
|
export function chromeOptions() {
|
||||||
|
return process.platform === "darwin"
|
||||||
|
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 4 } }
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Windows are created hidden and revealed on the webview's first dom-ready —
|
* Windows are created hidden and revealed on the webview's first dom-ready —
|
||||||
* showing them earlier paints the native (white) surface before the themed
|
* showing them earlier paints the native (white) surface before the themed
|
||||||
@@ -59,7 +80,7 @@ export interface SecondaryWindowOptions {
|
|||||||
title: string;
|
title: string;
|
||||||
/** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */
|
/** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */
|
||||||
url: string;
|
url: string;
|
||||||
mode: "editor" | "manager";
|
mode: "editor" | "manager" | "settings";
|
||||||
/** Current persisted settings — baked into the preload for flash-free paint. */
|
/** Current persisted settings — baked into the preload for flash-free paint. */
|
||||||
settings: AppSettings;
|
settings: AppSettings;
|
||||||
ctx?: unknown;
|
ctx?: unknown;
|
||||||
@@ -69,22 +90,13 @@ export interface SecondaryWindowOptions {
|
|||||||
rpc: NonNullable<ConstructorParameters<typeof BrowserWindow>[0]>["rpc"];
|
rpc: NonNullable<ConstructorParameters<typeof BrowserWindow>[0]>["rpc"];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** App-chromed secondary window; its frame comes from chromeOptions. */
|
||||||
* App-chromed secondary window.
|
|
||||||
*
|
|
||||||
* PORTABILITY: the only spot that branches on platform for secondary-window
|
|
||||||
* chrome. macOS fuses the titlebar (traffic lights overlay the UI's h-10
|
|
||||||
* header row, same treatment as the main window); Windows/Linux keep the
|
|
||||||
* native titlebar and the UI renders a compact non-drag header instead.
|
|
||||||
*/
|
|
||||||
export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow {
|
export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow {
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
title: opts.title,
|
title: opts.title,
|
||||||
url: opts.url,
|
url: opts.url,
|
||||||
frame: opts.frame,
|
frame: opts.frame,
|
||||||
...(isMac
|
...chromeOptions(),
|
||||||
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
|
||||||
: {}),
|
|
||||||
preload: buildPreload(opts.mode, opts.settings, opts.ctx),
|
preload: buildPreload(opts.mode, opts.settings, opts.ctx),
|
||||||
rpc: opts.rpc,
|
rpc: opts.rpc,
|
||||||
hidden: true,
|
hidden: true,
|
||||||
|
|||||||
+18
-16
@@ -23,31 +23,27 @@ export const de = {
|
|||||||
manageConnections: "Verbindungen verwalten",
|
manageConnections: "Verbindungen verwalten",
|
||||||
newTab: "Neuer Tab ({mod}T)",
|
newTab: "Neuer Tab ({mod}T)",
|
||||||
closeTab: "Tab schließen ({mod}W)",
|
closeTab: "Tab schließen ({mod}W)",
|
||||||
options: "Optionen",
|
options: "Einstellungen",
|
||||||
update: "Update verfügbar",
|
update: "Update verfügbar",
|
||||||
updating: "Aktualisiere",
|
updating: "Aktualisiere",
|
||||||
restartToUpdate: "Zum Anwenden neu starten",
|
restartToUpdate: "Zum Anwenden neu starten",
|
||||||
updateTooltip: "Update verfügbar: miniSCP {version}",
|
updateTooltip: "Update verfügbar: miniSCP {version}",
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
title: "Optionen",
|
title: "Einstellungen",
|
||||||
|
sections: {
|
||||||
|
appearance: "Darstellung",
|
||||||
|
transfers: "Übertragungen",
|
||||||
|
editor: "Editor",
|
||||||
|
fileList: "Dateiliste",
|
||||||
|
about: "Über",
|
||||||
|
},
|
||||||
|
columns: "Sichtbare Spalten",
|
||||||
language: "Sprache",
|
language: "Sprache",
|
||||||
system: "System",
|
system: "System",
|
||||||
theme: { label: "Design", system: "System", light: "Hell", dark: "Dunkel" },
|
theme: { label: "Design", system: "System", light: "Hell", dark: "Dunkel" },
|
||||||
tone: { label: "Ton", dark: "Dunkel", darker: "Dunkler" },
|
tone: { label: "Ton", dark: "Dunkel", darker: "AMOLED" },
|
||||||
accent: { label: "Akzent" },
|
interface: { label: "Oberfläche", smaller: "Kleiner", larger: "Größer", reset: "Auf 100% zurücksetzen" },
|
||||||
accentNames: {
|
|
||||||
graphite: "Graphit",
|
|
||||||
blue: "Blau",
|
|
||||||
indigo: "Indigo",
|
|
||||||
purple: "Lila",
|
|
||||||
rose: "Rosa",
|
|
||||||
orange: "Orange",
|
|
||||||
green: "Grün",
|
|
||||||
teal: "Petrol",
|
|
||||||
},
|
|
||||||
fileIcons: { label: "Datei-Symbole", plain: "Einfach", multi: "Farbig" },
|
|
||||||
interface: { label: "Oberfläche", compact: "Kompakt", default: "Normal", large: "Groß" },
|
|
||||||
speedUnit: { label: "Geschwindigkeitseinheit" },
|
speedUnit: { label: "Geschwindigkeitseinheit" },
|
||||||
transfers: { label: "Übertragungen", single: "Einzeln", multi: "Mehrere" },
|
transfers: { label: "Übertragungen", single: "Einzeln", multi: "Mehrere" },
|
||||||
atOnce: "Gleichzeitig",
|
atOnce: "Gleichzeitig",
|
||||||
@@ -249,6 +245,7 @@ export const de = {
|
|||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
about: "Über {app}",
|
about: "Über {app}",
|
||||||
|
settings: "Einstellungen…",
|
||||||
checkForUpdates: "Nach Updates suchen…",
|
checkForUpdates: "Nach Updates suchen…",
|
||||||
quit: "Beenden",
|
quit: "Beenden",
|
||||||
file: "Datei",
|
file: "Datei",
|
||||||
@@ -288,6 +285,11 @@ export const de = {
|
|||||||
copyPath: "Pfad kopieren",
|
copyPath: "Pfad kopieren",
|
||||||
delete: "Löschen",
|
delete: "Löschen",
|
||||||
columns: "Spalten",
|
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",
|
trashTooltip: "Finders Wiederherstellen ist für von hier gelöschte Elemente nicht verfügbar",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
|
|||||||
+18
-16
@@ -27,31 +27,27 @@ export const en = {
|
|||||||
manageConnections: "Manage connections",
|
manageConnections: "Manage connections",
|
||||||
newTab: "New tab ({mod}T)",
|
newTab: "New tab ({mod}T)",
|
||||||
closeTab: "Close tab ({mod}W)",
|
closeTab: "Close tab ({mod}W)",
|
||||||
options: "Options",
|
options: "Settings",
|
||||||
update: "Update available",
|
update: "Update available",
|
||||||
updating: "Updating",
|
updating: "Updating",
|
||||||
restartToUpdate: "Restart to apply",
|
restartToUpdate: "Restart to apply",
|
||||||
updateTooltip: "Update available: miniSCP {version}",
|
updateTooltip: "Update available: miniSCP {version}",
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
title: "Options",
|
title: "Settings",
|
||||||
|
sections: {
|
||||||
|
appearance: "Appearance",
|
||||||
|
transfers: "Transfers",
|
||||||
|
editor: "Editor",
|
||||||
|
fileList: "File list",
|
||||||
|
about: "About",
|
||||||
|
},
|
||||||
|
columns: "Visible columns",
|
||||||
language: "Language",
|
language: "Language",
|
||||||
system: "System",
|
system: "System",
|
||||||
theme: { label: "Theme", system: "System", light: "Light", dark: "Dark" },
|
theme: { label: "Theme", system: "System", light: "Light", dark: "Dark" },
|
||||||
tone: { label: "Tone", dark: "Dark", darker: "Darker" },
|
tone: { label: "Tone", dark: "Dark", darker: "AMOLED" },
|
||||||
accent: { label: "Accent" },
|
interface: { label: "Interface", smaller: "Smaller", larger: "Larger", reset: "Reset to 100%" },
|
||||||
accentNames: {
|
|
||||||
graphite: "Graphite",
|
|
||||||
blue: "Blue",
|
|
||||||
indigo: "Indigo",
|
|
||||||
purple: "Purple",
|
|
||||||
rose: "Rose",
|
|
||||||
orange: "Orange",
|
|
||||||
green: "Green",
|
|
||||||
teal: "Teal",
|
|
||||||
},
|
|
||||||
fileIcons: { label: "File icons", plain: "Plain", multi: "Multi" },
|
|
||||||
interface: { label: "Interface", compact: "Compact", default: "Default", large: "Large" },
|
|
||||||
speedUnit: { label: "Speed unit" },
|
speedUnit: { label: "Speed unit" },
|
||||||
transfers: { label: "Transfers", single: "Single", multi: "Multi" },
|
transfers: { label: "Transfers", single: "Single", multi: "Multi" },
|
||||||
atOnce: "At once",
|
atOnce: "At once",
|
||||||
@@ -253,6 +249,7 @@ export const en = {
|
|||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
about: "About {app}",
|
about: "About {app}",
|
||||||
|
settings: "Settings…",
|
||||||
checkForUpdates: "Check for Updates…",
|
checkForUpdates: "Check for Updates…",
|
||||||
quit: "Quit",
|
quit: "Quit",
|
||||||
file: "File",
|
file: "File",
|
||||||
@@ -292,6 +289,11 @@ export const en = {
|
|||||||
copyPath: "Copy Path",
|
copyPath: "Copy Path",
|
||||||
delete: "Delete",
|
delete: "Delete",
|
||||||
columns: "Columns",
|
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",
|
trashTooltip: "Finder's Restore is not available for items trashed from here",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
|
|||||||
+18
-16
@@ -23,31 +23,27 @@ export const es = {
|
|||||||
manageConnections: "Gestionar conexiones",
|
manageConnections: "Gestionar conexiones",
|
||||||
newTab: "Nueva pestaña ({mod}T)",
|
newTab: "Nueva pestaña ({mod}T)",
|
||||||
closeTab: "Cerrar pestaña ({mod}W)",
|
closeTab: "Cerrar pestaña ({mod}W)",
|
||||||
options: "Opciones",
|
options: "Ajustes",
|
||||||
update: "Actualización disponible",
|
update: "Actualización disponible",
|
||||||
updating: "Actualizando",
|
updating: "Actualizando",
|
||||||
restartToUpdate: "Reiniciar para aplicar",
|
restartToUpdate: "Reiniciar para aplicar",
|
||||||
updateTooltip: "Actualización disponible: miniSCP {version}",
|
updateTooltip: "Actualización disponible: miniSCP {version}",
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
title: "Opciones",
|
title: "Ajustes",
|
||||||
|
sections: {
|
||||||
|
appearance: "Apariencia",
|
||||||
|
transfers: "Transferencias",
|
||||||
|
editor: "Editor",
|
||||||
|
fileList: "Lista de archivos",
|
||||||
|
about: "Acerca de",
|
||||||
|
},
|
||||||
|
columns: "Columnas visibles",
|
||||||
language: "Idioma",
|
language: "Idioma",
|
||||||
system: "Sistema",
|
system: "Sistema",
|
||||||
theme: { label: "Tema", system: "Sistema", light: "Claro", dark: "Oscuro" },
|
theme: { label: "Tema", system: "Sistema", light: "Claro", dark: "Oscuro" },
|
||||||
tone: { label: "Tono", dark: "Oscuro", darker: "Más oscuro" },
|
tone: { label: "Tono", dark: "Oscuro", darker: "AMOLED" },
|
||||||
accent: { label: "Acento" },
|
interface: { label: "Interfaz", smaller: "Más pequeña", larger: "Más grande", reset: "Restablecer al 100%" },
|
||||||
accentNames: {
|
|
||||||
graphite: "Grafito",
|
|
||||||
blue: "Azul",
|
|
||||||
indigo: "Índigo",
|
|
||||||
purple: "Morado",
|
|
||||||
rose: "Rosa",
|
|
||||||
orange: "Naranja",
|
|
||||||
green: "Verde",
|
|
||||||
teal: "Turquesa",
|
|
||||||
},
|
|
||||||
fileIcons: { label: "Iconos", plain: "Simple", multi: "Color" },
|
|
||||||
interface: { label: "Interfaz", compact: "Compacta", default: "Normal", large: "Grande" },
|
|
||||||
speedUnit: { label: "Unidad de velocidad" },
|
speedUnit: { label: "Unidad de velocidad" },
|
||||||
transfers: { label: "Transferencias", single: "Una", multi: "Varias" },
|
transfers: { label: "Transferencias", single: "Una", multi: "Varias" },
|
||||||
atOnce: "A la vez",
|
atOnce: "A la vez",
|
||||||
@@ -249,6 +245,7 @@ export const es = {
|
|||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
about: "Acerca de {app}",
|
about: "Acerca de {app}",
|
||||||
|
settings: "Ajustes…",
|
||||||
checkForUpdates: "Buscar actualizaciones…",
|
checkForUpdates: "Buscar actualizaciones…",
|
||||||
quit: "Salir",
|
quit: "Salir",
|
||||||
file: "Archivo",
|
file: "Archivo",
|
||||||
@@ -288,6 +285,11 @@ export const es = {
|
|||||||
copyPath: "Copiar ruta",
|
copyPath: "Copiar ruta",
|
||||||
delete: "Eliminar",
|
delete: "Eliminar",
|
||||||
columns: "Columnas",
|
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í",
|
trashTooltip: "La opción Restaurar de Finder no está disponible para elementos enviados a la papelera desde aquí",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
|
|||||||
+18
-16
@@ -25,31 +25,27 @@ export const eu = {
|
|||||||
manageConnections: "Kudeatu konexioak",
|
manageConnections: "Kudeatu konexioak",
|
||||||
newTab: "Fitxa berria ({mod}T)",
|
newTab: "Fitxa berria ({mod}T)",
|
||||||
closeTab: "Itxi fitxa ({mod}W)",
|
closeTab: "Itxi fitxa ({mod}W)",
|
||||||
options: "Aukerak",
|
options: "Ezarpenak",
|
||||||
update: "Eguneratzea eskuragarri",
|
update: "Eguneratzea eskuragarri",
|
||||||
updating: "Eguneratzen",
|
updating: "Eguneratzen",
|
||||||
restartToUpdate: "Berrabiarazi aplikatzeko",
|
restartToUpdate: "Berrabiarazi aplikatzeko",
|
||||||
updateTooltip: "Eguneratzea eskuragarri: miniSCP {version}",
|
updateTooltip: "Eguneratzea eskuragarri: miniSCP {version}",
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
title: "Aukerak",
|
title: "Ezarpenak",
|
||||||
|
sections: {
|
||||||
|
appearance: "Itxura",
|
||||||
|
transfers: "Transferentziak",
|
||||||
|
editor: "Editorea",
|
||||||
|
fileList: "Fitxategi-zerrenda",
|
||||||
|
about: "Honi buruz",
|
||||||
|
},
|
||||||
|
columns: "Zutabe ikusgaiak",
|
||||||
language: "Hizkuntza",
|
language: "Hizkuntza",
|
||||||
system: "Sistema",
|
system: "Sistema",
|
||||||
theme: { label: "Gaia", system: "Sistema", light: "Argia", dark: "Iluna" },
|
theme: { label: "Gaia", system: "Sistema", light: "Argia", dark: "Iluna" },
|
||||||
tone: { label: "Tonua", dark: "Iluna", darker: "Ilunagoa" },
|
tone: { label: "Tonua", dark: "Iluna", darker: "AMOLED" },
|
||||||
accent: { label: "Azentua" },
|
interface: { label: "Interfazea", smaller: "Txikiagoa", larger: "Handiagoa", reset: "Berrezarri %100era" },
|
||||||
accentNames: {
|
|
||||||
graphite: "Grafitoa",
|
|
||||||
blue: "Urdina",
|
|
||||||
indigo: "Indigoa",
|
|
||||||
purple: "Morea",
|
|
||||||
rose: "Arrosa",
|
|
||||||
orange: "Laranja",
|
|
||||||
green: "Berdea",
|
|
||||||
teal: "Turkesa",
|
|
||||||
},
|
|
||||||
fileIcons: { label: "Fitxategi-ikonoak", plain: "Soila", multi: "Koloretsua" },
|
|
||||||
interface: { label: "Interfazea", compact: "Trinkoa", default: "Lehenetsia", large: "Handia" },
|
|
||||||
speedUnit: { label: "Abiadura-unitatea" },
|
speedUnit: { label: "Abiadura-unitatea" },
|
||||||
transfers: { label: "Transferentziak", single: "Banaka", multi: "Anitz" },
|
transfers: { label: "Transferentziak", single: "Banaka", multi: "Anitz" },
|
||||||
atOnce: "Aldi berean",
|
atOnce: "Aldi berean",
|
||||||
@@ -251,6 +247,7 @@ export const eu = {
|
|||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
about: "{app} (r)i buruz",
|
about: "{app} (r)i buruz",
|
||||||
|
settings: "Ezarpenak…",
|
||||||
checkForUpdates: "Bilatu eguneratzeak…",
|
checkForUpdates: "Bilatu eguneratzeak…",
|
||||||
quit: "Irten",
|
quit: "Irten",
|
||||||
file: "Fitxategia",
|
file: "Fitxategia",
|
||||||
@@ -290,6 +287,11 @@ export const eu = {
|
|||||||
copyPath: "Kopiatu bidea",
|
copyPath: "Kopiatu bidea",
|
||||||
delete: "Ezabatu",
|
delete: "Ezabatu",
|
||||||
columns: "Zutabeak",
|
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",
|
trashTooltip: "Finder-en «Jarri berriro» ez dago erabilgarri hemendik zakarrontzira botatako elementuentzat",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
|
|||||||
+18
-16
@@ -23,31 +23,27 @@ export const fr = {
|
|||||||
manageConnections: "Gérer les connexions",
|
manageConnections: "Gérer les connexions",
|
||||||
newTab: "Nouvel onglet ({mod}T)",
|
newTab: "Nouvel onglet ({mod}T)",
|
||||||
closeTab: "Fermer l'onglet ({mod}W)",
|
closeTab: "Fermer l'onglet ({mod}W)",
|
||||||
options: "Options",
|
options: "Réglages",
|
||||||
update: "Mise à jour disponible",
|
update: "Mise à jour disponible",
|
||||||
updating: "Mise à jour",
|
updating: "Mise à jour",
|
||||||
restartToUpdate: "Redémarrer pour appliquer",
|
restartToUpdate: "Redémarrer pour appliquer",
|
||||||
updateTooltip: "Mise à jour disponible : miniSCP {version}",
|
updateTooltip: "Mise à jour disponible : miniSCP {version}",
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
title: "Options",
|
title: "Réglages",
|
||||||
|
sections: {
|
||||||
|
appearance: "Apparence",
|
||||||
|
transfers: "Transferts",
|
||||||
|
editor: "Éditeur",
|
||||||
|
fileList: "Liste des fichiers",
|
||||||
|
about: "À propos",
|
||||||
|
},
|
||||||
|
columns: "Colonnes visibles",
|
||||||
language: "Langue",
|
language: "Langue",
|
||||||
system: "Système",
|
system: "Système",
|
||||||
theme: { label: "Thème", system: "Système", light: "Clair", dark: "Sombre" },
|
theme: { label: "Thème", system: "Système", light: "Clair", dark: "Sombre" },
|
||||||
tone: { label: "Ton", dark: "Sombre", darker: "Plus sombre" },
|
tone: { label: "Ton", dark: "Sombre", darker: "AMOLED" },
|
||||||
accent: { label: "Accent" },
|
interface: { label: "Interface", smaller: "Plus petite", larger: "Plus grande", reset: "Réinitialiser à 100%" },
|
||||||
accentNames: {
|
|
||||||
graphite: "Graphite",
|
|
||||||
blue: "Bleu",
|
|
||||||
indigo: "Indigo",
|
|
||||||
purple: "Violet",
|
|
||||||
rose: "Rose",
|
|
||||||
orange: "Orange",
|
|
||||||
green: "Vert",
|
|
||||||
teal: "Sarcelle",
|
|
||||||
},
|
|
||||||
fileIcons: { label: "Icônes", plain: "Simple", multi: "Couleur" },
|
|
||||||
interface: { label: "Interface", compact: "Compacte", default: "Normale", large: "Grande" },
|
|
||||||
speedUnit: { label: "Unité de vitesse" },
|
speedUnit: { label: "Unité de vitesse" },
|
||||||
transfers: { label: "Transferts", single: "Un", multi: "Plusieurs" },
|
transfers: { label: "Transferts", single: "Un", multi: "Plusieurs" },
|
||||||
atOnce: "Simultanés",
|
atOnce: "Simultanés",
|
||||||
@@ -249,6 +245,7 @@ export const fr = {
|
|||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
about: "À propos de {app}",
|
about: "À propos de {app}",
|
||||||
|
settings: "Réglages…",
|
||||||
checkForUpdates: "Rechercher des mises à jour…",
|
checkForUpdates: "Rechercher des mises à jour…",
|
||||||
quit: "Quitter",
|
quit: "Quitter",
|
||||||
file: "Fichier",
|
file: "Fichier",
|
||||||
@@ -288,6 +285,11 @@ export const fr = {
|
|||||||
copyPath: "Copier le chemin",
|
copyPath: "Copier le chemin",
|
||||||
delete: "Supprimer",
|
delete: "Supprimer",
|
||||||
columns: "Colonnes",
|
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",
|
trashTooltip: "La restauration du Finder n'est pas disponible pour les éléments mis à la corbeille d'ici",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
|
|||||||
+18
-16
@@ -24,31 +24,27 @@ export const ja = {
|
|||||||
manageConnections: "接続を管理",
|
manageConnections: "接続を管理",
|
||||||
newTab: "新しいタブ ({mod}T)",
|
newTab: "新しいタブ ({mod}T)",
|
||||||
closeTab: "タブを閉じる ({mod}W)",
|
closeTab: "タブを閉じる ({mod}W)",
|
||||||
options: "オプション",
|
options: "設定",
|
||||||
update: "アップデートあり",
|
update: "アップデートあり",
|
||||||
updating: "更新中",
|
updating: "更新中",
|
||||||
restartToUpdate: "再起動して適用",
|
restartToUpdate: "再起動して適用",
|
||||||
updateTooltip: "アップデートがあります: miniSCP {version}",
|
updateTooltip: "アップデートがあります: miniSCP {version}",
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
title: "オプション",
|
title: "設定",
|
||||||
|
sections: {
|
||||||
|
appearance: "外観",
|
||||||
|
transfers: "転送",
|
||||||
|
editor: "エディタ",
|
||||||
|
fileList: "ファイル一覧",
|
||||||
|
about: "情報",
|
||||||
|
},
|
||||||
|
columns: "表示する列",
|
||||||
language: "言語",
|
language: "言語",
|
||||||
system: "システム",
|
system: "システム",
|
||||||
theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" },
|
theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" },
|
||||||
tone: { label: "トーン", dark: "ダーク", darker: "より暗く" },
|
tone: { label: "トーン", dark: "ダーク", darker: "AMOLED" },
|
||||||
accent: { label: "アクセント" },
|
interface: { label: "インターフェース", smaller: "縮小", larger: "拡大", reset: "100%に戻す" },
|
||||||
accentNames: {
|
|
||||||
graphite: "グラファイト",
|
|
||||||
blue: "ブルー",
|
|
||||||
indigo: "インディゴ",
|
|
||||||
purple: "パープル",
|
|
||||||
rose: "ローズ",
|
|
||||||
orange: "オレンジ",
|
|
||||||
green: "グリーン",
|
|
||||||
teal: "ティール",
|
|
||||||
},
|
|
||||||
fileIcons: { label: "ファイルアイコン", plain: "シンプル", multi: "カラー" },
|
|
||||||
interface: { label: "インターフェース", compact: "コンパクト", default: "標準", large: "大" },
|
|
||||||
speedUnit: { label: "速度の単位" },
|
speedUnit: { label: "速度の単位" },
|
||||||
transfers: { label: "転送", single: "1件ずつ", multi: "複数" },
|
transfers: { label: "転送", single: "1件ずつ", multi: "複数" },
|
||||||
atOnce: "同時数",
|
atOnce: "同時数",
|
||||||
@@ -249,6 +245,7 @@ export const ja = {
|
|||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
about: "{app} について",
|
about: "{app} について",
|
||||||
|
settings: "設定…",
|
||||||
checkForUpdates: "アップデートを確認…",
|
checkForUpdates: "アップデートを確認…",
|
||||||
quit: "終了",
|
quit: "終了",
|
||||||
file: "ファイル",
|
file: "ファイル",
|
||||||
@@ -288,6 +285,11 @@ export const ja = {
|
|||||||
copyPath: "パスをコピー",
|
copyPath: "パスをコピー",
|
||||||
delete: "削除",
|
delete: "削除",
|
||||||
columns: "列",
|
columns: "列",
|
||||||
|
tabNew: "新しいタブ",
|
||||||
|
tabClose: "タブを閉じる",
|
||||||
|
tabCloseOthers: "他のタブを閉じる",
|
||||||
|
tabCloseRight: "右側のタブを閉じる",
|
||||||
|
tabDisconnect: "切断",
|
||||||
trashTooltip: "ここからゴミ箱に入れた項目は Finder の「戻す」を利用できません",
|
trashTooltip: "ここからゴミ箱に入れた項目は Finder の「戻す」を利用できません",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
|
|||||||
+18
-16
@@ -24,31 +24,27 @@ export const zh = {
|
|||||||
manageConnections: "管理连接",
|
manageConnections: "管理连接",
|
||||||
newTab: "新建标签页 ({mod}T)",
|
newTab: "新建标签页 ({mod}T)",
|
||||||
closeTab: "关闭标签页 ({mod}W)",
|
closeTab: "关闭标签页 ({mod}W)",
|
||||||
options: "选项",
|
options: "设置",
|
||||||
update: "有可用更新",
|
update: "有可用更新",
|
||||||
updating: "正在更新",
|
updating: "正在更新",
|
||||||
restartToUpdate: "重启以应用",
|
restartToUpdate: "重启以应用",
|
||||||
updateTooltip: "有可用更新:miniSCP {version}",
|
updateTooltip: "有可用更新:miniSCP {version}",
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
title: "选项",
|
title: "设置",
|
||||||
|
sections: {
|
||||||
|
appearance: "外观",
|
||||||
|
transfers: "传输",
|
||||||
|
editor: "编辑器",
|
||||||
|
fileList: "文件列表",
|
||||||
|
about: "关于",
|
||||||
|
},
|
||||||
|
columns: "显示的列",
|
||||||
language: "语言",
|
language: "语言",
|
||||||
system: "系统",
|
system: "系统",
|
||||||
theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" },
|
theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" },
|
||||||
tone: { label: "色调", dark: "深色", darker: "更深" },
|
tone: { label: "色调", dark: "深色", darker: "AMOLED" },
|
||||||
accent: { label: "强调色" },
|
interface: { label: "界面", smaller: "缩小", larger: "放大", reset: "重置为100%" },
|
||||||
accentNames: {
|
|
||||||
graphite: "石墨",
|
|
||||||
blue: "蓝色",
|
|
||||||
indigo: "靛蓝",
|
|
||||||
purple: "紫色",
|
|
||||||
rose: "玫红",
|
|
||||||
orange: "橙色",
|
|
||||||
green: "绿色",
|
|
||||||
teal: "青色",
|
|
||||||
},
|
|
||||||
fileIcons: { label: "文件图标", plain: "简约", multi: "彩色" },
|
|
||||||
interface: { label: "界面", compact: "紧凑", default: "默认", large: "大" },
|
|
||||||
speedUnit: { label: "速度单位" },
|
speedUnit: { label: "速度单位" },
|
||||||
transfers: { label: "传输", single: "单个", multi: "多个" },
|
transfers: { label: "传输", single: "单个", multi: "多个" },
|
||||||
atOnce: "同时",
|
atOnce: "同时",
|
||||||
@@ -249,6 +245,7 @@ export const zh = {
|
|||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
about: "关于 {app}",
|
about: "关于 {app}",
|
||||||
|
settings: "设置…",
|
||||||
checkForUpdates: "检查更新…",
|
checkForUpdates: "检查更新…",
|
||||||
quit: "退出",
|
quit: "退出",
|
||||||
file: "文件",
|
file: "文件",
|
||||||
@@ -288,6 +285,11 @@ export const zh = {
|
|||||||
copyPath: "复制路径",
|
copyPath: "复制路径",
|
||||||
delete: "删除",
|
delete: "删除",
|
||||||
columns: "列",
|
columns: "列",
|
||||||
|
tabNew: "新建标签页",
|
||||||
|
tabClose: "关闭标签页",
|
||||||
|
tabCloseOthers: "关闭其他标签页",
|
||||||
|
tabCloseRight: "关闭右侧标签页",
|
||||||
|
tabDisconnect: "断开连接",
|
||||||
trashTooltip: "从此处移入废纸篓的项目无法使用访达的放回原处功能",
|
trashTooltip: "从此处移入废纸篓的项目无法使用访达的放回原处功能",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
|
|||||||
+15
-15
@@ -1,6 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
AppSettings,
|
AppSettings,
|
||||||
ColumnKey,
|
|
||||||
ConnectionConfig,
|
ConnectionConfig,
|
||||||
ConnectionsFile,
|
ConnectionsFile,
|
||||||
EntryType,
|
EntryType,
|
||||||
@@ -27,9 +26,9 @@ export interface EditorOpenContext {
|
|||||||
// bun.requests = calls the webview makes, handled in src/bun/rpc-handlers.ts
|
// bun.requests = calls the webview makes, handled in src/bun/rpc-handlers.ts
|
||||||
// webview.messages = push events from bun, handled in ui rpc.service.ts
|
// webview.messages = push events from bun, handled in ui rpc.service.ts
|
||||||
//
|
//
|
||||||
// ONE schema serves both window kinds (main + editor): electrobun allows
|
// ONE schema serves every window kind (main, editor, manager, settings):
|
||||||
// partial handler sets, and messages nobody listens to are silently dropped,
|
// electrobun allows partial handler sets, and messages nobody listens to are
|
||||||
// so each side only implements/hears what applies to it.
|
// silently dropped, so each side only implements/hears what applies to it.
|
||||||
export type AppRPC = {
|
export type AppRPC = {
|
||||||
bun: {
|
bun: {
|
||||||
requests: {
|
requests: {
|
||||||
@@ -126,20 +125,18 @@ export type AppRPC = {
|
|||||||
response: Result<{ names: string[] }>;
|
response: Result<{ names: string[] }>;
|
||||||
};
|
};
|
||||||
clearEditLeftovers: { params: Record<string, never>; response: Result };
|
clearEditLeftovers: { params: Record<string, never>; response: Result };
|
||||||
// context menu + local shell integration
|
// local shell integration
|
||||||
showRowMenu: {
|
/**
|
||||||
params: {
|
* Shell actions the context menu triggers on a LOCAL path. The menu
|
||||||
path: string;
|
* itself is HTML (ui/src/app/core/context-menu.service.ts) — electrobun's native
|
||||||
name: string;
|
* context menu is a no-op on Linux — but these three still need the
|
||||||
type: EntryType;
|
* bun side's Utils.
|
||||||
isLocal: boolean;
|
*/
|
||||||
hasEntry: boolean;
|
shellItemAction: {
|
||||||
};
|
params: { action: "reveal" | "open" | "copyPath"; path: string };
|
||||||
response: Result;
|
response: Result;
|
||||||
};
|
};
|
||||||
moveToTrash: { params: { path: string }; response: Result };
|
moveToTrash: { params: { path: string }; response: Result };
|
||||||
/** Native column-picker menu (right-click on the list headers). */
|
|
||||||
showColumnsMenu: { params: { visible: ColumnKey[] }; response: Result };
|
|
||||||
/** Native About box (name, Build n, commit, engine versions). */
|
/** Native About box (name, Build n, commit, engine versions). */
|
||||||
showAbout: { params: Record<string, never>; response: Result };
|
showAbout: { params: Record<string, never>; response: Result };
|
||||||
/** Windows drive roots for the local panel ("C:\", …); empty elsewhere. */
|
/** Windows drive roots for the local panel ("C:\", …); empty elsewhere. */
|
||||||
@@ -157,6 +154,9 @@ export type AppRPC = {
|
|||||||
managerConnect: { params: { id: string }; response: Result };
|
managerConnect: { params: { id: string }; response: Result };
|
||||||
managerOpenStack: { params: { id: string }; response: Result };
|
managerOpenStack: { params: { id: string }; response: Result };
|
||||||
managerClose: { params: Record<string, never>; response: Result };
|
managerClose: { params: Record<string, never>; response: Result };
|
||||||
|
// settings window
|
||||||
|
/** Main window: open (or focus) the single Settings window. */
|
||||||
|
openSettings: { params: Record<string, never>; response: Result };
|
||||||
// auto-updater (electrobun Updater)
|
// auto-updater (electrobun Updater)
|
||||||
/** Main window: re-run the update check on demand (menu item). */
|
/** Main window: re-run the update check on demand (menu item). */
|
||||||
checkForUpdate: {
|
checkForUpdate: {
|
||||||
|
|||||||
+25
-19
@@ -65,31 +65,22 @@ export interface ConnectionsFile {
|
|||||||
/** UI languages the app ships translations for. */
|
/** UI languages the app ships translations for. */
|
||||||
export type SupportedLocale = "en" | "es" | "eu" | "fr" | "de" | "zh" | "ja";
|
export type SupportedLocale = "en" | "es" | "eu" | "fr" | "de" | "zh" | "ja";
|
||||||
|
|
||||||
export type AccentTone =
|
|
||||||
| "graphite"
|
|
||||||
| "blue"
|
|
||||||
| "indigo"
|
|
||||||
| "purple"
|
|
||||||
| "rose"
|
|
||||||
| "orange"
|
|
||||||
| "green"
|
|
||||||
| "teal";
|
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
/** UI language; "system" resolves to the OS locale (fallback English). */
|
/** UI language; "system" resolves to the OS locale (fallback English). */
|
||||||
locale: "system" | SupportedLocale;
|
locale: "system" | SupportedLocale;
|
||||||
theme: "system" | "light" | "dark";
|
theme: "system" | "light" | "dark";
|
||||||
/** Dark appearance: grey ("dark") or near-black AMOLED ("darker"). */
|
/** Dark appearance: grey ("dark") or near-black AMOLED ("darker"). */
|
||||||
darkTone: "dark" | "darker";
|
darkTone: "dark" | "darker";
|
||||||
/** Accent tone for chrome and selection tint; graphite = neutral zinc. */
|
|
||||||
accent: AccentTone;
|
|
||||||
/** File-list icons: plain neutral glyphs, or per-category glyphs and colors. */
|
|
||||||
iconColors: "plain" | "multi";
|
|
||||||
/** Visible optional file-list columns (both panels; Name always shows). */
|
/** Visible optional file-list columns (both panels; Name always shows). */
|
||||||
columns: ColumnKey[];
|
columns: ColumnKey[];
|
||||||
speedUnit: "MBps" | "Mbps";
|
speedUnit: "MBps" | "Mbps";
|
||||||
/** Size of the panel interface: file list, path bar, header controls. */
|
/**
|
||||||
textSize: "compact" | "default" | "large";
|
* Interface scale factor, 0.5–4 in 0.1 steps (shown as 50%–400%). Works by
|
||||||
|
* setting the root font-size — every size in the UI is rem, so one lever
|
||||||
|
* scales the whole UI. Replaced the 3-tier `textSize`
|
||||||
|
* (compact|default|large); SettingsStore.load migrates old files.
|
||||||
|
*/
|
||||||
|
uiScale: number;
|
||||||
/** Run transfer jobs one at a time, or several in parallel. */
|
/** Run transfer jobs one at a time, or several in parallel. */
|
||||||
transferMode: "single" | "multi";
|
transferMode: "single" | "multi";
|
||||||
/** How many jobs run at once when transferMode is "multi". */
|
/** How many jobs run at once when transferMode is "multi". */
|
||||||
@@ -110,11 +101,9 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
locale: "system",
|
locale: "system",
|
||||||
theme: "system",
|
theme: "system",
|
||||||
darkTone: "dark",
|
darkTone: "dark",
|
||||||
accent: "blue",
|
|
||||||
iconColors: "multi",
|
|
||||||
columns: ["size", "mtime"],
|
columns: ["size", "mtime"],
|
||||||
speedUnit: "MBps",
|
speedUnit: "MBps",
|
||||||
textSize: "default",
|
uiScale: 1,
|
||||||
transferMode: "single",
|
transferMode: "single",
|
||||||
transferConcurrency: 3,
|
transferConcurrency: 3,
|
||||||
autoClearCompleted: true,
|
autoClearCompleted: true,
|
||||||
@@ -123,6 +112,23 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
// autoRefresh: true,
|
// 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));
|
||||||
|
|||||||
Vendored
-3
@@ -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";
|
|
||||||
+2
-1
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
|
import type { ColumnKey, EntryType } from '@shared/types';
|
||||||
|
import { I18nService } from './i18n.service';
|
||||||
|
import { IS_MAC, IS_WINDOWS, MOD_LABEL } from './platform';
|
||||||
|
import { RpcService } from './rpc.service';
|
||||||
|
|
||||||
|
/** Optional list columns, in display order (Name is fixed). */
|
||||||
|
export const COLUMN_KEYS: ColumnKey[] = ['size', 'mtime', 'kind', 'rights', 'owner', 'group'];
|
||||||
|
|
||||||
|
export interface MenuItem {
|
||||||
|
type?: 'separator';
|
||||||
|
label?: string;
|
||||||
|
/** Emitted verbatim to the action dispatcher (`ctx-*` / `col:*` / `tab:*`). */
|
||||||
|
action?: string;
|
||||||
|
checked?: boolean;
|
||||||
|
tooltip?: string;
|
||||||
|
/** Shortcut hint, rendered right-aligned. */
|
||||||
|
accelerator?: string;
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuRequest {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
items: MenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
type: EntryType;
|
||||||
|
isLocal: boolean;
|
||||||
|
hasEntry: boolean; // false = right-click on empty panel space
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 panel right-click 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 — and still emits the exact same
|
||||||
|
* action strings, so the dispatcher in app.ts is unchanged.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ContextMenuService {
|
||||||
|
private readonly i18n = inject(I18nService);
|
||||||
|
private readonly rpc = inject(RpcService);
|
||||||
|
|
||||||
|
readonly request = signal<MenuRequest | null>(null);
|
||||||
|
|
||||||
|
open(event: MouseEvent, items: MenuItem[]): void {
|
||||||
|
this.request.set({ x: event.clientX, y: event.clientY, items });
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.request.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A picked item goes through the same dispatcher the app menubar uses. */
|
||||||
|
onPick(action: string): void {
|
||||||
|
this.rpc.emitMenuAction(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Row / empty-space menu for a file panel. */
|
||||||
|
rowItems(ctx: RowMenuContext): MenuItem[] {
|
||||||
|
const t = (key: string) => this.i18n.t(key);
|
||||||
|
const items: MenuItem[] = [];
|
||||||
|
|
||||||
|
if (!ctx.hasEntry || ctx.name === '..') {
|
||||||
|
// empty space (or the parent row): folder-level actions
|
||||||
|
items.push({ label: t('ctx.newFolder'), action: 'ctx-new-folder' });
|
||||||
|
items.push({ label: t('ctx.newFile'), action: 'ctx-new-file' });
|
||||||
|
items.push({ type: 'separator' });
|
||||||
|
items.push({ label: t('ctx.refresh'), action: 'ctx-refresh' });
|
||||||
|
if (ctx.isLocal) {
|
||||||
|
items.push({ type: 'separator' });
|
||||||
|
items.push({ label: t(revealKey()), action: 'ctx-reveal' });
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({ label: t('ctx.copyToOther'), action: 'ctx-copy' });
|
||||||
|
if (ctx.type === 'dir') {
|
||||||
|
items.push({ label: t('ctx.open'), action: 'ctx-open' });
|
||||||
|
} else {
|
||||||
|
if (ctx.isLocal) {
|
||||||
|
items.push({ label: t('ctx.open'), action: 'ctx-open-default', tooltip: t('ctx.openDefault') });
|
||||||
|
}
|
||||||
|
items.push({ label: t('ctx.edit'), action: 'ctx-edit', accelerator: 'F4' });
|
||||||
|
}
|
||||||
|
items.push({ label: t('ctx.rename'), action: 'ctx-rename' });
|
||||||
|
// chmod on Windows local files only flips the read-only bit — hide the lie
|
||||||
|
if (!(ctx.isLocal && IS_WINDOWS)) {
|
||||||
|
items.push({ label: t('ctx.permissions'), action: 'ctx-chmod' });
|
||||||
|
}
|
||||||
|
items.push({ type: 'separator' });
|
||||||
|
items.push({ label: t('ctx.copyPath'), action: 'ctx-copy-path' });
|
||||||
|
if (ctx.isLocal) {
|
||||||
|
items.push({ label: t(revealKey()), action: 'ctx-reveal' });
|
||||||
|
}
|
||||||
|
items.push({ type: 'separator' });
|
||||||
|
if (ctx.isLocal) {
|
||||||
|
items.push({
|
||||||
|
label: t(trashKey()),
|
||||||
|
action: 'ctx-trash',
|
||||||
|
...(IS_MAC ? { tooltip: t('ctx.trashTooltip') } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
items.push({ label: t('ctx.delete'), action: 'ctx-delete' });
|
||||||
|
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`.
|
||||||
|
*
|
||||||
|
* 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', accelerator: `${MOD_LABEL}T` },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: t('ctx.tabClose'),
|
||||||
|
action: `tab:close:${ctx.id}`,
|
||||||
|
accelerator: `${MOD_LABEL}W`,
|
||||||
|
disabled: last,
|
||||||
|
},
|
||||||
|
{ label: t('ctx.tabCloseOthers'), action: `tab:close-others:${ctx.id}`, disabled: last },
|
||||||
|
{
|
||||||
|
label: t('ctx.tabCloseRight'),
|
||||||
|
action: `tab:close-right:${ctx.id}`,
|
||||||
|
disabled: ctx.index >= ctx.tabCount - 1,
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: t('ctx.tabDisconnect'),
|
||||||
|
action: `tab:disconnect:${ctx.id}`,
|
||||||
|
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,5 +1,6 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import type { FileEntry } from '@shared/types';
|
import type { FileEntry } from '@shared/types';
|
||||||
|
import { ContextMenuService } from './context-menu.service';
|
||||||
import { DialogsService } from './dialogs.service';
|
import { DialogsService } from './dialogs.service';
|
||||||
import { joinPath } from './format';
|
import { joinPath } from './format';
|
||||||
import { type PanelState, type Side } from './panels.service';
|
import { type PanelState, type Side } from './panels.service';
|
||||||
@@ -26,21 +27,25 @@ export class FileActionsService {
|
|||||||
private readonly rpc = inject(RpcService);
|
private readonly rpc = inject(RpcService);
|
||||||
private readonly toast = inject(ToastService);
|
private readonly toast = inject(ToastService);
|
||||||
private readonly dialogs = inject(DialogsService);
|
private readonly dialogs = inject(DialogsService);
|
||||||
|
private readonly menu = inject(ContextMenuService);
|
||||||
private readonly settings = inject(SettingsService);
|
private readonly settings = inject(SettingsService);
|
||||||
|
|
||||||
menuTarget: MenuTarget | null = null;
|
menuTarget: MenuTarget | null = null;
|
||||||
|
|
||||||
/** Right-click entry point: remember the target and ask bun for a native menu. */
|
/** Right-click entry point: remember the target, then open the menu on it. */
|
||||||
async openContextMenu(target: MenuTarget): Promise<void> {
|
openContextMenu(event: MouseEvent, target: MenuTarget): void {
|
||||||
this.menuTarget = target;
|
this.menuTarget = target;
|
||||||
const { panel, entry } = target;
|
const { panel, entry } = target;
|
||||||
await this.rpc.request.showRowMenu({
|
this.menu.open(
|
||||||
|
event,
|
||||||
|
this.menu.rowItems({
|
||||||
path: entry?.path ?? panel.cwd(),
|
path: entry?.path ?? panel.cwd(),
|
||||||
name: entry?.name ?? '',
|
name: entry?.name ?? '',
|
||||||
type: entry?.type ?? 'dir',
|
type: entry?.type ?? 'dir',
|
||||||
isLocal: panel.sessionId() === 'local',
|
isLocal: panel.sessionId() === 'local',
|
||||||
hasEntry: !!entry,
|
hasEntry: !!entry,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async trash(panel: PanelState, entry: FileEntry): Promise<void> {
|
async trash(panel: PanelState, entry: FileEntry): Promise<void> {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// 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.
|
||||||
@@ -8,33 +8,32 @@ 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: string; class: string; extensions: string[] }> = {
|
||||||
image: {
|
image: {
|
||||||
glyph: 'mynaImage',
|
glyph: 'tdesignImage',
|
||||||
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: 'tdesignMusic',
|
||||||
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: 'tdesignVideo',
|
||||||
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: 'tdesignFileZip',
|
||||||
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: 'tdesignCode',
|
||||||
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 +45,7 @@ const CATEGORIES: Record<Category, { glyph: string; class: string; extensions: s
|
|||||||
).split(' '),
|
).split(' '),
|
||||||
},
|
},
|
||||||
doc: {
|
doc: {
|
||||||
glyph: 'mynaFileText',
|
glyph: 'tdesignFile1',
|
||||||
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(' '),
|
||||||
},
|
},
|
||||||
@@ -85,18 +84,15 @@ export function fileKindKey(entry: FileEntry): string {
|
|||||||
return category ? KIND_KEYS[category] : 'file';
|
return category ? KIND_KEYS[category] : 'file';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fileGlyph(entry: FileEntry, multi: boolean): string {
|
export function fileGlyph(entry: FileEntry): string {
|
||||||
if (entry.name === '..') return 'mynaArrowUp';
|
if (entry.name === '..') return 'tdesignArrowUp';
|
||||||
if (entry.type === 'dir') return 'mynaFolder';
|
if (entry.type === 'dir') return 'tdesignFolder';
|
||||||
if (!multi) return 'mynaFile';
|
if (entry.type === 'symlink') return 'tdesignLink';
|
||||||
if (entry.type === 'symlink') return 'mynaLink';
|
|
||||||
const category = categoryOf(entry.name);
|
const category = categoryOf(entry.name);
|
||||||
return category ? CATEGORIES[category].glyph : 'mynaFile';
|
return category ? CATEGORIES[category].glyph : 'tdesignFile';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fileIconClass(entry: FileEntry, multi: boolean): string {
|
export function fileIconClass(entry: FileEntry): string {
|
||||||
// plain mode: exactly the pre-color look ('..' has type 'dir' -> primary)
|
|
||||||
if (!multi) return entry.type === 'dir' ? PRIMARY : MUTED;
|
|
||||||
if (entry.name === '..') return MUTED;
|
if (entry.name === '..') return MUTED;
|
||||||
if (entry.type === 'dir') return FOLDER;
|
if (entry.type === 'dir') return FOLDER;
|
||||||
if (entry.type === 'symlink') return LINK;
|
if (entry.type === 'symlink') return LINK;
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Single icon registry for the whole app, registered once in app.config.ts.
|
||||||
|
// TDesign's package root exports every icon; named imports tree-shake. Names are
|
||||||
|
// plain strings in templates — the build never checks them, so every name used
|
||||||
|
// anywhere must be listed here (an unregistered one renders nothing).
|
||||||
|
import {
|
||||||
|
tdesignAdd,
|
||||||
|
tdesignArrowLeft,
|
||||||
|
tdesignArrowRight,
|
||||||
|
tdesignArrowUp,
|
||||||
|
tdesignCheck,
|
||||||
|
tdesignCheckCircle,
|
||||||
|
tdesignChevronDown,
|
||||||
|
tdesignChevronRight,
|
||||||
|
tdesignChevronUp,
|
||||||
|
tdesignClose,
|
||||||
|
tdesignCloseCircle,
|
||||||
|
tdesignCode,
|
||||||
|
tdesignDelete,
|
||||||
|
tdesignDownload,
|
||||||
|
tdesignEdit,
|
||||||
|
tdesignFile,
|
||||||
|
tdesignFile1,
|
||||||
|
tdesignFileAdd,
|
||||||
|
tdesignFileZip,
|
||||||
|
tdesignFolder,
|
||||||
|
tdesignFolderAdd,
|
||||||
|
tdesignHardDrive,
|
||||||
|
tdesignImage,
|
||||||
|
tdesignLink,
|
||||||
|
tdesignLoading,
|
||||||
|
tdesignLockOn,
|
||||||
|
tdesignMusic,
|
||||||
|
tdesignRefresh,
|
||||||
|
tdesignSearch,
|
||||||
|
tdesignServer,
|
||||||
|
tdesignSetting,
|
||||||
|
tdesignVideo,
|
||||||
|
} from "@ng-icons/tdesign-icons";
|
||||||
|
|
||||||
|
export const APP_ICONS = {
|
||||||
|
tdesignAdd,
|
||||||
|
tdesignArrowLeft,
|
||||||
|
tdesignArrowRight,
|
||||||
|
tdesignArrowUp,
|
||||||
|
tdesignCheck,
|
||||||
|
tdesignCheckCircle,
|
||||||
|
tdesignChevronDown,
|
||||||
|
tdesignChevronRight,
|
||||||
|
tdesignChevronUp,
|
||||||
|
tdesignClose,
|
||||||
|
tdesignCloseCircle,
|
||||||
|
tdesignCode,
|
||||||
|
tdesignDelete,
|
||||||
|
tdesignDownload,
|
||||||
|
tdesignEdit,
|
||||||
|
tdesignFile,
|
||||||
|
tdesignFile1,
|
||||||
|
tdesignFileAdd,
|
||||||
|
tdesignFileZip,
|
||||||
|
tdesignFolder,
|
||||||
|
tdesignFolderAdd,
|
||||||
|
tdesignHardDrive,
|
||||||
|
tdesignImage,
|
||||||
|
tdesignLink,
|
||||||
|
tdesignLoading,
|
||||||
|
tdesignLockOn,
|
||||||
|
tdesignMusic,
|
||||||
|
tdesignRefresh,
|
||||||
|
tdesignSearch,
|
||||||
|
tdesignServer,
|
||||||
|
tdesignSetting,
|
||||||
|
tdesignVideo,
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -398,6 +398,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);
|
||||||
@@ -7,7 +7,7 @@ declare global {
|
|||||||
interface Window {
|
interface Window {
|
||||||
__APP_PLATFORM?: string;
|
__APP_PLATFORM?: string;
|
||||||
/** Which root component this window boots. */
|
/** Which root component this window boots. */
|
||||||
__APP_MODE?: 'main' | 'editor' | 'manager';
|
__APP_MODE?: 'main' | 'editor' | 'manager' | 'settings';
|
||||||
/** Window-kind specific context (EditorOpenContext for editor windows). */
|
/** Window-kind specific context (EditorOpenContext for editor windows). */
|
||||||
__APP_CTX?: unknown;
|
__APP_CTX?: unknown;
|
||||||
}
|
}
|
||||||
@@ -15,21 +15,26 @@ declare global {
|
|||||||
|
|
||||||
export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?? '') : '';
|
export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?? '') : '';
|
||||||
|
|
||||||
/** True when the window uses macOS hiddenInset chrome (header fused with titlebar). */
|
export const IS_MAC = PLATFORM === 'darwin';
|
||||||
export const IS_MAC_CHROME = PLATFORM === 'darwin';
|
|
||||||
|
|
||||||
export const IS_WINDOWS = PLATFORM === 'win32';
|
export const IS_WINDOWS = PLATFORM === 'win32';
|
||||||
|
|
||||||
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. */
|
export const IS_LINUX = PLATFORM === 'linux';
|
||||||
export const MOD_LABEL = IS_MAC_CHROME ? '⌘' : 'Ctrl+';
|
|
||||||
export const ALT_LABEL = IS_MAC_CHROME ? '⌥' : 'Alt+';
|
|
||||||
|
|
||||||
export const APP_MODE: 'main' | 'editor' | 'manager' =
|
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. */
|
||||||
|
export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+';
|
||||||
|
export const ALT_LABEL = IS_MAC ? '⌥' : 'Alt+';
|
||||||
|
|
||||||
|
export const APP_MODE: 'main' | 'editor' | 'manager' | 'settings' =
|
||||||
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
|
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
|
||||||
|
|
||||||
export const EDITOR_CONTEXT =
|
export const EDITOR_CONTEXT =
|
||||||
typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined;
|
typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined;
|
||||||
|
|
||||||
|
/** True when macOS traffic lights overlay the app's own header row (the fused
|
||||||
|
* hiddenInset layout, see chromeOptions), which reserves `pl-24` for them. */
|
||||||
|
export const IS_MAC_CHROME = IS_MAC;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kill the webview's default right-click menu (Reload, Inspect Element…)
|
* Kill the webview's default right-click menu (Reload, Inspect Element…)
|
||||||
* everywhere the app doesn't show its own native menu. Text fields keep the
|
* everywhere the app doesn't show its own native menu. Text fields keep the
|
||||||
@@ -76,9 +76,10 @@ export class RpcService {
|
|||||||
});
|
});
|
||||||
const electroview = new Electroview({ rpc: this.rpc });
|
const electroview = new Electroview({ rpc: this.rpc });
|
||||||
|
|
||||||
// Requests fired before the RPC WebSocket finishes connecting are lost, so
|
// Gate everything below on the RPC WebSocket being open (services fire calls
|
||||||
// everything below gates on it being open (services fire calls at startup).
|
// at startup). v1 silently lost pre-open sends; 2.x Electroview queues them
|
||||||
const socket = electroview.bunSocket;
|
// and falls back to the native bridge, so this is now just belt-and-braces.
|
||||||
|
const socket = electroview.hostSocket;
|
||||||
if (socket && socket.readyState !== WebSocket.OPEN) {
|
if (socket && socket.readyState !== WebSocket.OPEN) {
|
||||||
this.ready = new Promise((resolve) => {
|
this.ready = new Promise((resolve) => {
|
||||||
socket.addEventListener('open', () => resolve(), { once: true });
|
socket.addEventListener('open', () => resolve(), { once: true });
|
||||||
@@ -99,6 +100,15 @@ export class RpcService {
|
|||||||
this.menuListeners.add(listener);
|
this.menuListeners.add(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch an action that originated in the UI (the HTML context menu) as if
|
||||||
|
* it had arrived from bun, so app.ts keeps ONE handler for menu actions
|
||||||
|
* regardless of whether the app menubar or a right-click produced them.
|
||||||
|
*/
|
||||||
|
emitMenuAction(action: string): void {
|
||||||
|
for (const listener of this.menuListeners) listener(action);
|
||||||
|
}
|
||||||
|
|
||||||
onSettingsChanged(listener: (settings: AppSettings) => void): void {
|
onSettingsChanged(listener: (settings: AppSettings) => void): void {
|
||||||
this.settingsListeners.add(listener);
|
this.settingsListeners.add(listener);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
import { resolveLocale } from '@shared/i18n';
|
import { resolveLocale } from '@shared/i18n';
|
||||||
import { DEFAULT_SETTINGS, type AppSettings, type ColumnKey } from '@shared/types';
|
import { clampUiScale, DEFAULT_SETTINGS, type AppSettings, type ColumnKey } from '@shared/types';
|
||||||
import { I18nService } from './i18n.service';
|
import { I18nService } from './i18n.service';
|
||||||
import { RpcService } from './rpc.service';
|
import { RpcService } from './rpc.service';
|
||||||
|
|
||||||
@@ -9,11 +9,9 @@ export class SettingsService {
|
|||||||
readonly locale = signal<AppSettings['locale']>(DEFAULT_SETTINGS.locale);
|
readonly locale = signal<AppSettings['locale']>(DEFAULT_SETTINGS.locale);
|
||||||
readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme);
|
readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme);
|
||||||
readonly darkTone = signal<AppSettings['darkTone']>(DEFAULT_SETTINGS.darkTone);
|
readonly darkTone = signal<AppSettings['darkTone']>(DEFAULT_SETTINGS.darkTone);
|
||||||
readonly accent = signal<AppSettings['accent']>(DEFAULT_SETTINGS.accent);
|
|
||||||
readonly iconColors = signal<AppSettings['iconColors']>(DEFAULT_SETTINGS.iconColors);
|
|
||||||
readonly columns = signal<ColumnKey[]>(DEFAULT_SETTINGS.columns);
|
readonly columns = signal<ColumnKey[]>(DEFAULT_SETTINGS.columns);
|
||||||
readonly speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit);
|
readonly speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit);
|
||||||
readonly textSize = signal<AppSettings['textSize']>(DEFAULT_SETTINGS.textSize);
|
readonly uiScale = signal<number>(DEFAULT_SETTINGS.uiScale);
|
||||||
readonly transferMode = signal<AppSettings['transferMode']>(DEFAULT_SETTINGS.transferMode);
|
readonly transferMode = signal<AppSettings['transferMode']>(DEFAULT_SETTINGS.transferMode);
|
||||||
readonly transferConcurrency = signal(DEFAULT_SETTINGS.transferConcurrency);
|
readonly transferConcurrency = signal(DEFAULT_SETTINGS.transferConcurrency);
|
||||||
readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted);
|
readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted);
|
||||||
@@ -38,8 +36,6 @@ export class SettingsService {
|
|||||||
window
|
window
|
||||||
.matchMedia('(prefers-color-scheme: dark)')
|
.matchMedia('(prefers-color-scheme: dark)')
|
||||||
.addEventListener('change', (e) => this.systemDark.set(e.matches));
|
.addEventListener('change', (e) => this.systemDark.set(e.matches));
|
||||||
// the default accent is not the CSS default — apply before first paint
|
|
||||||
this.applyAccent();
|
|
||||||
if (!this.rpc.available) return;
|
if (!this.rpc.available) return;
|
||||||
void this.rpc.request.getSettings({}).then((res) => {
|
void this.rpc.request.getSettings({}).then((res) => {
|
||||||
if (res.ok) this.applyAll(res.settings);
|
if (res.ok) this.applyAll(res.settings);
|
||||||
@@ -52,11 +48,9 @@ export class SettingsService {
|
|||||||
this.locale.set(settings.locale);
|
this.locale.set(settings.locale);
|
||||||
this.theme.set(settings.theme);
|
this.theme.set(settings.theme);
|
||||||
this.darkTone.set(settings.darkTone);
|
this.darkTone.set(settings.darkTone);
|
||||||
this.accent.set(settings.accent);
|
|
||||||
this.iconColors.set(settings.iconColors);
|
|
||||||
this.columns.set(settings.columns);
|
this.columns.set(settings.columns);
|
||||||
this.speedUnit.set(settings.speedUnit);
|
this.speedUnit.set(settings.speedUnit);
|
||||||
this.textSize.set(settings.textSize);
|
this.uiScale.set(settings.uiScale);
|
||||||
this.transferMode.set(settings.transferMode);
|
this.transferMode.set(settings.transferMode);
|
||||||
this.transferConcurrency.set(settings.transferConcurrency);
|
this.transferConcurrency.set(settings.transferConcurrency);
|
||||||
this.autoClearCompleted.set(settings.autoClearCompleted);
|
this.autoClearCompleted.set(settings.autoClearCompleted);
|
||||||
@@ -64,8 +58,7 @@ export class SettingsService {
|
|||||||
this.editorApp.set(settings.editorApp);
|
this.editorApp.set(settings.editorApp);
|
||||||
this.applyTheme();
|
this.applyTheme();
|
||||||
this.applyDarkTone();
|
this.applyDarkTone();
|
||||||
this.applyAccent();
|
this.applyUiScale();
|
||||||
this.applyTextSize();
|
|
||||||
this.applyLocale();
|
this.applyLocale();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,18 +80,6 @@ export class SettingsService {
|
|||||||
void this.persist();
|
void this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
setAccent(tone: AppSettings['accent']): void {
|
|
||||||
this.accent.set(tone);
|
|
||||||
this.applyAccent();
|
|
||||||
void this.persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
setIconColors(mode: AppSettings['iconColors']): void {
|
|
||||||
// no apply step — the file panels read the signal reactively
|
|
||||||
this.iconColors.set(mode);
|
|
||||||
void this.persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleColumn(key: ColumnKey): void {
|
toggleColumn(key: ColumnKey): void {
|
||||||
this.columns.update((list) =>
|
this.columns.update((list) =>
|
||||||
list.includes(key) ? list.filter((c) => c !== key) : [...list, key],
|
list.includes(key) ? list.filter((c) => c !== key) : [...list, key],
|
||||||
@@ -111,9 +92,9 @@ export class SettingsService {
|
|||||||
void this.persist();
|
void this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
setTextSize(size: AppSettings['textSize']): void {
|
setUiScale(scale: number): void {
|
||||||
this.textSize.set(size);
|
this.uiScale.set(clampUiScale(scale));
|
||||||
this.applyTextSize();
|
this.applyUiScale();
|
||||||
void this.persist();
|
void this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,21 +141,17 @@ export class SettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyAccent(): void {
|
/** The whole interface scale is ONE declaration: every size in the app is
|
||||||
const tone = this.accent();
|
* rem, so moving the root font-size moves the whole UI. 1 = the
|
||||||
if (tone === 'graphite') {
|
* browser-default 16px root; delete-when-default keeps the pristine state
|
||||||
delete document.documentElement.dataset['accent'];
|
* attribute-free like the other axes. buildPreload mirrors this for a
|
||||||
|
* flash-free first paint. */
|
||||||
|
private applyUiScale(): void {
|
||||||
|
const scale = this.uiScale();
|
||||||
|
if (scale === 1) {
|
||||||
|
document.documentElement.style.removeProperty('font-size');
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.dataset['accent'] = tone;
|
document.documentElement.style.fontSize = `${16 * scale}px`;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyTextSize(): void {
|
|
||||||
const size = this.textSize();
|
|
||||||
if (size === 'default') {
|
|
||||||
delete document.documentElement.dataset['text'];
|
|
||||||
} else {
|
|
||||||
document.documentElement.dataset['text'] = size;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,11 +164,9 @@ export class SettingsService {
|
|||||||
locale: this.locale(),
|
locale: this.locale(),
|
||||||
theme: this.theme(),
|
theme: this.theme(),
|
||||||
darkTone: this.darkTone(),
|
darkTone: this.darkTone(),
|
||||||
accent: this.accent(),
|
|
||||||
iconColors: this.iconColors(),
|
|
||||||
columns: this.columns(),
|
columns: this.columns(),
|
||||||
speedUnit: this.speedUnit(),
|
speedUnit: this.speedUnit(),
|
||||||
textSize: this.textSize(),
|
uiScale: this.uiScale(),
|
||||||
transferMode: this.transferMode(),
|
transferMode: this.transferMode(),
|
||||||
transferConcurrency: this.transferConcurrency(),
|
transferConcurrency: this.transferConcurrency(),
|
||||||
autoClearCompleted: this.autoClearCompleted(),
|
autoClearCompleted: this.autoClearCompleted(),
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every window-level shortcut the app answers to, by name. Components switch
|
||||||
|
* on these ids instead of key-matching inline, so the whole keymap is ONE
|
||||||
|
* table — the precondition for the vim-style bindings planned next, which
|
||||||
|
* will need a mode/leader state machine wrapped around exactly this point.
|
||||||
|
*/
|
||||||
|
export type ShortcutId =
|
||||||
|
// main window (workspace/tab management)
|
||||||
|
| 'new-tab'
|
||||||
|
| 'close-tab'
|
||||||
|
| 'cycle-tab'
|
||||||
|
| 'cycle-tab-back'
|
||||||
|
| 'go-to-tab' // 1-9; the digit rides in ShortcutMatch.digit
|
||||||
|
| 'open-settings'
|
||||||
|
// editor window
|
||||||
|
| 'save'
|
||||||
|
| 'find'
|
||||||
|
| 'close-window'
|
||||||
|
| 'toggle-wrap'
|
||||||
|
// any window
|
||||||
|
| 'scale-up'
|
||||||
|
| 'scale-down'
|
||||||
|
| 'scale-reset';
|
||||||
|
|
||||||
|
export interface ShortcutMatch {
|
||||||
|
id: ShortcutId;
|
||||||
|
/** Only for go-to-tab: the digit pressed, 1–9. */
|
||||||
|
digit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which keymap a window consults. 'manager' and 'settings' answer only to the
|
||||||
|
* any-window shortcuts (scale). Escape/Tab stay component-local: their meaning
|
||||||
|
* is contextual state (find bar open? close prompt up? which pane?), not a
|
||||||
|
* global binding. */
|
||||||
|
export type ShortcutScope = 'main' | 'editor' | 'manager' | 'settings';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The keyboard layer: modifier helpers + one declarative keymap.
|
||||||
|
*
|
||||||
|
* House rules encoded here rather than re-derived per component (they were
|
||||||
|
* previously copy-pasted inline in six places, drifting apart):
|
||||||
|
* - `mod` is metaKey OR ctrlKey — ⌘ on macOS, Ctrl elsewhere. The keyboard
|
||||||
|
* does not change with the window chrome (platform.ts says the same about
|
||||||
|
* MOD_LABEL).
|
||||||
|
* - Layout-dependent keys match on `event.code`, not `event.key`: digits
|
||||||
|
* (AZERTY's digit row shifts), +/- (key varies wildly across layouts), and
|
||||||
|
* letter chords with Alt (macOS Option produces Ω for ⌥Z). Plain letter
|
||||||
|
* chords match on `key` so they follow the user's layout.
|
||||||
|
* - isTypingTarget guards shortcuts that would be hostile mid-typing. It is
|
||||||
|
* deliberately NOT applied inside resolve(): the existing bindings rely on
|
||||||
|
* the path bar's stopPropagation and the editor WANTS Mod+S while typing.
|
||||||
|
* Callers opt in per shortcut (the scale keys do).
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ShortcutsService {
|
||||||
|
/** The platform command modifier: ⌘ on macOS, Ctrl elsewhere. Mouse events
|
||||||
|
* qualify too — Mod+click toggle-select in the panels uses the same rule. */
|
||||||
|
isMod(event: KeyboardEvent | MouseEvent): boolean {
|
||||||
|
return event.metaKey || event.ctrlKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the event originates from something the user types into. */
|
||||||
|
isTypingTarget(event: KeyboardEvent): boolean {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
return !!target?.closest('input, textarea, select, [contenteditable="true"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match an event against the scope's keymap. Returns null for anything the
|
||||||
|
* table does not claim — callers must then leave the event alone.
|
||||||
|
*/
|
||||||
|
resolve(event: KeyboardEvent, scope: ShortcutScope): ShortcutMatch | null {
|
||||||
|
const mod = this.isMod(event);
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
|
||||||
|
// ---- any window: interface scale ----
|
||||||
|
// event.code, not key: '+' needs Shift on many layouts and '-' moves
|
||||||
|
// around; the physical Equal/Minus keys (and the numpad pair) are stable.
|
||||||
|
if (mod && !event.altKey) {
|
||||||
|
if (event.code === 'Equal' || event.code === 'NumpadAdd') return { id: 'scale-up' };
|
||||||
|
if (event.code === 'Minus' || event.code === 'NumpadSubtract') return { id: 'scale-down' };
|
||||||
|
if (event.code === 'Digit0' || event.code === 'Numpad0') return { id: 'scale-reset' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scope === 'manager' || scope === 'settings') return null; // any-window shortcuts only
|
||||||
|
|
||||||
|
if (scope === 'main') {
|
||||||
|
// on macOS the app menu's ⌘, intercepts first; this is the Windows/Linux path
|
||||||
|
if (mod && key === ',') return { id: 'open-settings' };
|
||||||
|
if (mod && key === 't') return { id: 'new-tab' };
|
||||||
|
if (mod && key === 'w') return { id: 'close-tab' };
|
||||||
|
// ctrlKey only, never meta: ⌘Tab is the macOS app switcher
|
||||||
|
if (event.ctrlKey && event.key === 'Tab') {
|
||||||
|
return { id: event.shiftKey ? 'cycle-tab-back' : 'cycle-tab' };
|
||||||
|
}
|
||||||
|
if (mod && !event.shiftKey && !event.altKey) {
|
||||||
|
const digit = /^(?:Digit|Numpad)([1-9])$/.exec(event.code)?.[1];
|
||||||
|
if (digit) return { id: 'go-to-tab', digit: Number(digit) };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// scope === 'editor'
|
||||||
|
if (mod && key === 's') return { id: 'save' };
|
||||||
|
if (mod && key === 'f') return { id: 'find' };
|
||||||
|
// on macOS the app menu intercepts ⌘W before this ever fires; this arm is
|
||||||
|
// the Windows/Linux path
|
||||||
|
if (mod && key === 'w') return { id: 'close-window' };
|
||||||
|
if (event.altKey && event.code === 'KeyZ') return { id: 'toggle-wrap' };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
// Single icon registry for the whole app, registered once in app.config.ts.
|
|
||||||
// NOTE: @ng-icons/mynaui's package root is empty — always import from /outline
|
|
||||||
// (or /solid, where names get a `Solid` suffix).
|
|
||||||
import {
|
|
||||||
mynaArchive,
|
|
||||||
mynaArrowLeft,
|
|
||||||
mynaArrowLeftRight,
|
|
||||||
mynaArrowRight,
|
|
||||||
mynaArrowUp,
|
|
||||||
mynaCheck,
|
|
||||||
mynaCheckCircle,
|
|
||||||
mynaChevronDown,
|
|
||||||
mynaChevronRight,
|
|
||||||
mynaChevronUp,
|
|
||||||
mynaCode,
|
|
||||||
mynaCog,
|
|
||||||
mynaDots,
|
|
||||||
mynaDownload,
|
|
||||||
mynaEdit,
|
|
||||||
mynaFile,
|
|
||||||
mynaFilePlus,
|
|
||||||
mynaFileText,
|
|
||||||
mynaFolder,
|
|
||||||
mynaFolderPlus,
|
|
||||||
mynaHardDrive,
|
|
||||||
mynaImage,
|
|
||||||
mynaLink,
|
|
||||||
mynaLock,
|
|
||||||
mynaMusic,
|
|
||||||
mynaPlus,
|
|
||||||
mynaRefresh,
|
|
||||||
mynaSearch,
|
|
||||||
mynaServers,
|
|
||||||
mynaSpinner,
|
|
||||||
mynaTrash,
|
|
||||||
mynaVideo,
|
|
||||||
mynaX,
|
|
||||||
mynaXCircle,
|
|
||||||
} from "@ng-icons/mynaui/outline";
|
|
||||||
|
|
||||||
export const APP_ICONS = {
|
|
||||||
mynaArchive,
|
|
||||||
mynaArrowLeft,
|
|
||||||
mynaArrowLeftRight,
|
|
||||||
mynaArrowRight,
|
|
||||||
mynaArrowUp,
|
|
||||||
mynaCheck,
|
|
||||||
mynaCheckCircle,
|
|
||||||
mynaChevronDown,
|
|
||||||
mynaChevronRight,
|
|
||||||
mynaChevronUp,
|
|
||||||
mynaCode,
|
|
||||||
mynaCog,
|
|
||||||
mynaDots,
|
|
||||||
mynaDownload,
|
|
||||||
mynaEdit,
|
|
||||||
mynaFile,
|
|
||||||
mynaFilePlus,
|
|
||||||
mynaFileText,
|
|
||||||
mynaFolder,
|
|
||||||
mynaFolderPlus,
|
|
||||||
mynaHardDrive,
|
|
||||||
mynaImage,
|
|
||||||
mynaLink,
|
|
||||||
mynaLock,
|
|
||||||
mynaMusic,
|
|
||||||
mynaPlus,
|
|
||||||
mynaRefresh,
|
|
||||||
mynaSearch,
|
|
||||||
mynaServers,
|
|
||||||
mynaSpinner,
|
|
||||||
mynaTrash,
|
|
||||||
mynaVideo,
|
|
||||||
mynaX,
|
|
||||||
mynaXCircle,
|
|
||||||
};
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<div class="flex h-screen flex-col bg-background text-foreground">
|
<div class="flex h-screen flex-col bg-background text-foreground">
|
||||||
@if (!rpc.available) {
|
@if (!rpc.available) {
|
||||||
<div class="flex flex-1 items-center justify-center">
|
<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">
|
<div class="max-w-sm border border-border bg-card p-6 text-center shadow-sm">
|
||||||
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
|
<ng-icon name="tdesignCloseCircle" size="1.5rem" class="text-destructive" />
|
||||||
<p class="mt-2 text-sm font-medium">{{ i18n.t('app.notConnectedTitle') }}</p>
|
<p class="mt-2 text-sm font-medium">{{ i18n.t('app.notConnectedTitle') }}</p>
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
{{ i18n.t('app.notConnectedBody') }}
|
{{ i18n.t('app.notConnectedBody') }}
|
||||||
@@ -27,11 +27,11 @@
|
|||||||
class="fixed inset-0 z-60 flex items-center justify-center bg-black/40"
|
class="fixed inset-0 z-60 flex items-center justify-center bg-black/40"
|
||||||
(mousedown)="focusManager()"
|
(mousedown)="focusManager()"
|
||||||
>
|
>
|
||||||
<div class="rounded-lg border border-border bg-card px-5 py-4 text-center shadow-xl">
|
<div class="border border-border bg-card px-5 py-4 text-center shadow-xl">
|
||||||
<ng-icon name="mynaServers" size="1.25rem" class="text-muted-foreground" />
|
<ng-icon name="tdesignServer" size="1.25rem" class="text-muted-foreground" />
|
||||||
<p class="mt-1.5 text-xs text-muted-foreground">{{ i18n.t('app.managerOpen') }}</p>
|
<p class="mt-1.5 text-xs text-muted-foreground">{{ i18n.t('app.managerOpen') }}</p>
|
||||||
<button
|
<button
|
||||||
class="mt-3 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
class="mt-3 bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
{{ i18n.t('app.showConnections') }}
|
{{ i18n.t('app.showConnections') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -53,5 +53,6 @@
|
|||||||
<app-overwrite-dialog [request]="req" />
|
<app-overwrite-dialog [request]="req" />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<app-context-menu />
|
||||||
<app-toasts />
|
<app-toasts />
|
||||||
</div>
|
</div>
|
||||||
@@ -1,27 +1,29 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
import { Component, inject, signal } from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import { I18nService } from './i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import { BRANDING } from '@shared/branding';
|
import { BRANDING } from '@shared/branding';
|
||||||
import type { ColumnKey } from '@shared/types';
|
import type { ColumnKey } from '@shared/types';
|
||||||
import { ConnectionsService } from './connections.service';
|
import { ConnectionsService } from '@core/connections.service';
|
||||||
|
import { ContextMenu } from './context-menu';
|
||||||
import { ChmodDialog } from './dialogs/chmod-dialog';
|
import { ChmodDialog } from './dialogs/chmod-dialog';
|
||||||
import { ConfirmDialog } from './dialogs/confirm-dialog';
|
import { ConfirmDialog } from './dialogs/confirm-dialog';
|
||||||
import { OverwriteDialog } from './dialogs/overwrite-dialog';
|
import { OverwriteDialog } from './dialogs/overwrite-dialog';
|
||||||
import { PromptDialog } from './dialogs/prompt-dialog';
|
import { PromptDialog } from './dialogs/prompt-dialog';
|
||||||
import { DialogsService } from './dialogs.service';
|
import { DialogsService } from '@core/dialogs.service';
|
||||||
import { FileActionsService } from './file-actions.service';
|
import { FileActionsService } from '@core/file-actions.service';
|
||||||
import { FilePanel } from './file-panel/file-panel';
|
import { FilePanel } from './file-panel/file-panel';
|
||||||
import { PanelsService } from './panels.service';
|
import { PanelsService } from '@core/panels.service';
|
||||||
import { blockNativeContextMenu } from './platform';
|
import { blockNativeContextMenu } from '@core/platform';
|
||||||
import { RpcService } from './rpc.service';
|
import { RpcService } from '@core/rpc.service';
|
||||||
import { SettingsService } from './settings.service';
|
import { SettingsService } from '@core/settings.service';
|
||||||
|
import { ShortcutsService } from '@core/shortcuts.service';
|
||||||
import { StatusBar } from './status-bar/status-bar';
|
import { StatusBar } from './status-bar/status-bar';
|
||||||
import { TabBar } from './tab-bar/tab-bar';
|
import { TabBar } from './tab-bar/tab-bar';
|
||||||
import { ToastService } from './toast.service';
|
import { ToastService } from '@core/toast.service';
|
||||||
import { Toasts } from './toasts/toasts';
|
import { Toasts } from './toasts/toasts';
|
||||||
import { TransferBar } from './transfer-bar/transfer-bar';
|
import { TransferBar } from './transfer-bar/transfer-bar';
|
||||||
import { TransfersService } from './transfers.service';
|
import { TransfersService } from '@core/transfers.service';
|
||||||
import { UpdaterService } from './updater.service';
|
import { UpdaterService } from '@core/updater.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@@ -36,6 +38,7 @@ import { UpdaterService } from './updater.service';
|
|||||||
OverwriteDialog,
|
OverwriteDialog,
|
||||||
Toasts,
|
Toasts,
|
||||||
TransferBar,
|
TransferBar,
|
||||||
|
ContextMenu,
|
||||||
],
|
],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
host: {
|
host: {
|
||||||
@@ -53,6 +56,7 @@ export class App {
|
|||||||
readonly connections = inject(ConnectionsService);
|
readonly connections = inject(ConnectionsService);
|
||||||
private readonly transfers = inject(TransfersService);
|
private readonly transfers = inject(TransfersService);
|
||||||
private readonly settings = inject(SettingsService);
|
private readonly settings = inject(SettingsService);
|
||||||
|
private readonly shortcuts = inject(ShortcutsService);
|
||||||
private readonly updater = inject(UpdaterService);
|
private readonly updater = inject(UpdaterService);
|
||||||
|
|
||||||
/** The connection-manager window is open — this window is modal-blocked. */
|
/** The connection-manager window is open — this window is modal-blocked. */
|
||||||
@@ -117,6 +121,10 @@ export class App {
|
|||||||
this.settings.toggleColumn(action.slice('col:'.length) as ColumnKey);
|
this.settings.toggleColumn(action.slice('col:'.length) as ColumnKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action.startsWith('tab:')) {
|
||||||
|
this.handleTabAction(action.slice('tab:'.length));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (action.startsWith('open-stack:')) {
|
if (action.startsWith('open-stack:')) {
|
||||||
const id = action.slice('open-stack:'.length);
|
const id = action.slice('open-stack:'.length);
|
||||||
let stackConnections = this.connections.connections().filter((c) => c.stackId === id);
|
let stackConnections = this.connections.connections().filter((c) => c.stackId === id);
|
||||||
@@ -152,38 +160,99 @@ export class App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tab (workspace) shortcuts — panel-level keys live on the panels themselves. */
|
/** Window shortcuts — matching lives in ShortcutsService (one keymap for the
|
||||||
|
* app); this switch only maps ids onto actions. Panel-level keys stay on
|
||||||
|
* the panels themselves. */
|
||||||
onWindowKeydown(event: KeyboardEvent): void {
|
onWindowKeydown(event: KeyboardEvent): void {
|
||||||
if (this.managerOpen()) return; // modal-blocked behind the manager
|
if (this.managerOpen()) return; // modal-blocked behind the manager
|
||||||
const mod = event.metaKey || event.ctrlKey;
|
const match = this.shortcuts.resolve(event, 'main');
|
||||||
if (mod && event.key.toLowerCase() === 't') {
|
if (!match) return;
|
||||||
|
// scale keys are hostile mid-typing ('-' in a rename prompt must type '-');
|
||||||
|
// the tab shortcuts keep firing there, as they always did
|
||||||
|
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
switch (match.id) {
|
||||||
|
case 'new-tab':
|
||||||
this.panels.newWorkspace();
|
this.panels.newWorkspace();
|
||||||
} else if (mod && event.key.toLowerCase() === 'w') {
|
break;
|
||||||
event.preventDefault();
|
case 'close-tab':
|
||||||
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
|
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
|
||||||
} else if (event.ctrlKey && event.key === 'Tab') {
|
break;
|
||||||
event.preventDefault();
|
case 'cycle-tab':
|
||||||
this.panels.cycleWorkspace(event.shiftKey ? -1 : 1);
|
this.panels.cycleWorkspace(1);
|
||||||
} else if (mod && !event.shiftKey && !event.altKey) {
|
break;
|
||||||
// ⌘1–⌘8 jump to that tab, ⌘9 to the last (Ctrl+digit elsewhere), like
|
case 'cycle-tab-back':
|
||||||
// Firefox. Matched on event.code so the digit row works on any layout.
|
this.panels.cycleWorkspace(-1);
|
||||||
const digit = /^(?:Digit|Numpad)([1-9])$/.exec(event.code)?.[1];
|
break;
|
||||||
if (!digit) return;
|
case 'go-to-tab':
|
||||||
event.preventDefault();
|
// ⌘1–⌘8 jump to that tab, ⌘9 to the last, like Firefox
|
||||||
this.panels.goToWorkspace(digit === '9' ? -1 : Number(digit) - 1);
|
this.panels.goToWorkspace(match.digit === 9 ? -1 : match.digit! - 1);
|
||||||
|
break;
|
||||||
|
case 'open-settings':
|
||||||
|
void this.rpc.request.openSettings({});
|
||||||
|
break;
|
||||||
|
case 'scale-up':
|
||||||
|
this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
||||||
|
break;
|
||||||
|
case 'scale-down':
|
||||||
|
this.settings.setUiScale(this.settings.uiScale() - 0.1);
|
||||||
|
break;
|
||||||
|
case 'scale-reset':
|
||||||
|
this.settings.setUiScale(1);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Context-menu actions apply to the row/panel remembered at right-click time. */
|
/** Context-menu actions apply to the row/panel remembered at right-click time. */
|
||||||
|
/**
|
||||||
|
* Workspace-tab menu actions. The target id travels INSIDE the action string
|
||||||
|
* because the right-clicked tab is not necessarily the active one — nothing
|
||||||
|
* here may fall back to activeWorkspaceId.
|
||||||
|
*/
|
||||||
|
private handleTabAction(action: string): void {
|
||||||
|
if (action === 'new') {
|
||||||
|
this.panels.newWorkspace();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = /^([a-z-]+):(\d+)$/.exec(action);
|
||||||
|
if (!parsed) return;
|
||||||
|
const id = Number(parsed[2]);
|
||||||
|
switch (parsed[1]) {
|
||||||
|
case 'close':
|
||||||
|
this.panels.closeWorkspace(id);
|
||||||
|
break;
|
||||||
|
case 'close-others':
|
||||||
|
this.panels.closeOtherWorkspaces(id);
|
||||||
|
break;
|
||||||
|
case 'close-right':
|
||||||
|
this.panels.closeWorkspacesToRight(id);
|
||||||
|
break;
|
||||||
|
case 'disconnect':
|
||||||
|
void this.panels.disconnectWorkspace(id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private handleContextAction(action: string): void {
|
private handleContextAction(action: string): void {
|
||||||
const target = this.actions.menuTarget;
|
const target = this.actions.menuTarget;
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
const { panel, side, entry } = target;
|
const { panel, side, entry } = target;
|
||||||
|
// the empty-space menu acts on the directory itself
|
||||||
|
const path = entry?.path ?? panel.cwd();
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'ctx-open':
|
case 'ctx-open':
|
||||||
if (entry) panel.open(entry);
|
if (entry) panel.open(entry);
|
||||||
break;
|
break;
|
||||||
|
// local shell integration — only bun can reach the OS shell/clipboard
|
||||||
|
case 'ctx-reveal':
|
||||||
|
void this.rpc.request.shellItemAction({ action: 'reveal', path });
|
||||||
|
break;
|
||||||
|
case 'ctx-open-default':
|
||||||
|
void this.rpc.request.shellItemAction({ action: 'open', path });
|
||||||
|
break;
|
||||||
|
case 'ctx-copy-path':
|
||||||
|
void this.rpc.request.shellItemAction({ action: 'copyPath', path });
|
||||||
|
break;
|
||||||
case 'ctx-edit':
|
case 'ctx-edit':
|
||||||
void this.actions.edit(panel);
|
void this.actions.edit(panel);
|
||||||
break;
|
break;
|
||||||
+44
-47
@@ -9,19 +9,19 @@
|
|||||||
[class.h-9]="!isMacChrome"
|
[class.h-9]="!isMacChrome"
|
||||||
[class.pl-3]="!isMacChrome"
|
[class.pl-3]="!isMacChrome"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaServers" size="0.95rem" class="shrink-0 text-muted-foreground" />
|
<ng-icon name="tdesignServer" size="0.95rem" class="shrink-0 text-muted-foreground" />
|
||||||
<h1 class="text-sm font-semibold">{{ i18n.t('connMgr.title') }}</h1>
|
<h1 class="text-sm font-semibold">{{ i18n.t('connMgr.title') }}</h1>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button
|
<button
|
||||||
(click)="importWinScp()"
|
(click)="importWinScp()"
|
||||||
[disabled]="importing()"
|
[disabled]="importing()"
|
||||||
[title]="i18n.t('connMgr.importWinscpHint')"
|
[title]="i18n.t('connMgr.importWinscpHint')"
|
||||||
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground disabled:opacity-50"
|
class="flex items-center gap-1.5 px-2 py-1 text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||||
>
|
>
|
||||||
@if (importing()) {
|
@if (importing()) {
|
||||||
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
|
<ng-icon name="tdesignLoading" size="0.85rem" class="animate-spin" />
|
||||||
} @else {
|
} @else {
|
||||||
<!-- <ng-icon name="mynaArchive" size="0.85rem" /> -->
|
<!-- <ng-icon name="tdesignFileZip" size="0.85rem" /> -->
|
||||||
}
|
}
|
||||||
{{ i18n.t('connMgr.importWinscp') }}
|
{{ i18n.t('connMgr.importWinscp') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -34,16 +34,16 @@
|
|||||||
<button
|
<button
|
||||||
(click)="newConnection()"
|
(click)="newConnection()"
|
||||||
[title]="i18n.t('connMgr.newConnectionHint')"
|
[title]="i18n.t('connMgr.newConnectionHint')"
|
||||||
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaPlus" size="0.85rem" /> {{ i18n.t('connMgr.newConnection') }}
|
<ng-icon name="tdesignAdd" size="0.85rem" /> {{ i18n.t('connMgr.newConnection') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="newStack()"
|
(click)="newStack()"
|
||||||
[title]="i18n.t('connMgr.newStackHint')"
|
[title]="i18n.t('connMgr.newStackHint')"
|
||||||
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaFolderPlus" size="0.85rem" /> {{ i18n.t('connMgr.newStack') }}
|
<ng-icon name="tdesignFolderAdd" size="0.85rem" /> {{ i18n.t('connMgr.newStack') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -54,7 +54,6 @@
|
|||||||
(dragover)="onDragOver($event, group.stack.id)"
|
(dragover)="onDragOver($event, group.stack.id)"
|
||||||
(dragleave)="dropTarget() === group.stack.id && dropTarget.set(null)"
|
(dragleave)="dropTarget() === group.stack.id && dropTarget.set(null)"
|
||||||
(drop)="onDrop($event, group.stack.id)"
|
(drop)="onDrop($event, group.stack.id)"
|
||||||
class="rounded-md"
|
|
||||||
[class.bg-primary/10]="dropTarget() === group.stack.id"
|
[class.bg-primary/10]="dropTarget() === group.stack.id"
|
||||||
[class.ring-1]="dropTarget() === group.stack.id"
|
[class.ring-1]="dropTarget() === group.stack.id"
|
||||||
[class.ring-inset]="dropTarget() === group.stack.id"
|
[class.ring-inset]="dropTarget() === group.stack.id"
|
||||||
@@ -63,22 +62,22 @@
|
|||||||
<div class="group flex items-center gap-1 px-2 pt-2 pb-0.5">
|
<div class="group flex items-center gap-1 px-2 pt-2 pb-0.5">
|
||||||
<button
|
<button
|
||||||
(click)="toggleStack(group.stack)"
|
(click)="toggleStack(group.stack)"
|
||||||
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground hover:text-foreground"
|
class="grid size-4 shrink-0 place-items-center text-muted-foreground hover:text-foreground"
|
||||||
[title]="group.stack.collapsed ? i18n.t('connMgr.expand') : i18n.t('connMgr.collapse')"
|
[title]="group.stack.collapsed ? i18n.t('connMgr.expand') : i18n.t('connMgr.collapse')"
|
||||||
>
|
>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
[name]="group.stack.collapsed ? 'mynaChevronRight' : 'mynaChevronDown'"
|
[name]="group.stack.collapsed ? 'tdesignChevronRight' : 'tdesignChevronDown'"
|
||||||
size="0.8rem"
|
size="0.8rem"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<span class="truncate text-[11px] font-medium text-muted-foreground uppercase">
|
<span class="truncate text-[0.6875rem] font-medium text-muted-foreground uppercase">
|
||||||
{{ group.stack.name }}
|
{{ group.stack.name }}
|
||||||
</span>
|
</span>
|
||||||
<div class="min-w-0 flex-1"></div>
|
<div class="min-w-0 flex-1"></div>
|
||||||
<!-- count and actions swap on hover: they never share the space,
|
<!-- count and actions swap on hover: they never share the space,
|
||||||
so a long stack name can't collide with the buttons -->
|
so a long stack name can't collide with the buttons -->
|
||||||
<span
|
<span
|
||||||
class="shrink-0 text-[10px] text-muted-foreground/70 tabular-nums group-hover:hidden"
|
class="shrink-0 text-[0.625rem] text-muted-foreground/70 tabular-nums group-hover:hidden"
|
||||||
>
|
>
|
||||||
{{ group.connections.length }}
|
{{ group.connections.length }}
|
||||||
</span>
|
</span>
|
||||||
@@ -86,24 +85,24 @@
|
|||||||
<button
|
<button
|
||||||
(click)="renameStack(group.stack)"
|
(click)="renameStack(group.stack)"
|
||||||
[title]="i18n.t('connMgr.renameStack')"
|
[title]="i18n.t('connMgr.renameStack')"
|
||||||
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-foreground"
|
class="grid size-4 place-items-center text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaEdit" size="0.75rem" />
|
<ng-icon name="tdesignEdit" size="0.75rem" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="deleteStack(group.stack)"
|
(click)="deleteStack(group.stack)"
|
||||||
[title]="i18n.t('connMgr.deleteStack')"
|
[title]="i18n.t('connMgr.deleteStack')"
|
||||||
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-destructive"
|
class="grid size-4 place-items-center text-muted-foreground hover:text-destructive"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaTrash" size="0.75rem" />
|
<ng-icon name="tdesignDelete" size="0.75rem" />
|
||||||
</button>
|
</button>
|
||||||
@if (group.connections.length) {
|
@if (group.connections.length) {
|
||||||
<button
|
<button
|
||||||
(click)="openStack(group.stack.id)"
|
(click)="openStack(group.stack.id)"
|
||||||
[title]="i18n.t('connMgr.openStack')"
|
[title]="i18n.t('connMgr.openStack')"
|
||||||
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-primary"
|
class="grid size-4 place-items-center text-muted-foreground hover:text-primary"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaArrowRight" size="0.8rem" />
|
<ng-icon name="tdesignArrowRight" size="0.8rem" />
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -119,11 +118,11 @@
|
|||||||
[class.bg-accent]="draft()?.id === connection.id"
|
[class.bg-accent]="draft()?.id === connection.id"
|
||||||
[class.opacity-50]="draggedId() === connection.id"
|
[class.opacity-50]="draggedId() === connection.id"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
<ng-icon name="tdesignServer" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
||||||
<span class="truncate">{{ connection.name }}</span>
|
<span class="truncate">{{ connection.name }}</span>
|
||||||
</button>
|
</button>
|
||||||
} @empty {
|
} @empty {
|
||||||
<div class="py-1 pl-6 text-[11px] text-muted-foreground italic">{{ i18n.t('connMgr.empty') }}</div>
|
<div class="py-1 pl-6 text-[0.6875rem] text-muted-foreground italic">{{ i18n.t('connMgr.empty') }}</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -134,14 +133,14 @@
|
|||||||
(dragover)="onDragOver($event, 'loose')"
|
(dragover)="onDragOver($event, 'loose')"
|
||||||
(dragleave)="dropTarget() === 'loose' && dropTarget.set(null)"
|
(dragleave)="dropTarget() === 'loose' && dropTarget.set(null)"
|
||||||
(drop)="onDrop($event, null)"
|
(drop)="onDrop($event, null)"
|
||||||
class="min-h-8 rounded-md pb-1"
|
class="min-h-8 pb-1"
|
||||||
[class.bg-primary/10]="dropTarget() === 'loose'"
|
[class.bg-primary/10]="dropTarget() === 'loose'"
|
||||||
[class.ring-1]="dropTarget() === 'loose'"
|
[class.ring-1]="dropTarget() === 'loose'"
|
||||||
[class.ring-inset]="dropTarget() === 'loose'"
|
[class.ring-inset]="dropTarget() === 'loose'"
|
||||||
[class.ring-primary]="dropTarget() === 'loose'"
|
[class.ring-primary]="dropTarget() === 'loose'"
|
||||||
>
|
>
|
||||||
@if (store.grouped().stacks.length && store.grouped().loose.length) {
|
@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">
|
<div class="px-2 pt-2 pb-0.5 text-[0.6875rem] font-medium text-muted-foreground uppercase">
|
||||||
{{ i18n.t('connMgr.connectionsHeader') }}
|
{{ i18n.t('connMgr.connectionsHeader') }}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -155,17 +154,17 @@
|
|||||||
[class.bg-accent]="draft()?.id === connection.id"
|
[class.bg-accent]="draft()?.id === connection.id"
|
||||||
[class.opacity-50]="draggedId() === connection.id"
|
[class.opacity-50]="draggedId() === connection.id"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
<ng-icon name="tdesignServer" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
||||||
<span class="truncate">{{ connection.name }}</span>
|
<span class="truncate">{{ connection.name }}</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!store.connections().length && !store.stacks().length) {
|
@if (!store.connections().length && !store.stacks().length) {
|
||||||
<p class="px-3 py-2 text-[11px] text-muted-foreground">
|
<p class="px-3 py-2 text-[0.6875rem] text-muted-foreground">
|
||||||
{{ i18n.t('connMgr.noneTitle') }}
|
{{ i18n.t('connMgr.noneTitle') }}
|
||||||
</p>
|
</p>
|
||||||
<p class="px-3 py-2 text-[11px] text-muted-foreground">
|
<p class="px-3 py-2 text-[0.6875rem] text-muted-foreground">
|
||||||
{{ i18n.t('connMgr.noneBody') }}
|
{{ i18n.t('connMgr.noneBody') }}
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
@@ -183,7 +182,7 @@
|
|||||||
(input)="patch('name', $any($event.target).value)"
|
(input)="patch('name', $any($event.target).value)"
|
||||||
[placeholder]="i18n.t('connMgr.namePlaceholder')"
|
[placeholder]="i18n.t('connMgr.namePlaceholder')"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
class="h-7 border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -194,7 +193,7 @@
|
|||||||
(input)="patch('host', $any($event.target).value)"
|
(input)="patch('host', $any($event.target).value)"
|
||||||
placeholder="192.168.1.10"
|
placeholder="192.168.1.10"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
class="h-7 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
@@ -204,14 +203,14 @@
|
|||||||
type="number"
|
type="number"
|
||||||
[value]="d.port"
|
[value]="d.port"
|
||||||
(input)="patch('port', +$any($event.target).value)"
|
(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"
|
class="h-7 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-xs">
|
<label class="flex flex-col gap-1 text-xs">
|
||||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.stack') }}</span>
|
<span class="text-muted-foreground">{{ i18n.t('connMgr.stack') }}</span>
|
||||||
<select
|
<select
|
||||||
(change)="patch('stackId', $any($event.target).value || null)"
|
(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"
|
class="h-7 border border-input bg-background pl-1 outline-none focus:ring-2 focus:ring-ring"
|
||||||
>
|
>
|
||||||
<!-- [selected] per option: binding [value] on the <select> runs
|
<!-- [selected] per option: binding [value] on the <select> runs
|
||||||
before the options render and silently shows "(none)" -->
|
before the options render and silently shows "(none)" -->
|
||||||
@@ -231,7 +230,7 @@
|
|||||||
[value]="d.username"
|
[value]="d.username"
|
||||||
(input)="patch('username', $any($event.target).value)"
|
(input)="patch('username', $any($event.target).value)"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
class="h-7 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-xs">
|
<label class="flex flex-col gap-1 text-xs">
|
||||||
@@ -241,7 +240,7 @@
|
|||||||
(input)="patch('initialPath', $any($event.target).value)"
|
(input)="patch('initialPath', $any($event.target).value)"
|
||||||
[placeholder]="i18n.t('connMgr.remotePathPlaceholder')"
|
[placeholder]="i18n.t('connMgr.remotePathPlaceholder')"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
class="h-7 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -253,7 +252,6 @@
|
|||||||
name="authKind"
|
name="authKind"
|
||||||
[checked]="d.authKind === 'password'"
|
[checked]="d.authKind === 'password'"
|
||||||
(change)="patch('authKind', 'password')"
|
(change)="patch('authKind', 'password')"
|
||||||
class="accent-primary"
|
|
||||||
/>
|
/>
|
||||||
{{ i18n.t('connMgr.authPassword') }}
|
{{ i18n.t('connMgr.authPassword') }}
|
||||||
</label>
|
</label>
|
||||||
@@ -263,7 +261,6 @@
|
|||||||
name="authKind"
|
name="authKind"
|
||||||
[checked]="d.authKind === 'key'"
|
[checked]="d.authKind === 'key'"
|
||||||
(change)="patch('authKind', 'key')"
|
(change)="patch('authKind', 'key')"
|
||||||
class="accent-primary"
|
|
||||||
/>
|
/>
|
||||||
{{ i18n.t('connMgr.authKey') }}
|
{{ i18n.t('connMgr.authKey') }}
|
||||||
</label>
|
</label>
|
||||||
@@ -276,7 +273,7 @@
|
|||||||
type="password"
|
type="password"
|
||||||
[value]="d.password"
|
[value]="d.password"
|
||||||
(input)="patch('password', $any($event.target).value)"
|
(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"
|
class="h-7 border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -288,12 +285,12 @@
|
|||||||
(input)="patch('privateKeyPath', $any($event.target).value)"
|
(input)="patch('privateKeyPath', $any($event.target).value)"
|
||||||
placeholder="~/.ssh/id_ed25519"
|
placeholder="~/.ssh/id_ed25519"
|
||||||
spellcheck="false"
|
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"
|
class="h-7 min-w-0 flex-1 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="pickKeyFile()"
|
(click)="pickKeyFile()"
|
||||||
class="h-7 shrink-0 rounded-md border border-border px-2 text-[11px] hover:bg-accent"
|
class="h-7 shrink-0 border border-border px-2 text-[0.6875rem] hover:bg-accent"
|
||||||
>
|
>
|
||||||
{{ i18n.t('common.choose') }}
|
{{ i18n.t('common.choose') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -305,7 +302,7 @@
|
|||||||
type="password"
|
type="password"
|
||||||
[value]="d.passphrase"
|
[value]="d.passphrase"
|
||||||
(input)="patch('passphrase', $any($event.target).value)"
|
(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"
|
class="h-7 border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
}
|
}
|
||||||
@@ -318,9 +315,9 @@
|
|||||||
(input)="patch('localDir', $any($event.target).value)"
|
(input)="patch('localDir', $any($event.target).value)"
|
||||||
[placeholder]="i18n.t('connMgr.localDirPlaceholder')"
|
[placeholder]="i18n.t('connMgr.localDirPlaceholder')"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
class="h-7 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">
|
<span class="text-[0.625rem] text-muted-foreground/80">
|
||||||
{{ i18n.t('connMgr.localDirHint') }}
|
{{ i18n.t('connMgr.localDirHint') }}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -330,13 +327,13 @@
|
|||||||
<div class="flex items-center gap-2 border-t border-border bg-chrome px-4 py-2.5">
|
<div class="flex items-center gap-2 border-t border-border bg-chrome px-4 py-2.5">
|
||||||
<button
|
<button
|
||||||
(click)="deleteCurrent()"
|
(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"
|
class="flex items-center gap-1.5 px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-destructive"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaTrash" size="0.85rem" /> {{ i18n.t('common.delete') }}
|
<ng-icon name="tdesignDelete" size="0.85rem" /> {{ i18n.t('common.delete') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="moreOpen.set(!moreOpen())"
|
(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"
|
class="border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
{{ moreOpen() ? i18n.t('connMgr.less') : i18n.t('connMgr.more') }}
|
{{ moreOpen() ? i18n.t('connMgr.less') : i18n.t('connMgr.more') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -344,17 +341,17 @@
|
|||||||
<button
|
<button
|
||||||
(click)="save()"
|
(click)="save()"
|
||||||
[disabled]="!valid()"
|
[disabled]="!valid()"
|
||||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50"
|
class="px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{{ i18n.t('common.save') }}
|
{{ i18n.t('common.save') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="saveAndConnect()"
|
(click)="saveAndConnect()"
|
||||||
[disabled]="!valid() || busy()"
|
[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"
|
class="flex items-center gap-1.5 bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
@if (busy()) {
|
@if (busy()) {
|
||||||
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
|
<ng-icon name="tdesignLoading" size="0.85rem" class="animate-spin" />
|
||||||
}
|
}
|
||||||
{{ i18n.t('common.connect') }}
|
{{ i18n.t('common.connect') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -362,7 +359,7 @@
|
|||||||
} @else {
|
} @else {
|
||||||
<div class="grid flex-1 place-items-center">
|
<div class="grid flex-1 place-items-center">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<ng-icon name="mynaServers" size="1.75rem" class="text-muted-foreground" />
|
<ng-icon name="tdesignServer" size="1.75rem" class="text-muted-foreground" />
|
||||||
<p class="mt-2 text-xs text-muted-foreground">
|
<p class="mt-2 text-xs text-muted-foreground">
|
||||||
{{ i18n.t('connMgr.selectPrompt') }}
|
{{ i18n.t('connMgr.selectPrompt') }}
|
||||||
</p>
|
</p>
|
||||||
+19
-6
@@ -1,14 +1,15 @@
|
|||||||
import { Component, computed, inject, signal } from '@angular/core';
|
import { Component, computed, inject, signal } from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import { I18nService } from '../i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import type { SavedConnection, Stack } from '@shared/types';
|
import type { SavedConnection, Stack } from '@shared/types';
|
||||||
import { ConnectionsService } from '../connections.service';
|
import { ConnectionsService } from '@core/connections.service';
|
||||||
import { ConfirmDialog } from '../dialogs/confirm-dialog';
|
import { ConfirmDialog } from '../dialogs/confirm-dialog';
|
||||||
import { PromptDialog } from '../dialogs/prompt-dialog';
|
import { PromptDialog } from '../dialogs/prompt-dialog';
|
||||||
import { DialogsService } from '../dialogs.service';
|
import { DialogsService } from '@core/dialogs.service';
|
||||||
import { blockNativeContextMenu, IS_MAC_CHROME } from '../platform';
|
import { blockNativeContextMenu, IS_MAC_CHROME } from '@core/platform';
|
||||||
import { RpcService } from '../rpc.service';
|
import { RpcService } from '@core/rpc.service';
|
||||||
import { SettingsService } from '../settings.service';
|
import { SettingsService } from '@core/settings.service';
|
||||||
|
import { ShortcutsService } from '@core/shortcuts.service';
|
||||||
import { Toasts } from '../toasts/toasts';
|
import { Toasts } from '../toasts/toasts';
|
||||||
|
|
||||||
interface Draft {
|
interface Draft {
|
||||||
@@ -76,6 +77,7 @@ function fromDraft(d: Draft): SavedConnection {
|
|||||||
templateUrl: './connection-manager.html',
|
templateUrl: './connection-manager.html',
|
||||||
host: {
|
host: {
|
||||||
'(window:keydown.escape)': 'onEscape()',
|
'(window:keydown.escape)': 'onEscape()',
|
||||||
|
'(window:keydown)': 'onWindowKeydown($event)',
|
||||||
'(window:contextmenu)': 'blockContextMenu($event)',
|
'(window:contextmenu)': 'blockContextMenu($event)',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -88,6 +90,7 @@ export class ConnectionManager {
|
|||||||
// injected for its side effects: fetches settings and applies theme/accent/
|
// injected for its side effects: fetches settings and applies theme/accent/
|
||||||
// text-size data attributes to this window's document root
|
// text-size data attributes to this window's document root
|
||||||
private readonly settings = inject(SettingsService);
|
private readonly settings = inject(SettingsService);
|
||||||
|
private readonly shortcuts = inject(ShortcutsService);
|
||||||
|
|
||||||
readonly draft = signal<Draft | null>(null);
|
readonly draft = signal<Draft | null>(null);
|
||||||
readonly busy = signal(false);
|
readonly busy = signal(false);
|
||||||
@@ -143,6 +146,16 @@ export class ConnectionManager {
|
|||||||
if (!this.dialogs.anyOpen) this.close();
|
if (!this.dialogs.anyOpen) this.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Any-window shortcuts (interface scale). Escape has its own binding above. */
|
||||||
|
onWindowKeydown(event: KeyboardEvent): void {
|
||||||
|
const match = this.shortcuts.resolve(event, 'manager');
|
||||||
|
if (!match || this.shortcuts.isTypingTarget(event)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (match.id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
||||||
|
else if (match.id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1);
|
||||||
|
else if (match.id === 'scale-reset') this.settings.setUiScale(1);
|
||||||
|
}
|
||||||
|
|
||||||
blockContextMenu(event: Event): void {
|
blockContextMenu(event: Event): void {
|
||||||
blockNativeContextMenu(event);
|
blockNativeContextMenu(event);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
viewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { NgIcon } from '@ng-icons/core';
|
||||||
|
import { ContextMenuService, type MenuItem } from '@core/context-menu.service';
|
||||||
|
import { FocusService } from '@core/focus.service';
|
||||||
|
import { stepIndex } from '@core/menu-nav';
|
||||||
|
import { remPx } from '@core/metrics';
|
||||||
|
|
||||||
|
/** Rough per-item metrics in REM, used only to decide whether the menu must
|
||||||
|
* flip. Converted through remPx() at open time so the math tracks the same
|
||||||
|
* root font-size the CSS scales with — the old px constants (`MENU_W = 224`)
|
||||||
|
* were 16px-root mirrors of rem classes, so the box grew with the interface
|
||||||
|
* scale while the flip math still believed 224 and fired at the wrong edge. */
|
||||||
|
const ITEM_H_REM = 1.625; // px-3 py-1 row + --path-font line ≈ 26px @16
|
||||||
|
const SEPARATOR_H_REM = 0.4375; // my-1 + 1px border ≈ 7px @16
|
||||||
|
const MENU_W_REM = 14; // w-56
|
||||||
|
const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel right-click menu. Replaces electrobun's native ContextMenu, which
|
||||||
|
* is a no-op on Linux — see ContextMenuService for the why. Emits the same
|
||||||
|
* `ctx-*` / `col:*` action strings the native menu did.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-context-menu',
|
||||||
|
imports: [NgIcon],
|
||||||
|
template: `
|
||||||
|
@if (menu.request(); as req) {
|
||||||
|
<!-- click-outside catcher; mousedown so it beats the panel's own handlers -->
|
||||||
|
<div class="fixed inset-0 z-50" (mousedown)="close()" (contextmenu)="onBackdropContext($event)"></div>
|
||||||
|
<div
|
||||||
|
#menuEl
|
||||||
|
role="menu"
|
||||||
|
tabindex="-1"
|
||||||
|
[attr.aria-activedescendant]="activeIndex() >= 0 ? 'ctx-item-' + activeIndex() : null"
|
||||||
|
(keydown)="onKeydown($event)"
|
||||||
|
[style.left.px]="position().x"
|
||||||
|
[style.top.px]="position().y"
|
||||||
|
class="fixed z-50 w-56 border border-border/60 bg-chrome py-1 text-foreground shadow-xl outline-none"
|
||||||
|
>
|
||||||
|
@for (item of req.items; track $index) {
|
||||||
|
@if (item.type === 'separator') {
|
||||||
|
<div class="my-1 border-t border-border/60"></div>
|
||||||
|
} @else {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
[attr.id]="'ctx-item-' + $index"
|
||||||
|
[title]="item.tooltip ?? ''"
|
||||||
|
[disabled]="!!item.disabled"
|
||||||
|
[class.bg-primary-surface]="$index === activeIndex() && !item.disabled"
|
||||||
|
[class.text-primary]="$index === activeIndex() && !item.disabled"
|
||||||
|
(mouseenter)="activeIndex.set($index)"
|
||||||
|
(click)="pick(item)"
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-1 text-left text-(length:--path-font) disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span class="w-3.5 shrink-0">
|
||||||
|
@if (item.checked) {
|
||||||
|
<ng-icon name="tdesignCheck" size="0.85rem" class="text-primary" />
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span class="flex-1 truncate">{{ item.label }}</span>
|
||||||
|
@if (item.accelerator) {
|
||||||
|
<span class="shrink-0 text-muted-foreground">{{ item.accelerator }}</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
// Closing on scroll/blur matches native menu behavior: the anchor row moves
|
||||||
|
// out from under a menu that would otherwise float over stale content.
|
||||||
|
host: {
|
||||||
|
'(window:blur)': 'close()',
|
||||||
|
'(window:resize)': 'close()',
|
||||||
|
'(document:scroll)': 'close()',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class ContextMenu {
|
||||||
|
readonly menu = inject(ContextMenuService);
|
||||||
|
private readonly focus = inject(FocusService);
|
||||||
|
private readonly menuEl = viewChild<ElementRef<HTMLDivElement>>('menuEl');
|
||||||
|
|
||||||
|
readonly activeIndex = signal(-1);
|
||||||
|
|
||||||
|
/** Click point, flipped/clamped so the menu never overflows the viewport.
|
||||||
|
* All UI metrics convert through the live root font-size; window.inner* is
|
||||||
|
* viewport CSS px, so both sides of every comparison share units. */
|
||||||
|
readonly position = computed(() => {
|
||||||
|
const req = this.menu.request();
|
||||||
|
if (!req) return { x: 0, y: 0 };
|
||||||
|
const itemH = remPx(ITEM_H_REM);
|
||||||
|
const separatorH = remPx(SEPARATOR_H_REM);
|
||||||
|
const menuW = remPx(MENU_W_REM);
|
||||||
|
const height =
|
||||||
|
req.items.reduce((sum, i) => sum + (i.type === 'separator' ? separatorH : itemH), 0) +
|
||||||
|
remPx(0.5); // the menu's py-1 (0.25rem x2)
|
||||||
|
const x =
|
||||||
|
req.x + menuW > window.innerWidth - EDGE_GAP ? Math.max(EDGE_GAP, req.x - menuW) : req.x;
|
||||||
|
const y =
|
||||||
|
req.y + height > window.innerHeight - EDGE_GAP
|
||||||
|
? Math.max(EDGE_GAP, req.y - height)
|
||||||
|
: req.y;
|
||||||
|
return { x, y };
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Focus the menu once it exists so arrow keys / Escape reach it. Keyed on
|
||||||
|
// the viewChild signal, NOT on request(): under zoneless CD an effect can
|
||||||
|
// run before the @if has rendered the element.
|
||||||
|
effect(() => this.menuEl()?.nativeElement.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
if (!this.menu.request()) return;
|
||||||
|
this.menu.close();
|
||||||
|
this.activeIndex.set(-1); // a fresh menu starts with nothing highlighted
|
||||||
|
this.focus.bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
pick(item: MenuItem): void {
|
||||||
|
// the keyboard path never consults the disabled attribute — guard here
|
||||||
|
if (item.disabled) return;
|
||||||
|
if (!item.action) return;
|
||||||
|
const action = item.action;
|
||||||
|
this.close();
|
||||||
|
this.menu.onPick(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Right-clicking the backdrop closes rather than stacking a second menu. */
|
||||||
|
onBackdropContext(event: MouseEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeydown(event: KeyboardEvent): void {
|
||||||
|
const items = this.menu.request()?.items ?? [];
|
||||||
|
switch (event.key) {
|
||||||
|
case 'Escape':
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.close();
|
||||||
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
event.preventDefault();
|
||||||
|
this.step(items, 1);
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
event.preventDefault();
|
||||||
|
this.step(items, -1);
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
case ' ': {
|
||||||
|
event.preventDefault();
|
||||||
|
const item = items[this.activeIndex()];
|
||||||
|
if (item) this.pick(item);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move the highlight by one selectable item (see stepIndex). */
|
||||||
|
private step(items: MenuItem[], delta: 1 | -1): void {
|
||||||
|
const selectable = items.map((i) => i.type !== 'separator' && !i.disabled);
|
||||||
|
this.activeIndex.set(stepIndex(selectable, this.activeIndex(), delta));
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-8
@@ -1,7 +1,7 @@
|
|||||||
import { Component, computed, inject, input, signal } from '@angular/core';
|
import { Component, computed, inject, input, signal } from '@angular/core';
|
||||||
import { I18nService } from '../i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import { DialogsService, type ChmodRequest } from '../dialogs.service';
|
import { DialogsService, type ChmodRequest } from '@core/dialogs.service';
|
||||||
import { FocusService } from '../focus.service';
|
import { FocusService } from '@core/focus.service';
|
||||||
|
|
||||||
const ROLES = ['roleOwner', 'roleGroup', 'roleOthers'] as const;
|
const ROLES = ['roleOwner', 'roleGroup', 'roleOthers'] as const;
|
||||||
const PERMS = ['r', 'w', 'x'] as const;
|
const PERMS = ['r', 'w', 'x'] as const;
|
||||||
@@ -11,7 +11,7 @@ const PERMS = ['r', 'w', 'x'] as const;
|
|||||||
template: `
|
template: `
|
||||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
|
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
|
||||||
<div
|
<div
|
||||||
class="w-80 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
class="w-80 border border-border bg-card p-4 text-card-foreground shadow-xl"
|
||||||
(mousedown)="$event.stopPropagation()"
|
(mousedown)="$event.stopPropagation()"
|
||||||
(keydown.escape)="cancel()"
|
(keydown.escape)="cancel()"
|
||||||
>
|
>
|
||||||
@@ -37,7 +37,7 @@ const PERMS = ['r', 'w', 'x'] as const;
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
[checked]="hasBit(r, p)"
|
[checked]="hasBit(r, p)"
|
||||||
(change)="toggleBit(r, p)"
|
(change)="toggleBit(r, p)"
|
||||||
class="size-3.5 accent-primary"
|
class="size-3.5"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
}
|
}
|
||||||
@@ -53,20 +53,20 @@ const PERMS = ['r', 'w', 'x'] as const;
|
|||||||
(input)="setOctal($any($event.target).value)"
|
(input)="setOctal($any($event.target).value)"
|
||||||
maxlength="4"
|
maxlength="4"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
class="h-7 w-20 rounded-md border border-input bg-transparent px-2 font-mono text-xs outline-none focus:ring-2 focus:ring-ring"
|
class="h-7 w-20 border border-input bg-transparent px-2 font-mono text-xs outline-none focus:ring-2 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex justify-end gap-2">
|
<div class="mt-4 flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
(click)="cancel()"
|
(click)="cancel()"
|
||||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
class="px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||||
>
|
>
|
||||||
{{ i18n.t('common.cancel') }}
|
{{ i18n.t('common.cancel') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="submit()"
|
(click)="submit()"
|
||||||
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
class="bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
{{ i18n.t('common.apply') }}
|
{{ i18n.t('common.apply') }}
|
||||||
</button>
|
</button>
|
||||||
+7
-7
@@ -1,14 +1,14 @@
|
|||||||
import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core';
|
import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core';
|
||||||
import { I18nService } from '../i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import { DialogsService, type ConfirmRequest } from '../dialogs.service';
|
import { DialogsService, type ConfirmRequest } from '@core/dialogs.service';
|
||||||
import { FocusService } from '../focus.service';
|
import { FocusService } from '@core/focus.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-confirm-dialog',
|
selector: 'app-confirm-dialog',
|
||||||
template: `
|
template: `
|
||||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
|
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
|
||||||
<div
|
<div
|
||||||
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
class="w-96 border border-border bg-card p-4 text-card-foreground shadow-xl"
|
||||||
(mousedown)="$event.stopPropagation()"
|
(mousedown)="$event.stopPropagation()"
|
||||||
(keydown.escape)="cancel()"
|
(keydown.escape)="cancel()"
|
||||||
>
|
>
|
||||||
@@ -17,7 +17,7 @@ import { FocusService } from '../focus.service';
|
|||||||
<div class="mt-4 flex justify-end gap-2">
|
<div class="mt-4 flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
(click)="cancel()"
|
(click)="cancel()"
|
||||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
class="px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||||
>
|
>
|
||||||
{{ i18n.t('common.cancel') }}
|
{{ i18n.t('common.cancel') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -26,8 +26,8 @@ import { FocusService } from '../focus.service';
|
|||||||
(click)="submit()"
|
(click)="submit()"
|
||||||
[class]="
|
[class]="
|
||||||
request().destructive
|
request().destructive
|
||||||
? 'rounded-md bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground hover:bg-destructive/90'
|
? 'bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground hover:bg-destructive/90'
|
||||||
: 'rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90'
|
: 'bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{ request().confirmLabel }}
|
{{ request().confirmLabel }}
|
||||||
+15
-15
@@ -8,9 +8,9 @@ import {
|
|||||||
viewChild,
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import { I18nService } from '../i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '../dialogs.service';
|
import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '@core/dialogs.service';
|
||||||
import { FocusService } from '../focus.service';
|
import { FocusService } from '@core/focus.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-overwrite-dialog',
|
selector: 'app-overwrite-dialog',
|
||||||
@@ -21,7 +21,7 @@ import { FocusService } from '../focus.service';
|
|||||||
(keydown.escape)="pick('cancel')"
|
(keydown.escape)="pick('cancel')"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-[26rem] rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
class="w-[26rem] border border-border bg-card p-4 text-card-foreground shadow-xl"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<h2 class="min-w-0 text-sm font-semibold">
|
<h2 class="min-w-0 text-sm font-semibold">
|
||||||
@@ -30,7 +30,7 @@ import { FocusService } from '../focus.service';
|
|||||||
</h2>
|
</h2>
|
||||||
@if (request().total > 1) {
|
@if (request().total > 1) {
|
||||||
<span
|
<span
|
||||||
class="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground"
|
class="shrink-0 bg-muted px-2 py-0.5 text-[0.625rem] font-medium text-muted-foreground"
|
||||||
>
|
>
|
||||||
{{ i18n.t('dialogs.ofTotal', { index: request().index, total: request().total }) }}
|
{{ i18n.t('dialogs.ofTotal', { index: request().index, total: request().total }) }}
|
||||||
</span>
|
</span>
|
||||||
@@ -47,20 +47,20 @@ import { FocusService } from '../focus.service';
|
|||||||
<!-- comparison -->
|
<!-- comparison -->
|
||||||
<div class="mt-3 grid grid-cols-2 gap-2">
|
<div class="mt-3 grid grid-cols-2 gap-2">
|
||||||
@for (side of comparison(); track side.label) {
|
@for (side of comparison(); track side.label) {
|
||||||
<div class="rounded-md border border-border p-2.5">
|
<div class="border border-border p-2.5">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
|
<span class="text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
|
||||||
{{ i18n.t('dialogs.' + side.label) }}
|
{{ i18n.t('dialogs.' + side.label) }}
|
||||||
</span>
|
</span>
|
||||||
@if (side.newer) {
|
@if (side.newer) {
|
||||||
<span class="rounded-full bg-success/15 px-1.5 text-[10px] font-medium text-success">
|
<span class="bg-success/15 px-1.5 text-[0.625rem] font-medium text-success">
|
||||||
{{ i18n.t('dialogs.newer') }}
|
{{ i18n.t('dialogs.newer') }}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1.5 flex items-center gap-1.5 text-xs">
|
<div class="mt-1.5 flex items-center gap-1.5 text-xs">
|
||||||
<ng-icon
|
<ng-icon
|
||||||
[name]="isDir() ? 'mynaFolder' : 'mynaFile'"
|
[name]="isDir() ? 'tdesignFolder' : 'tdesignFile'"
|
||||||
size="0.9rem"
|
size="0.9rem"
|
||||||
class="shrink-0 text-muted-foreground"
|
class="shrink-0 text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
@@ -68,7 +68,7 @@ import { FocusService } from '../focus.service';
|
|||||||
{{ isDir() ? '—' : i18n.size(side.entry.size) }}
|
{{ isDir() ? '—' : i18n.size(side.entry.size) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 font-mono text-[11px] tabular-nums text-muted-foreground">
|
<div class="mt-1 font-mono text-[0.6875rem] tabular-nums text-muted-foreground">
|
||||||
{{ i18n.date(side.entry.mtime) }}
|
{{ i18n.date(side.entry.mtime) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,7 +79,7 @@ import { FocusService } from '../focus.service';
|
|||||||
<div class="mt-4 flex items-center gap-2">
|
<div class="mt-4 flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
(click)="pick('cancel')"
|
(click)="pick('cancel')"
|
||||||
class="rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
|
class="px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
{{ i18n.t('common.cancel') }}
|
{{ i18n.t('common.cancel') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -87,13 +87,13 @@ import { FocusService } from '../focus.service';
|
|||||||
@if (request().total > 1) {
|
@if (request().total > 1) {
|
||||||
<button
|
<button
|
||||||
(click)="pick('skipAll')"
|
(click)="pick('skipAll')"
|
||||||
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
class="px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
{{ i18n.t('dialogs.skipAll') }}
|
{{ i18n.t('dialogs.skipAll') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="pick('overwriteAll')"
|
(click)="pick('overwriteAll')"
|
||||||
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
class="px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
{{ isDir() ? i18n.t('dialogs.mergeAll') : i18n.t('dialogs.replaceAll') }}
|
{{ isDir() ? i18n.t('dialogs.mergeAll') : i18n.t('dialogs.replaceAll') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -101,14 +101,14 @@ import { FocusService } from '../focus.service';
|
|||||||
}
|
}
|
||||||
<button
|
<button
|
||||||
(click)="pick('skip')"
|
(click)="pick('skip')"
|
||||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
class="px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||||
>
|
>
|
||||||
{{ i18n.t('dialogs.skip') }}
|
{{ i18n.t('dialogs.skip') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
#primaryBtn
|
#primaryBtn
|
||||||
(click)="pick('overwrite')"
|
(click)="pick('overwrite')"
|
||||||
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
class="bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
{{ isDir() ? i18n.t('dialogs.merge') : i18n.t('dialogs.replace') }}
|
{{ isDir() ? i18n.t('dialogs.merge') : i18n.t('dialogs.replace') }}
|
||||||
</button>
|
</button>
|
||||||
+7
-7
@@ -7,16 +7,16 @@ import {
|
|||||||
signal,
|
signal,
|
||||||
viewChild,
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { I18nService } from '../i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import { DialogsService, type PromptRequest } from '../dialogs.service';
|
import { DialogsService, type PromptRequest } from '@core/dialogs.service';
|
||||||
import { FocusService } from '../focus.service';
|
import { FocusService } from '@core/focus.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-prompt-dialog',
|
selector: 'app-prompt-dialog',
|
||||||
template: `
|
template: `
|
||||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
|
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
|
||||||
<div
|
<div
|
||||||
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
class="w-96 border border-border bg-card p-4 text-card-foreground shadow-xl"
|
||||||
(mousedown)="$event.stopPropagation()"
|
(mousedown)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<h2 class="text-sm font-semibold">{{ request().title }}</h2>
|
<h2 class="text-sm font-semibold">{{ request().title }}</h2>
|
||||||
@@ -28,19 +28,19 @@ import { FocusService } from '../focus.service';
|
|||||||
(keydown.escape)="cancel()"
|
(keydown.escape)="cancel()"
|
||||||
[placeholder]="request().placeholder ?? ''"
|
[placeholder]="request().placeholder ?? ''"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
class="mt-3 h-8 w-full rounded-md border border-input bg-transparent px-2 text-sm outline-none focus:ring-2 focus:ring-ring"
|
class="mt-3 h-8 w-full border border-input bg-transparent px-2 text-sm outline-none focus:ring-2 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
<div class="mt-4 flex justify-end gap-2">
|
<div class="mt-4 flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
(click)="cancel()"
|
(click)="cancel()"
|
||||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
class="px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||||
>
|
>
|
||||||
{{ i18n.t('common.cancel') }}
|
{{ i18n.t('common.cancel') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="submit()"
|
(click)="submit()"
|
||||||
[disabled]="!value().trim()"
|
[disabled]="!value().trim()"
|
||||||
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
class="bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{{ request().confirmLabel }}
|
{{ request().confirmLabel }}
|
||||||
</button>
|
</button>
|
||||||
+33
-33
@@ -1,8 +1,8 @@
|
|||||||
<div class="flex h-screen flex-col bg-background text-foreground">
|
<div class="flex h-screen flex-col bg-background text-foreground">
|
||||||
@if (!ctx) {
|
@if (!ctx) {
|
||||||
<div class="flex flex-1 items-center justify-center">
|
<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">
|
<div class="max-w-sm border border-border bg-card p-6 text-center shadow-sm">
|
||||||
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
|
<ng-icon name="tdesignCloseCircle" size="1.5rem" class="text-destructive" />
|
||||||
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.noFileTitle') }}</p>
|
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.noFileTitle') }}</p>
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
{{ i18n.t('editor.noFileBody') }}
|
{{ i18n.t('editor.noFileBody') }}
|
||||||
@@ -20,16 +20,16 @@
|
|||||||
[class.h-9]="!isMacChrome"
|
[class.h-9]="!isMacChrome"
|
||||||
[class.pl-3]="!isMacChrome"
|
[class.pl-3]="!isMacChrome"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaFileText" size="0.95rem" class="shrink-0 text-muted-foreground" />
|
<ng-icon name="tdesignFile1" size="0.95rem" class="shrink-0 text-muted-foreground" />
|
||||||
<h1 class="min-w-0 truncate font-mono text-sm font-semibold">
|
<h1 class="min-w-0 truncate font-mono text-sm font-semibold">
|
||||||
{{ ctx.name }}{{ dirty() ? ' •' : '' }}
|
{{ ctx.name }}{{ dirty() ? ' •' : '' }}
|
||||||
</h1>
|
</h1>
|
||||||
<span class="shrink-0 text-[11px] text-muted-foreground">{{ ctx.sessionTitle }}</span>
|
<span class="shrink-0 text-[0.6875rem] text-muted-foreground">{{ ctx.sessionTitle }}</span>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button
|
<button
|
||||||
(click)="toggleWrap()"
|
(click)="toggleWrap()"
|
||||||
[title]="i18n.t('editor.wrapHint', { alt: altLabel })"
|
[title]="i18n.t('editor.wrapHint', { alt: altLabel })"
|
||||||
class="rounded-md px-2 py-1 text-[11px] electrobun-webkit-app-region-no-drag hover:bg-accent"
|
class="px-2 py-1 text-[0.6875rem] electrobun-webkit-app-region-no-drag hover:bg-accent"
|
||||||
[class.bg-accent]="wrap()"
|
[class.bg-accent]="wrap()"
|
||||||
[class.text-foreground]="wrap()"
|
[class.text-foreground]="wrap()"
|
||||||
[class.text-muted-foreground]="!wrap()"
|
[class.text-muted-foreground]="!wrap()"
|
||||||
@@ -39,15 +39,15 @@
|
|||||||
<button
|
<button
|
||||||
(click)="openFind()"
|
(click)="openFind()"
|
||||||
[title]="i18n.t('editor.findHint', { mod: modLabel })"
|
[title]="i18n.t('editor.findHint', { mod: modLabel })"
|
||||||
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
class="grid size-6 place-items-center text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaSearch" size="0.85rem" />
|
<ng-icon name="tdesignSearch" size="0.85rem" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@if (findOpen()) {
|
@if (findOpen()) {
|
||||||
<div class="flex shrink-0 items-center gap-2 border-b border-border bg-chrome px-3 py-1.5">
|
<div class="flex shrink-0 items-center gap-2 border-b border-border bg-toolbar px-3 py-1.5">
|
||||||
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
<ng-icon name="tdesignSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
||||||
<input
|
<input
|
||||||
#findBox
|
#findBox
|
||||||
[value]="findQuery()"
|
[value]="findQuery()"
|
||||||
@@ -55,10 +55,10 @@
|
|||||||
(keydown)="onFindKeydown($event)"
|
(keydown)="onFindKeydown($event)"
|
||||||
[placeholder]="i18n.t('editor.findPlaceholder')"
|
[placeholder]="i18n.t('editor.findPlaceholder')"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
class="h-6 w-56 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-2 focus:ring-ring"
|
class="h-6 w-56 border border-input bg-transparent px-2 text-xs outline-none focus:ring-2 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
class="text-[11px] tabular-nums"
|
class="text-[0.6875rem] tabular-nums"
|
||||||
[class.text-destructive]="findQuery() && !matches().length"
|
[class.text-destructive]="findQuery() && !matches().length"
|
||||||
[class.text-muted-foreground]="!findQuery() || matches().length"
|
[class.text-muted-foreground]="!findQuery() || matches().length"
|
||||||
>
|
>
|
||||||
@@ -67,50 +67,50 @@
|
|||||||
<button
|
<button
|
||||||
(click)="findStep(-1)"
|
(click)="findStep(-1)"
|
||||||
[title]="i18n.t('editor.prevMatch')"
|
[title]="i18n.t('editor.prevMatch')"
|
||||||
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaChevronUp" size="0.8rem" />
|
<ng-icon name="tdesignChevronUp" size="0.8rem" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="findStep(1)"
|
(click)="findStep(1)"
|
||||||
[title]="i18n.t('editor.nextMatch')"
|
[title]="i18n.t('editor.nextMatch')"
|
||||||
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaChevronDown" size="0.8rem" />
|
<ng-icon name="tdesignChevronDown" size="0.8rem" />
|
||||||
</button>
|
</button>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button
|
<button
|
||||||
(click)="closeFind()"
|
(click)="closeFind()"
|
||||||
[title]="i18n.t('editor.closeHint')"
|
[title]="i18n.t('editor.closeHint')"
|
||||||
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaX" size="0.8rem" />
|
<ng-icon name="tdesignClose" size="0.8rem" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (sessionDead()) {
|
@if (sessionDead()) {
|
||||||
<div
|
<div
|
||||||
class="flex shrink-0 items-center gap-2 border-b border-border bg-destructive/10 px-3 py-1.5 text-[11px] text-destructive"
|
class="flex shrink-0 items-center gap-2 border-b border-border bg-destructive/10 px-3 py-1.5 text-[0.6875rem] text-destructive"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaXCircle" size="0.85rem" class="shrink-0" />
|
<ng-icon name="tdesignCloseCircle" size="0.85rem" class="shrink-0" />
|
||||||
{{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }}
|
{{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="flex flex-1 items-center justify-center">
|
<div class="flex flex-1 items-center justify-center">
|
||||||
<ng-icon name="mynaSpinner" size="1.25rem" class="animate-spin text-primary" />
|
<ng-icon name="tdesignLoading" size="1.25rem" class="animate-spin text-primary" />
|
||||||
</div>
|
</div>
|
||||||
} @else if (loadError(); as error) {
|
} @else if (loadError(); as error) {
|
||||||
<div class="flex flex-1 items-center justify-center">
|
<div class="flex flex-1 items-center justify-center">
|
||||||
<div class="max-w-md rounded-lg border border-border bg-card p-6 text-center shadow-sm">
|
<div class="max-w-md border border-border bg-card p-6 text-center shadow-sm">
|
||||||
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
|
<ng-icon name="tdesignCloseCircle" size="1.5rem" class="text-destructive" />
|
||||||
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.cantOpen', { name: ctx.name }) }}</p>
|
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.cantOpen', { name: ctx.name }) }}</p>
|
||||||
<p class="mt-1 text-xs text-muted-foreground">{{ error }}</p>
|
<p class="mt-1 text-xs text-muted-foreground">{{ error }}</p>
|
||||||
<button
|
<button
|
||||||
(click)="discardAndClose()"
|
(click)="discardAndClose()"
|
||||||
class="mt-4 rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
class="mt-4 border border-border px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||||
>
|
>
|
||||||
{{ i18n.t('editor.closeWindow') }}
|
{{ i18n.t('editor.closeWindow') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -141,25 +141,25 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="flex shrink-0 items-center gap-2 border-t border-border bg-chrome px-4 py-2">
|
<footer class="flex shrink-0 items-center gap-2 border-t border-border bg-chrome px-4 py-2">
|
||||||
<span class="text-[11px] text-muted-foreground">{{ i18n.t('editor.shortcuts', { mod: modLabel }) }}</span>
|
<span class="text-[0.6875rem] text-muted-foreground">{{ i18n.t('editor.shortcuts', { mod: modLabel }) }}</span>
|
||||||
<span class="text-[11px] text-muted-foreground">·</span>
|
<span class="text-[0.6875rem] text-muted-foreground">·</span>
|
||||||
<span class="text-[11px] tabular-nums text-muted-foreground">
|
<span class="text-[0.6875rem] tabular-nums text-muted-foreground">
|
||||||
{{ i18n.t('editor.lines', { count: lineCount() }) }}
|
{{ i18n.t('editor.lines', { count: lineCount() }) }}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button
|
<button
|
||||||
(click)="requestClose()"
|
(click)="requestClose()"
|
||||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
class="px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||||
>
|
>
|
||||||
{{ i18n.t('editor.close') }}
|
{{ i18n.t('editor.close') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="save()"
|
(click)="save()"
|
||||||
[disabled]="saving() || !dirty()"
|
[disabled]="saving() || !dirty()"
|
||||||
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"
|
class="flex items-center gap-1.5 bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
@if (saving()) {
|
@if (saving()) {
|
||||||
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
|
<ng-icon name="tdesignLoading" size="0.85rem" class="animate-spin" />
|
||||||
}
|
}
|
||||||
{{ i18n.t('common.save') }}
|
{{ i18n.t('common.save') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -171,7 +171,7 @@
|
|||||||
@if (closePrompt()) {
|
@if (closePrompt()) {
|
||||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancelClose()">
|
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancelClose()">
|
||||||
<div
|
<div
|
||||||
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
class="w-96 border border-border bg-card p-4 text-card-foreground shadow-xl"
|
||||||
(mousedown)="$event.stopPropagation()"
|
(mousedown)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<h2 class="text-sm font-semibold">{{ i18n.t('editor.saveChangesTitle', { name: ctx.name }) }}</h2>
|
<h2 class="text-sm font-semibold">{{ i18n.t('editor.saveChangesTitle', { name: ctx.name }) }}</h2>
|
||||||
@@ -181,21 +181,21 @@
|
|||||||
<div class="mt-4 flex items-center gap-2">
|
<div class="mt-4 flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
(click)="discardAndClose()"
|
(click)="discardAndClose()"
|
||||||
class="rounded-md px-3 py-1.5 text-xs font-medium text-destructive hover:bg-accent"
|
class="px-3 py-1.5 text-xs font-medium text-destructive hover:bg-accent"
|
||||||
>
|
>
|
||||||
{{ i18n.t('editor.dontSave') }}
|
{{ i18n.t('editor.dontSave') }}
|
||||||
</button>
|
</button>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button
|
<button
|
||||||
(click)="cancelClose()"
|
(click)="cancelClose()"
|
||||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
class="px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||||
>
|
>
|
||||||
{{ i18n.t('common.cancel') }}
|
{{ i18n.t('common.cancel') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="saveAndClose()"
|
(click)="saveAndClose()"
|
||||||
[disabled]="saving()"
|
[disabled]="saving()"
|
||||||
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
class="bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{{ i18n.t('common.save') }}
|
{{ i18n.t('common.save') }}
|
||||||
</button>
|
</button>
|
||||||
+54
-21
@@ -10,11 +10,12 @@ import {
|
|||||||
viewChild,
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import { I18nService } from '../i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME, MOD_LABEL } from '../platform';
|
import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME, MOD_LABEL } from '@core/platform';
|
||||||
import { RpcService } from '../rpc.service';
|
import { RpcService } from '@core/rpc.service';
|
||||||
import { SettingsService } from '../settings.service';
|
import { SettingsService } from '@core/settings.service';
|
||||||
import { ToastService } from '../toast.service';
|
import { ShortcutsService } from '@core/shortcuts.service';
|
||||||
|
import { ToastService } from '@core/toast.service';
|
||||||
import { Toasts } from '../toasts/toasts';
|
import { Toasts } from '../toasts/toasts';
|
||||||
|
|
||||||
/** Debounce for mirroring the draft to bun (the traffic-light-close safety net). */
|
/** Debounce for mirroring the draft to bun (the traffic-light-close safety net). */
|
||||||
@@ -94,12 +95,14 @@ export class EditorWindow {
|
|||||||
private readonly gutter = viewChild<ElementRef<HTMLElement>>('gutter');
|
private readonly gutter = viewChild<ElementRef<HTMLElement>>('gutter');
|
||||||
private readonly findBox = viewChild<ElementRef<HTMLInputElement>>('findBox');
|
private readonly findBox = viewChild<ElementRef<HTMLInputElement>>('findBox');
|
||||||
private cachedCharWidth = 0;
|
private cachedCharWidth = 0;
|
||||||
|
private cachedCharFont = '';
|
||||||
private readonly rpc = inject(RpcService);
|
private readonly rpc = inject(RpcService);
|
||||||
private readonly toast = inject(ToastService);
|
private readonly toast = inject(ToastService);
|
||||||
private readonly document = inject(DOCUMENT);
|
private readonly document = inject(DOCUMENT);
|
||||||
// injected for its side effects: fetches settings and applies theme/accent/
|
// injected for its side effects: fetches settings and applies theme/accent/
|
||||||
// text-size data attributes to this window's document root
|
// text-size data attributes to this window's document root
|
||||||
private readonly settings = inject(SettingsService);
|
private readonly settings = inject(SettingsService);
|
||||||
|
private readonly shortcuts = inject(ShortcutsService);
|
||||||
|
|
||||||
private draftTimer: ReturnType<typeof setTimeout> | null = null;
|
private draftTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private lastSentDirty = false;
|
private lastSentDirty = false;
|
||||||
@@ -214,7 +217,11 @@ export class EditorWindow {
|
|||||||
el.setSelectionRange(start, start + this.findQuery().length);
|
el.setSelectionRange(start, start + this.findQuery().length);
|
||||||
|
|
||||||
const text = this.text();
|
const text = this.text();
|
||||||
const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 20;
|
// Fallback derives from the element's own font size, not a hardcoded 20 —
|
||||||
|
// the interface scale moves the root font-size, so any px literal here
|
||||||
|
// would be a 16px-root assumption that miscentres at every other scale.
|
||||||
|
const style = getComputedStyle(el);
|
||||||
|
const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.625;
|
||||||
let line = 0;
|
let line = 0;
|
||||||
let i = text.indexOf('\n');
|
let i = text.indexOf('\n');
|
||||||
while (i !== -1 && i < start) {
|
while (i !== -1 && i < start) {
|
||||||
@@ -229,12 +236,17 @@ export class EditorWindow {
|
|||||||
this.onScroll();
|
this.onScroll();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Monospace advance width, measured once per window. */
|
/** Monospace advance width, cached per computed FONT rather than per window:
|
||||||
|
* the interface scale changes the root font-size live, and a cache measured
|
||||||
|
* once would keep positioning finds with the old advance width. Keying on
|
||||||
|
* the font string makes it self-invalidating under any font change. */
|
||||||
private charWidth(el: HTMLTextAreaElement): number {
|
private charWidth(el: HTMLTextAreaElement): number {
|
||||||
if (!this.cachedCharWidth) {
|
const font = getComputedStyle(el).font;
|
||||||
|
if (font !== this.cachedCharFont) {
|
||||||
const canvas = this.document.createElement('canvas').getContext('2d')!;
|
const canvas = this.document.createElement('canvas').getContext('2d')!;
|
||||||
canvas.font = getComputedStyle(el).font;
|
canvas.font = font;
|
||||||
this.cachedCharWidth = canvas.measureText('0').width || 7;
|
this.cachedCharWidth = canvas.measureText('0').width || parseFloat(getComputedStyle(el).fontSize) * 0.6;
|
||||||
|
this.cachedCharFont = font;
|
||||||
}
|
}
|
||||||
return this.cachedCharWidth;
|
return this.cachedCharWidth;
|
||||||
}
|
}
|
||||||
@@ -307,22 +319,43 @@ export class EditorWindow {
|
|||||||
// ---- keyboard ----
|
// ---- keyboard ----
|
||||||
|
|
||||||
onKeydown(event: KeyboardEvent): void {
|
onKeydown(event: KeyboardEvent): void {
|
||||||
const mod = event.metaKey || event.ctrlKey;
|
// Chord shortcuts resolve through the shared keymap (ShortcutsService);
|
||||||
if (mod && event.key.toLowerCase() === 's') {
|
// Escape and Tab stay below because their meaning is THIS window's state
|
||||||
|
// (close prompt up? find bar open? caret in the textarea?), not a binding.
|
||||||
|
const match = this.shortcuts.resolve(event, 'editor');
|
||||||
|
if (match) {
|
||||||
|
// scale keys must not fire while typing — but the editor's own chords
|
||||||
|
// (save/find/wrap) are WANTED mid-typing, so the guard is per-shortcut
|
||||||
|
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
switch (match.id) {
|
||||||
|
case 'save':
|
||||||
void this.save();
|
void this.save();
|
||||||
} else if (mod && event.key.toLowerCase() === 'f') {
|
break;
|
||||||
event.preventDefault();
|
case 'find':
|
||||||
this.openFind();
|
this.openFind();
|
||||||
} else if (mod && event.key.toLowerCase() === 'w') {
|
break;
|
||||||
// reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W and
|
case 'close-window':
|
||||||
// bun routes it back as editorCloseRequest
|
// reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W
|
||||||
event.preventDefault();
|
// and bun routes it back as editorCloseRequest
|
||||||
this.requestClose();
|
this.requestClose();
|
||||||
} else if (event.altKey && event.code === 'KeyZ') {
|
break;
|
||||||
event.preventDefault();
|
case 'toggle-wrap':
|
||||||
this.toggleWrap();
|
this.toggleWrap();
|
||||||
} else if (event.key === 'Escape' && this.closePrompt()) {
|
break;
|
||||||
|
case 'scale-up':
|
||||||
|
this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
||||||
|
break;
|
||||||
|
case 'scale-down':
|
||||||
|
this.settings.setUiScale(this.settings.uiScale() - 0.1);
|
||||||
|
break;
|
||||||
|
case 'scale-reset':
|
||||||
|
this.settings.setUiScale(1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape' && this.closePrompt()) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.cancelClose();
|
this.cancelClose();
|
||||||
} else if (event.key === 'Escape' && this.findOpen()) {
|
} else if (event.key === 'Escape' && this.findOpen()) {
|
||||||
+46
-42
@@ -1,22 +1,25 @@
|
|||||||
<!-- session strip -->
|
<!-- session strip -->
|
||||||
<div class="relative flex items-center gap-1.5 border-b border-border px-2 py-1">
|
<div
|
||||||
|
class="relative flex items-center gap-1.5 border-b border-border bg-toolbar px-2 py-1"
|
||||||
|
[style.box-shadow]="activeMarker()"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
(click)="toggleMenu()"
|
(click)="toggleMenu()"
|
||||||
class="flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1 text-(length:--path-font) font-medium hover:bg-accent"
|
class="flex min-w-0 items-center gap-1.5 px-2 py-1 text-(length:--path-font) font-medium hover:bg-accent"
|
||||||
[title]="i18n.t('panel.choosePanel')"
|
[title]="i18n.t('panel.choosePanel')"
|
||||||
>
|
>
|
||||||
@if (panel().connecting()) {
|
@if (panel().connecting()) {
|
||||||
<ng-icon name="mynaSpinner" class="shrink-0 animate-spin text-primary" size="var(--action-icon)" />
|
<ng-icon name="tdesignLoading" class="shrink-0 animate-spin text-primary" size="var(--action-icon)" />
|
||||||
} @else {
|
} @else {
|
||||||
<ng-icon
|
<ng-icon
|
||||||
[name]="isRemote() ? 'mynaServers' : 'mynaHardDrive'"
|
[name]="isRemote() ? 'tdesignServer' : 'tdesignHardDrive'"
|
||||||
[class]="isRemote() ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'"
|
[class]="isRemote() ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'"
|
||||||
size="var(--action-icon)"
|
size="var(--action-icon)"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
<span class="truncate">{{ isRemote() ? panel().title() : i18n.t('common.local') }}</span>
|
<span class="truncate">{{ isRemote() ? panel().title() : i18n.t('common.local') }}</span>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="mynaChevronDown"
|
name="tdesignChevronDown"
|
||||||
class="shrink-0 text-muted-foreground"
|
class="shrink-0 text-muted-foreground"
|
||||||
size="calc(var(--action-icon) - 0.1rem)"
|
size="calc(var(--action-icon) - 0.1rem)"
|
||||||
/>
|
/>
|
||||||
@@ -28,7 +31,7 @@
|
|||||||
<button
|
<button
|
||||||
(click)="copyToOther()"
|
(click)="copyToOther()"
|
||||||
[title]="side() === 'left' ? i18n.t('panel.copyToRight') : i18n.t('panel.copyToLeft')"
|
[title]="side() === 'left' ? i18n.t('panel.copyToRight') : i18n.t('panel.copyToLeft')"
|
||||||
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-primary"
|
class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-primary"
|
||||||
>
|
>
|
||||||
<ng-icon [name]="copyIcon()" size="var(--action-icon)" />
|
<ng-icon [name]="copyIcon()" size="var(--action-icon)" />
|
||||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.copy') }}</span>
|
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.copy') }}</span>
|
||||||
@@ -36,64 +39,64 @@
|
|||||||
<button
|
<button
|
||||||
(click)="run('edit')"
|
(click)="run('edit')"
|
||||||
[title]="i18n.t('panel.editFile')"
|
[title]="i18n.t('panel.editFile')"
|
||||||
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaFileText" size="var(--action-icon)" />
|
<ng-icon name="tdesignFile1" size="var(--action-icon)" />
|
||||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.edit') }}</span>
|
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.edit') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="run('rename')"
|
(click)="run('rename')"
|
||||||
[title]="i18n.t('panel.rename')"
|
[title]="i18n.t('panel.rename')"
|
||||||
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaEdit" size="var(--action-icon)" />
|
<ng-icon name="tdesignEdit" size="var(--action-icon)" />
|
||||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.rename') }}</span>
|
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.rename') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="run('mkdir')"
|
(click)="run('mkdir')"
|
||||||
[title]="i18n.t('panel.newFolder')"
|
[title]="i18n.t('panel.newFolder')"
|
||||||
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
class="grid size-(--action-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaFolderPlus" size="var(--action-icon)" />
|
<ng-icon name="tdesignFolderAdd" size="var(--action-icon)" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="run('createFile')"
|
(click)="run('createFile')"
|
||||||
[title]="i18n.t('panel.newFile')"
|
[title]="i18n.t('panel.newFile')"
|
||||||
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
class="grid size-(--action-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaFilePlus" size="var(--action-icon)" />
|
<ng-icon name="tdesignFileAdd" size="var(--action-icon)" />
|
||||||
</button>
|
</button>
|
||||||
@if (chmodAvailable()) {
|
@if (chmodAvailable()) {
|
||||||
<button
|
<button
|
||||||
(click)="run('chmod')"
|
(click)="run('chmod')"
|
||||||
[title]="i18n.t('panel.permissions')"
|
[title]="i18n.t('panel.permissions')"
|
||||||
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
class="grid size-(--action-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaLock" size="var(--action-icon)" />
|
<ng-icon name="tdesignLockOn" size="var(--action-icon)" />
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<button
|
<button
|
||||||
(click)="run('delete')"
|
(click)="run('delete')"
|
||||||
[title]="i18n.t('panel.deleteHint')"
|
[title]="i18n.t('panel.deleteHint')"
|
||||||
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-destructive"
|
class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-destructive"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaTrash" size="var(--action-icon)" />
|
<ng-icon name="tdesignDelete" size="var(--action-icon)" />
|
||||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.delete') }}</span>
|
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.delete') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@if (menuOpen()) {
|
@if (menuOpen()) {
|
||||||
<div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div>
|
<div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div>
|
||||||
<div
|
<div
|
||||||
class="absolute top-full left-2 z-30 mt-1 max-h-80 w-64 overflow-y-auto rounded-lg border border-border bg-card py-1 text-card-foreground shadow-xl"
|
class="absolute top-full left-2 z-30 mt-1 max-h-80 w-64 overflow-y-auto border border-border bg-card py-1 text-card-foreground shadow-xl"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
(click)="pickLocal()"
|
(click)="pickLocal()"
|
||||||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
|
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
<ng-icon name="tdesignHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
||||||
<span class="flex-1">{{ i18n.t('common.local') }}</span>
|
<span class="flex-1">{{ i18n.t('common.local') }}</span>
|
||||||
@if (!isRemote()) {
|
@if (!isRemote()) {
|
||||||
<ng-icon name="mynaCheck" size="0.85rem" class="shrink-0 text-primary" />
|
<ng-icon name="tdesignCheck" size="0.85rem" class="shrink-0 text-primary" />
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -103,7 +106,7 @@
|
|||||||
(click)="pickDrive(drive)"
|
(click)="pickDrive(drive)"
|
||||||
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-6 text-left text-(length:--path-font) hover:bg-accent"
|
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-6 text-left text-(length:--path-font) hover:bg-accent"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
<ng-icon name="tdesignHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
||||||
<span class="flex-1 font-mono">{{ drive }}</span>
|
<span class="flex-1 font-mono">{{ drive }}</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -116,12 +119,12 @@
|
|||||||
(click)="pickConnection(connection)"
|
(click)="pickConnection(connection)"
|
||||||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
|
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
<ng-icon name="tdesignServer" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
||||||
<span class="flex-1 truncate">{{ connection.name }}</span>
|
<span class="flex-1 truncate">{{ connection.name }}</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@for (group of connections.grouped().stacks; track group.stack.id) {
|
@for (group of connections.grouped().stacks; track group.stack.id) {
|
||||||
<div class="px-3 pt-1.5 pb-0.5 text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
|
<div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
|
||||||
{{ group.stack.name }}
|
{{ group.stack.name }}
|
||||||
</div>
|
</div>
|
||||||
@for (connection of group.connections; track connection.id) {
|
@for (connection of group.connections; track connection.id) {
|
||||||
@@ -129,7 +132,7 @@
|
|||||||
(click)="pickConnection(connection)"
|
(click)="pickConnection(connection)"
|
||||||
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent"
|
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
<ng-icon name="tdesignServer" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
||||||
<span class="flex-1 truncate">{{ connection.name }}</span>
|
<span class="flex-1 truncate">{{ connection.name }}</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -137,7 +140,7 @@
|
|||||||
|
|
||||||
@if (connections.sshHosts().length) {
|
@if (connections.sshHosts().length) {
|
||||||
<div class="my-1 border-t border-border"></div>
|
<div class="my-1 border-t border-border"></div>
|
||||||
<div class="px-3 pt-1.5 pb-0.5 text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
|
<div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
|
||||||
~/.ssh/config
|
~/.ssh/config
|
||||||
</div>
|
</div>
|
||||||
@for (host of connections.sshHosts(); track host.name) {
|
@for (host of connections.sshHosts(); track host.name) {
|
||||||
@@ -147,7 +150,7 @@
|
|||||||
[title]="sshHostTitle(host)"
|
[title]="sshHostTitle(host)"
|
||||||
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent disabled:opacity-60 disabled:hover:bg-transparent"
|
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent disabled:opacity-60 disabled:hover:bg-transparent"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
<ng-icon name="tdesignServer" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
||||||
<span class="flex-1 truncate">{{ host.name }}</span>
|
<span class="flex-1 truncate">{{ host.name }}</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -158,14 +161,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- path bar -->
|
<!-- path bar -->
|
||||||
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
<div class="flex items-center gap-1 border-b border-border bg-toolbar-2 px-2 py-1.5">
|
||||||
<button
|
<button
|
||||||
(click)="panel().up()"
|
(click)="panel().up()"
|
||||||
[disabled]="blocked()"
|
[disabled]="blocked()"
|
||||||
[title]="i18n.t('panel.parentDir')"
|
[title]="i18n.t('panel.parentDir')"
|
||||||
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
|
class="grid size-(--path-ctl-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaArrowUp" size="1rem" />
|
<ng-icon name="tdesignArrowUp" size="1rem" />
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
[value]="pathDraft()"
|
[value]="pathDraft()"
|
||||||
@@ -175,18 +178,18 @@
|
|||||||
(keydown)="$event.stopPropagation()"
|
(keydown)="$event.stopPropagation()"
|
||||||
[disabled]="blocked()"
|
[disabled]="blocked()"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
class="h-(--path-ctl-h) min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono text-(length:--path-font) outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
|
class="h-(--path-ctl-h) min-w-0 flex-1 border border-input bg-transparent px-2 font-mono text-(length:--path-font) outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
(click)="panel().refresh()"
|
(click)="panel().refresh()"
|
||||||
[disabled]="blocked()"
|
[disabled]="blocked()"
|
||||||
[title]="i18n.t('panel.refresh')"
|
[title]="i18n.t('panel.refresh')"
|
||||||
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
|
class="grid size-(--path-ctl-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
|
||||||
>
|
>
|
||||||
@if (panel().loading()) {
|
@if (panel().loading()) {
|
||||||
<ng-icon name="mynaSpinner" size="1rem" class="animate-spin" />
|
<ng-icon name="tdesignLoading" size="1rem" class="animate-spin" />
|
||||||
} @else {
|
} @else {
|
||||||
<ng-icon name="mynaRefresh" size="1rem" />
|
<ng-icon name="tdesignRefresh" size="1rem" />
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -195,7 +198,7 @@
|
|||||||
<div
|
<div
|
||||||
(contextmenu)="headerContextMenu($event)"
|
(contextmenu)="headerContextMenu($event)"
|
||||||
[style.grid-template-columns]="gridTemplate()"
|
[style.grid-template-columns]="gridTemplate()"
|
||||||
class="grid border-b border-border text-[11px] font-medium text-muted-foreground"
|
class="grid border-b border-border bg-toolbar-3 text-[0.6875rem] font-medium text-muted-foreground"
|
||||||
>
|
>
|
||||||
<button class="px-2 py-1 text-left hover:text-foreground" (click)="panel().toggleSort('name')">
|
<button class="px-2 py-1 text-left hover:text-foreground" (click)="panel().toggleSort('name')">
|
||||||
{{ i18n.t('columns.name') }} {{ sortIndicator('name') }}
|
{{ i18n.t('columns.name') }} {{ sortIndicator('name') }}
|
||||||
@@ -215,17 +218,17 @@
|
|||||||
<!-- type-to-filter bar (appears while a query is active) -->
|
<!-- type-to-filter bar (appears while a query is active) -->
|
||||||
@if (panel().filter(); as query) {
|
@if (panel().filter(); as query) {
|
||||||
<div class="flex items-center gap-1.5 border-b border-border bg-accent/50 px-2 py-1">
|
<div class="flex items-center gap-1.5 border-b border-border bg-accent/50 px-2 py-1">
|
||||||
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
<ng-icon name="tdesignSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
||||||
<span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span>
|
<span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span>
|
||||||
<span class="shrink-0 text-[11px] text-muted-foreground">
|
<span class="shrink-0 text-[0.6875rem] text-muted-foreground">
|
||||||
{{ i18n.t('panel.matches', { count: filterMatches() }) }}
|
{{ i18n.t('panel.matches', { count: filterMatches() }) }}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
(click)="clearFilter()"
|
(click)="clearFilter()"
|
||||||
[title]="i18n.t('panel.clearFilter')"
|
[title]="i18n.t('panel.clearFilter')"
|
||||||
class="grid size-5 shrink-0 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
class="grid size-5 shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaX" size="0.8rem" />
|
<ng-icon name="tdesignClose" size="0.8rem" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -243,13 +246,13 @@
|
|||||||
@if (panel().connecting()) {
|
@if (panel().connecting()) {
|
||||||
<div class="panel-fade-in grid h-full place-items-center">
|
<div class="panel-fade-in grid h-full place-items-center">
|
||||||
<div class="flex flex-col items-center gap-3">
|
<div class="flex flex-col items-center gap-3">
|
||||||
<ng-icon name="mynaSpinner" size="1.4rem" class="animate-spin text-primary" />
|
<ng-icon name="tdesignLoading" size="1.4rem" class="animate-spin text-primary" />
|
||||||
<span class="text-xs text-muted-foreground">
|
<span class="text-xs text-muted-foreground">
|
||||||
{{ i18n.t('panel.connectingTo', { name: panel().connectingTo() ?? '' }) }}
|
{{ i18n.t('panel.connectingTo', { name: panel().connectingTo() ?? '' }) }}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
(click)="cancelConnect()"
|
(click)="cancelConnect()"
|
||||||
class="rounded-md border border-border px-3 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
class="border border-border px-3 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
{{ i18n.t('common.cancel') }}
|
{{ i18n.t('common.cancel') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -264,6 +267,7 @@
|
|||||||
(contextmenu)="rowContextMenu($event, i)"
|
(contextmenu)="rowContextMenu($event, i)"
|
||||||
[style.grid-template-columns]="gridTemplate()"
|
[style.grid-template-columns]="gridTemplate()"
|
||||||
class="grid h-(--row-h) items-center text-(length:--list-font)"
|
class="grid h-(--row-h) items-center text-(length:--list-font)"
|
||||||
|
[class.bg-row-alt]="i % 2 === 1 && i !== panel().cursor() && !isSelected(entry)"
|
||||||
[class.bg-cursor-tint]="i === panel().cursor() && !isSelected(entry)"
|
[class.bg-cursor-tint]="i === panel().cursor() && !isSelected(entry)"
|
||||||
[class.bg-primary/15]="isSelected(entry)"
|
[class.bg-primary/15]="isSelected(entry)"
|
||||||
[class.ring-1]="i === panel().cursor() && isActive()"
|
[class.ring-1]="i === panel().cursor() && isActive()"
|
||||||
@@ -321,7 +325,7 @@
|
|||||||
</div>
|
</div>
|
||||||
@if (panel().slowLoad() && !panel().connecting()) {
|
@if (panel().slowLoad() && !panel().connecting()) {
|
||||||
<div class="panel-fade-in absolute inset-0 z-10 grid place-items-center bg-background/60">
|
<div class="panel-fade-in absolute inset-0 z-10 grid place-items-center bg-background/60">
|
||||||
<ng-icon name="mynaSpinner" size="1.4rem" class="animate-spin text-primary" />
|
<ng-icon name="tdesignLoading" size="1.4rem" class="animate-spin text-primary" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
+28
-19
@@ -9,18 +9,20 @@ import {
|
|||||||
viewChild,
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import { I18nService } from '../i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import type { ColumnKey, FileEntry, SavedConnection, SshConfigHost } from '@shared/types';
|
import type { ColumnKey, FileEntry, SavedConnection, SshConfigHost } from '@shared/types';
|
||||||
import { ConnectionsService } from '../connections.service';
|
import { ConnectionsService } from '@core/connections.service';
|
||||||
import { DialogsService } from '../dialogs.service';
|
import { ContextMenuService } from '@core/context-menu.service';
|
||||||
import { FileActionsService } from '../file-actions.service';
|
import { DialogsService } from '@core/dialogs.service';
|
||||||
import { fileGlyph, fileIconClass, fileKindKey } from '../file-icons';
|
import { FileActionsService } from '@core/file-actions.service';
|
||||||
import { formatMode } from '../format';
|
import { fileGlyph, fileIconClass, fileKindKey } from '@core/file-icons';
|
||||||
import { PanelsService, type Side, type SortKey } from '../panels.service';
|
import { formatMode } from '@core/format';
|
||||||
import { IS_WINDOWS } from '../platform';
|
import { PanelsService, type Side, type SortKey } from '@core/panels.service';
|
||||||
import { RpcService } from '../rpc.service';
|
import { IS_WINDOWS } from '@core/platform';
|
||||||
import { SettingsService } from '../settings.service';
|
import { RpcService } from '@core/rpc.service';
|
||||||
import { TransfersService } from '../transfers.service';
|
import { SettingsService } from '@core/settings.service';
|
||||||
|
import { ShortcutsService } from '@core/shortcuts.service';
|
||||||
|
import { TransfersService } from '@core/transfers.service';
|
||||||
|
|
||||||
/** Optional columns in fixed display order, with their track widths. */
|
/** Optional columns in fixed display order, with their track widths. */
|
||||||
const COLUMN_DEFS: { key: ColumnKey; label: string; width: string }[] = [
|
const COLUMN_DEFS: { key: ColumnKey; label: string; width: string }[] = [
|
||||||
@@ -47,15 +49,22 @@ export class FilePanel {
|
|||||||
readonly transfers = inject(TransfersService);
|
readonly transfers = inject(TransfersService);
|
||||||
readonly connections = inject(ConnectionsService);
|
readonly connections = inject(ConnectionsService);
|
||||||
readonly dialogs = inject(DialogsService);
|
readonly dialogs = inject(DialogsService);
|
||||||
|
private readonly menu = inject(ContextMenuService);
|
||||||
private readonly settings = inject(SettingsService);
|
private readonly settings = inject(SettingsService);
|
||||||
|
private readonly shortcuts = inject(ShortcutsService);
|
||||||
private readonly rpc = inject(RpcService);
|
private readonly rpc = inject(RpcService);
|
||||||
readonly panel = computed(() => this.panels.get(this.side()));
|
readonly panel = computed(() => this.panels.get(this.side()));
|
||||||
readonly isActive = computed(() => this.panels.activeSide() === this.side());
|
readonly isActive = computed(() => this.panels.activeSide() === this.side());
|
||||||
|
/** The active panel's marker: an accent line along its top edge. Inset, so
|
||||||
|
* switching panels never shifts the layout. */
|
||||||
|
readonly activeMarker = computed(() =>
|
||||||
|
this.isActive() ? 'inset 0 0.125rem 0 var(--primary)' : null,
|
||||||
|
);
|
||||||
readonly isRemote = computed(() => this.panel().sessionId() !== 'local');
|
readonly isRemote = computed(() => this.panel().sessionId() !== 'local');
|
||||||
/** Input is ignored while the panel is connecting or a listing is in flight. */
|
/** Input is ignored while the panel is connecting or a listing is in flight. */
|
||||||
readonly blocked = computed(() => this.panel().busy());
|
readonly blocked = computed(() => this.panel().busy());
|
||||||
/** The copy button points toward the other panel. */
|
/** The copy button points toward the other panel. */
|
||||||
readonly copyIcon = computed(() => (this.side() === 'left' ? 'mynaArrowRight' : 'mynaArrowLeft'));
|
readonly copyIcon = computed(() => (this.side() === 'left' ? 'tdesignArrowRight' : 'tdesignArrowLeft'));
|
||||||
|
|
||||||
readonly pathDraft = signal('');
|
readonly pathDraft = signal('');
|
||||||
readonly menuOpen = signal(false);
|
readonly menuOpen = signal(false);
|
||||||
@@ -88,7 +97,7 @@ export class FilePanel {
|
|||||||
|
|
||||||
headerContextMenu(event: MouseEvent): void {
|
headerContextMenu(event: MouseEvent): void {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void this.rpc.request.showColumnsMenu({ visible: this.settings.columns() });
|
this.menu.open(event, this.menu.columnItems(this.settings.columns()));
|
||||||
}
|
}
|
||||||
|
|
||||||
pickDrive(drive: string): void {
|
pickDrive(drive: string): void {
|
||||||
@@ -173,11 +182,11 @@ export class FilePanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
iconFor(entry: FileEntry): string {
|
iconFor(entry: FileEntry): string {
|
||||||
return fileGlyph(entry, this.settings.iconColors() === 'multi');
|
return fileGlyph(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
iconClass(entry: FileEntry): string {
|
iconClass(entry: FileEntry): string {
|
||||||
return fileIconClass(entry, this.settings.iconColors() === 'multi');
|
return fileIconClass(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
isSelected(entry: FileEntry): boolean {
|
isSelected(entry: FileEntry): boolean {
|
||||||
@@ -218,7 +227,7 @@ export class FilePanel {
|
|||||||
const panel = this.panel();
|
const panel = this.panel();
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
panel.selectRange(this.lastClickedIndex, index);
|
panel.selectRange(this.lastClickedIndex, index);
|
||||||
} else if (event.metaKey || event.ctrlKey) {
|
} else if (this.shortcuts.isMod(event)) {
|
||||||
const entry = panel.sorted()[index];
|
const entry = panel.sorted()[index];
|
||||||
if (entry) panel.toggleSelect(entry.name);
|
if (entry) panel.toggleSelect(entry.name);
|
||||||
} else {
|
} else {
|
||||||
@@ -238,14 +247,14 @@ export class FilePanel {
|
|||||||
// standard behavior: keep the selection when right-clicking inside it
|
// standard behavior: keep the selection when right-clicking inside it
|
||||||
if (entry && !panel.selected().has(entry.name)) panel.clearSelection();
|
if (entry && !panel.selected().has(entry.name)) panel.clearSelection();
|
||||||
panel.cursor.set(index);
|
panel.cursor.set(index);
|
||||||
void this.actions.openContextMenu({ side: this.side(), panel, entry });
|
this.actions.openContextMenu(event, { side: this.side(), panel, entry });
|
||||||
}
|
}
|
||||||
|
|
||||||
listContextMenu(event: MouseEvent): void {
|
listContextMenu(event: MouseEvent): void {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (this.blocked()) return;
|
if (this.blocked()) return;
|
||||||
this.activate();
|
this.activate();
|
||||||
void this.actions.openContextMenu({ side: this.side(), panel: this.panel(), entry: null });
|
this.actions.openContextMenu(event, { side: this.side(), panel: this.panel(), entry: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
openAt(index: number): void {
|
openAt(index: number): void {
|
||||||
@@ -264,7 +273,7 @@ export class FilePanel {
|
|||||||
this.panels.setActiveSide(this.panels.otherSide(this.side()));
|
this.panels.setActiveSide(this.panels.otherSide(this.side()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ((event.metaKey || event.ctrlKey) && key.toLowerCase() === 'a') {
|
if (this.shortcuts.isMod(event) && key.toLowerCase() === 'a') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
panel.selectAll();
|
panel.selectAll();
|
||||||
return;
|
return;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { App } from './app';
|
||||||
|
import { ConnectionManager } from './connection-manager/connection-manager';
|
||||||
|
import { EditorWindow } from './editor-window/editor-window';
|
||||||
|
import { SettingsWindow } from './settings-window/settings-window';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The four window roots, keyed by APP_MODE — main.ts picks one. There is no
|
||||||
|
* title-bar component: the native window frame is kept (hiddenInset on macOS,
|
||||||
|
* which is what IS_MAC_CHROME accounts for).
|
||||||
|
*/
|
||||||
|
export const SHAD_ROOTS = {
|
||||||
|
main: App,
|
||||||
|
manager: ConnectionManager,
|
||||||
|
editor: EditorWindow,
|
||||||
|
settings: SettingsWindow,
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
<div class="flex h-screen flex-col bg-background text-foreground">
|
||||||
|
<!-- header: on macOS this row IS the titlebar (hiddenInset + drag region),
|
||||||
|
elsewhere the native titlebar sits above and this is a compact strip -->
|
||||||
|
<header
|
||||||
|
class="flex shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2"
|
||||||
|
[class.h-10]="isMacChrome"
|
||||||
|
[class.pl-24]="isMacChrome"
|
||||||
|
[class.electrobun-webkit-app-region-drag]="isMacChrome"
|
||||||
|
[class.h-9]="!isMacChrome"
|
||||||
|
[class.pl-3]="!isMacChrome"
|
||||||
|
>
|
||||||
|
<ng-icon name="tdesignSetting" size="0.95rem" class="shrink-0 text-muted-foreground" />
|
||||||
|
<h1 class="text-sm font-semibold">{{ i18n.t('options.title') }}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex min-h-0 flex-1">
|
||||||
|
<!-- section list -->
|
||||||
|
<nav class="flex w-44 shrink-0 flex-col gap-0.5 border-r border-border bg-chrome p-2">
|
||||||
|
@for (s of sections; track s) {
|
||||||
|
<button
|
||||||
|
(click)="section.set(s)"
|
||||||
|
class="px-2.5 py-1.5 text-left text-xs hover:bg-accent hover:text-foreground"
|
||||||
|
[class.bg-accent]="section() === s"
|
||||||
|
[class.font-medium]="section() === s"
|
||||||
|
[class.text-foreground]="section() === s"
|
||||||
|
[class.text-muted-foreground]="section() !== s"
|
||||||
|
>
|
||||||
|
{{ i18n.t('options.sections.' + s) }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- the chosen section's rows -->
|
||||||
|
<main class="min-w-0 flex-1 overflow-y-auto px-6 py-5">
|
||||||
|
<h2 class="text-sm font-semibold">{{ i18n.t('options.sections.' + section()) }}</h2>
|
||||||
|
<div class="mt-4 flex max-w-md flex-col gap-3">
|
||||||
|
@switch (section()) {
|
||||||
|
@case ('appearance') {
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs">{{ i18n.t('options.language') }}</span>
|
||||||
|
<select
|
||||||
|
(change)="settings.setLocale($any($event.target).value)"
|
||||||
|
class="h-7 border border-input bg-background pl-1.5 text-[0.6875rem] outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="system" [selected]="settings.locale() === 'system'">
|
||||||
|
{{ i18n.t('options.system') }}
|
||||||
|
</option>
|
||||||
|
@for (loc of localeOptions; track loc) {
|
||||||
|
<option [value]="loc" [selected]="settings.locale() === loc">
|
||||||
|
{{ localeLabels[loc] }}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs">{{ i18n.t('options.theme.label') }}</span>
|
||||||
|
<div class="flex border border-border p-0.5">
|
||||||
|
@for (theme of themes; track theme) {
|
||||||
|
<button
|
||||||
|
(click)="settings.setTheme(theme)"
|
||||||
|
class="px-2 py-0.5 text-[0.6875rem]"
|
||||||
|
[class.bg-accent]="settings.theme() === theme"
|
||||||
|
[class.text-foreground]="settings.theme() === theme"
|
||||||
|
[class.text-muted-foreground]="settings.theme() !== theme"
|
||||||
|
>
|
||||||
|
{{ i18n.t('options.theme.' + theme) }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (settings.effectiveDark()) {
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs">{{ i18n.t('options.tone.label') }}</span>
|
||||||
|
<div class="flex border border-border p-0.5">
|
||||||
|
@for (tone of darkTones; track tone) {
|
||||||
|
<button
|
||||||
|
(click)="settings.setDarkTone(tone)"
|
||||||
|
class="px-2 py-0.5 text-[0.6875rem]"
|
||||||
|
[class.bg-accent]="settings.darkTone() === tone"
|
||||||
|
[class.text-foreground]="settings.darkTone() === tone"
|
||||||
|
[class.text-muted-foreground]="settings.darkTone() !== tone"
|
||||||
|
>
|
||||||
|
{{ i18n.t('options.tone.' + tone) }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Interface scale: a continuous stepper, 50%..400% in 10-point
|
||||||
|
steps. One lever moves the whole UI (root font-size; everything
|
||||||
|
is rem), this window included. -->
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs">{{ i18n.t('options.interface.label') }}</span>
|
||||||
|
<div class="flex items-center border border-border p-0.5">
|
||||||
|
<button
|
||||||
|
(click)="settings.setUiScale(settings.uiScale() - 0.1)"
|
||||||
|
[disabled]="settings.uiScale() <= 0.5"
|
||||||
|
[title]="i18n.t('options.interface.smaller')"
|
||||||
|
class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="settings.setUiScale(1)"
|
||||||
|
[title]="i18n.t('options.interface.reset')"
|
||||||
|
class="w-11 px-1 py-0.5 text-center text-[0.6875rem] tabular-nums hover:bg-accent"
|
||||||
|
>
|
||||||
|
{{ scalePercent() }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="settings.setUiScale(settings.uiScale() + 0.1)"
|
||||||
|
[disabled]="settings.uiScale() >= 4"
|
||||||
|
[title]="i18n.t('options.interface.larger')"
|
||||||
|
class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@case ('transfers') {
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs">{{ i18n.t('options.speedUnit.label') }}</span>
|
||||||
|
<div class="flex border border-border p-0.5">
|
||||||
|
@for (unit of speedUnits; track unit.value) {
|
||||||
|
<button
|
||||||
|
(click)="settings.setSpeedUnit(unit.value)"
|
||||||
|
class="px-2 py-0.5 text-[0.6875rem]"
|
||||||
|
[class.bg-accent]="settings.speedUnit() === unit.value"
|
||||||
|
[class.text-foreground]="settings.speedUnit() === unit.value"
|
||||||
|
[class.text-muted-foreground]="settings.speedUnit() !== unit.value"
|
||||||
|
>
|
||||||
|
{{ unit.label }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs">{{ i18n.t('options.transfers.label') }}</span>
|
||||||
|
<div class="flex border border-border p-0.5">
|
||||||
|
@for (mode of transferModes; track mode) {
|
||||||
|
<button
|
||||||
|
(click)="settings.setTransferMode(mode)"
|
||||||
|
class="px-2 py-0.5 text-[0.6875rem]"
|
||||||
|
[class.bg-accent]="settings.transferMode() === mode"
|
||||||
|
[class.text-foreground]="settings.transferMode() === mode"
|
||||||
|
[class.text-muted-foreground]="settings.transferMode() !== mode"
|
||||||
|
>
|
||||||
|
{{ i18n.t('options.transfers.' + mode) }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (settings.transferMode() === 'multi') {
|
||||||
|
<div class="flex items-center justify-between gap-2 pl-3">
|
||||||
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.atOnce') }}</span>
|
||||||
|
<div class="flex border border-border p-0.5">
|
||||||
|
@for (count of concurrencyChoices; track count) {
|
||||||
|
<button
|
||||||
|
(click)="settings.setTransferConcurrency(count)"
|
||||||
|
class="px-2 py-0.5 text-[0.6875rem] tabular-nums"
|
||||||
|
[class.bg-accent]="settings.transferConcurrency() === count"
|
||||||
|
[class.text-foreground]="settings.transferConcurrency() === count"
|
||||||
|
[class.text-muted-foreground]="settings.transferConcurrency() !== count"
|
||||||
|
>
|
||||||
|
{{ count }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs">{{ i18n.t('options.finishedTransfers.label') }}</span>
|
||||||
|
<div class="flex border border-border p-0.5">
|
||||||
|
<button
|
||||||
|
(click)="settings.setAutoClearCompleted(true)"
|
||||||
|
class="px-2 py-0.5 text-[0.6875rem]"
|
||||||
|
[class.bg-accent]="settings.autoClearCompleted()"
|
||||||
|
[class.text-foreground]="settings.autoClearCompleted()"
|
||||||
|
[class.text-muted-foreground]="!settings.autoClearCompleted()"
|
||||||
|
>
|
||||||
|
{{ i18n.t('options.finishedTransfers.autoClear') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="settings.setAutoClearCompleted(false)"
|
||||||
|
class="px-2 py-0.5 text-[0.6875rem]"
|
||||||
|
[class.bg-accent]="!settings.autoClearCompleted()"
|
||||||
|
[class.text-foreground]="!settings.autoClearCompleted()"
|
||||||
|
[class.text-muted-foreground]="settings.autoClearCompleted()"
|
||||||
|
>
|
||||||
|
{{ i18n.t('options.finishedTransfers.keep') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@case ('editor') {
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs">{{ i18n.t('options.editor.label') }}</span>
|
||||||
|
<div class="flex border border-border p-0.5">
|
||||||
|
<button
|
||||||
|
(click)="settings.setEditor('builtin')"
|
||||||
|
class="px-2 py-0.5 text-[0.6875rem]"
|
||||||
|
[class.bg-accent]="settings.editor() === 'builtin'"
|
||||||
|
[class.text-foreground]="settings.editor() === 'builtin'"
|
||||||
|
[class.text-muted-foreground]="settings.editor() !== 'builtin'"
|
||||||
|
>
|
||||||
|
{{ i18n.t('options.editor.builtin') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="settings.setEditor('custom')"
|
||||||
|
class="px-2 py-0.5 text-[0.6875rem]"
|
||||||
|
[class.bg-accent]="settings.editor() === 'custom'"
|
||||||
|
[class.text-foreground]="settings.editor() === 'custom'"
|
||||||
|
[class.text-muted-foreground]="settings.editor() !== 'custom'"
|
||||||
|
>
|
||||||
|
{{ i18n.t('options.editor.custom') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (settings.editor() === 'custom') {
|
||||||
|
<div class="flex items-center justify-between gap-2 pl-3">
|
||||||
|
<span class="min-w-0 truncate text-xs text-muted-foreground" [title]="settings.editorApp()">
|
||||||
|
{{ editorAppName() }}
|
||||||
|
</span>
|
||||||
|
<div class="flex shrink-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
(click)="pickEditorApp()"
|
||||||
|
class="border border-border px-2 py-0.5 text-[0.6875rem] hover:bg-accent"
|
||||||
|
>
|
||||||
|
{{ i18n.t('common.choose') }}
|
||||||
|
</button>
|
||||||
|
@if (settings.editorApp()) {
|
||||||
|
<button
|
||||||
|
(click)="settings.setEditorApp('')"
|
||||||
|
[title]="i18n.t('options.resetDefault')"
|
||||||
|
class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="tdesignClose" size="0.7rem" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@case ('fileList') {
|
||||||
|
<span class="text-xs">{{ i18n.t('options.columns') }}</span>
|
||||||
|
<div class="flex flex-col gap-2 pl-3">
|
||||||
|
@for (key of columnKeys; track key) {
|
||||||
|
<label class="flex items-center gap-2 text-xs">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[checked]="settings.columns().includes(key)"
|
||||||
|
(change)="settings.toggleColumn(key)"
|
||||||
|
/>
|
||||||
|
{{ i18n.t('columns.' + key) }}
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@case ('about') {
|
||||||
|
<!-- the old popover footer: opens the native About box -->
|
||||||
|
<button
|
||||||
|
(click)="showAbout()"
|
||||||
|
class="self-start border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
{{ appName }} · {{ buildLabel }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { Component, computed, inject, signal } from '@angular/core';
|
||||||
|
import { NgIcon } from '@ng-icons/core';
|
||||||
|
import { BRANDING } from '@shared/branding';
|
||||||
|
import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
|
||||||
|
import { COLUMN_KEYS } from '@core/context-menu.service';
|
||||||
|
import { I18nService } from '@core/i18n.service';
|
||||||
|
import { blockNativeContextMenu, IS_MAC_CHROME } from '@core/platform';
|
||||||
|
import { RpcService } from '@core/rpc.service';
|
||||||
|
import { SettingsService } from '@core/settings.service';
|
||||||
|
import { ShortcutsService } from '@core/shortcuts.service';
|
||||||
|
|
||||||
|
const SECTIONS = ['appearance', 'transfers', 'editor', 'fileList', 'about'] as const;
|
||||||
|
type Section = (typeof SECTIONS)[number];
|
||||||
|
|
||||||
|
const THEMES = ['system', 'light', 'dark'] as const;
|
||||||
|
const DARK_TONES = ['dark', 'darker'] as const;
|
||||||
|
const SPEED_UNITS = [
|
||||||
|
{ value: 'MBps', label: 'MB/s' },
|
||||||
|
{ value: 'Mbps', label: 'Mbps' },
|
||||||
|
] as const;
|
||||||
|
const TRANSFER_MODES = ['single', 'multi'] as const;
|
||||||
|
const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root of the Settings window (APP_MODE 'settings'). Every control is a plain
|
||||||
|
* SettingsService setter: the save goes to bun, which rebroadcasts it, so the
|
||||||
|
* other windows follow live while this one stays open.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
imports: [NgIcon],
|
||||||
|
templateUrl: './settings-window.html',
|
||||||
|
host: {
|
||||||
|
'(window:keydown)': 'onWindowKeydown($event)',
|
||||||
|
'(window:contextmenu)': 'blockContextMenu($event)',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class SettingsWindow {
|
||||||
|
readonly settings = inject(SettingsService);
|
||||||
|
readonly i18n = inject(I18nService);
|
||||||
|
private readonly rpc = inject(RpcService);
|
||||||
|
private readonly shortcuts = inject(ShortcutsService);
|
||||||
|
|
||||||
|
readonly isMacChrome = IS_MAC_CHROME;
|
||||||
|
readonly sections = SECTIONS;
|
||||||
|
readonly section = signal<Section>('appearance');
|
||||||
|
readonly localeOptions = SUPPORTED;
|
||||||
|
readonly localeLabels = LOCALE_LABELS;
|
||||||
|
readonly themes = THEMES;
|
||||||
|
readonly darkTones = DARK_TONES;
|
||||||
|
readonly speedUnits = SPEED_UNITS;
|
||||||
|
readonly transferModes = TRANSFER_MODES;
|
||||||
|
readonly concurrencyChoices = CONCURRENCY_CHOICES;
|
||||||
|
readonly columnKeys = COLUMN_KEYS;
|
||||||
|
readonly appName = BRANDING.appName;
|
||||||
|
readonly buildLabel = BRANDING.buildLabel;
|
||||||
|
/** "100%" for the stepper readout; the value itself lives in settings. */
|
||||||
|
readonly scalePercent = computed(() => `${Math.round(this.settings.uiScale() * 100)}%`);
|
||||||
|
|
||||||
|
/** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */
|
||||||
|
editorAppName(): string {
|
||||||
|
const path = this.settings.editorApp();
|
||||||
|
if (!path) return this.i18n.t('options.systemDefault');
|
||||||
|
const base = path.split('/').filter(Boolean).pop() ?? path;
|
||||||
|
return base.replace(/\.app$/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async pickEditorApp(): Promise<void> {
|
||||||
|
const res = await this.rpc.request.pickEditorApp({});
|
||||||
|
if (res.ok && res.path) this.settings.setEditorApp(res.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
showAbout(): void {
|
||||||
|
void this.rpc.request.showAbout({});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Any-window shortcuts (interface scale); ⌘W arrives as a menu action bun-side. */
|
||||||
|
onWindowKeydown(event: KeyboardEvent): void {
|
||||||
|
const match = this.shortcuts.resolve(event, 'settings');
|
||||||
|
if (!match || this.shortcuts.isTypingTarget(event)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (match.id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
||||||
|
else if (match.id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1);
|
||||||
|
else if (match.id === 'scale-reset') this.settings.setUiScale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
blockContextMenu(event: Event): void {
|
||||||
|
blockNativeContextMenu(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-10
@@ -1,18 +1,18 @@
|
|||||||
import { Component, computed, inject } from '@angular/core';
|
import { Component, computed, inject } from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import { I18nService } from '../i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import type { TransferSnapshot } from '@shared/types';
|
import type { TransferSnapshot } from '@shared/types';
|
||||||
import { formatSpeed } from '../format';
|
import { formatSpeed } from '@core/format';
|
||||||
import { PanelsService } from '../panels.service';
|
import { PanelsService } from '@core/panels.service';
|
||||||
import { SettingsService } from '../settings.service';
|
import { SettingsService } from '@core/settings.service';
|
||||||
import { TransfersService } from '../transfers.service';
|
import { TransfersService } from '@core/transfers.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-status-bar',
|
selector: 'app-status-bar',
|
||||||
imports: [NgIcon],
|
imports: [NgIcon],
|
||||||
template: `
|
template: `
|
||||||
<div
|
<div
|
||||||
class="flex h-6 items-center justify-between gap-4 border-t border-border bg-chrome px-3 text-[11px] text-muted-foreground select-none"
|
class="flex h-6 items-center justify-between gap-4 border-t border-border bg-chrome px-3 text-[0.6875rem] text-muted-foreground select-none"
|
||||||
>
|
>
|
||||||
<!-- transfers summary; click toggles the detailed list -->
|
<!-- transfers summary; click toggles the detailed list -->
|
||||||
<button
|
<button
|
||||||
@@ -21,7 +21,7 @@ import { TransfersService } from '../transfers.service';
|
|||||||
[title]="i18n.t('transfer.showDetails')"
|
[title]="i18n.t('transfer.showDetails')"
|
||||||
>
|
>
|
||||||
@if (transfers.currentJob(); as job) {
|
@if (transfers.currentJob(); as job) {
|
||||||
<ng-icon name="mynaSpinner" size="0.8rem" class="shrink-0 animate-spin text-primary" />
|
<ng-icon name="tdesignLoading" size="0.8rem" class="shrink-0 animate-spin text-primary" />
|
||||||
<span class="truncate">
|
<span class="truncate">
|
||||||
{{ i18n.t('transfer.copying', { name: job.name, percent: percent(job), speed: speedSuffix(job) }) }}
|
{{ i18n.t('transfer.copying', { name: job.name, percent: percent(job), speed: speedSuffix(job) }) }}
|
||||||
@if (transfers.queuedCount(); as queued) {
|
@if (transfers.queuedCount(); as queued) {
|
||||||
@@ -29,12 +29,12 @@ import { TransfersService } from '../transfers.service';
|
|||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
} @else if (transfers.failedJobs().length; as failed) {
|
} @else if (transfers.failedJobs().length; as failed) {
|
||||||
<ng-icon name="mynaXCircle" size="0.8rem" class="shrink-0 text-destructive" />
|
<ng-icon name="tdesignCloseCircle" size="0.8rem" class="shrink-0 text-destructive" />
|
||||||
<span class="text-destructive">
|
<span class="text-destructive">
|
||||||
{{ i18n.t('transfer.failed', { count: failed }) }}
|
{{ i18n.t('transfer.failed', { count: failed }) }}
|
||||||
</span>
|
</span>
|
||||||
} @else if (transfers.hasJobs()) {
|
} @else if (transfers.hasJobs()) {
|
||||||
<ng-icon name="mynaCheckCircle" size="0.8rem" class="shrink-0 text-success" />
|
<ng-icon name="tdesignCheckCircle" size="0.8rem" class="shrink-0 text-success" />
|
||||||
<span>{{ i18n.t('transfer.finished') }}</span>
|
<span>{{ i18n.t('transfer.finished') }}</span>
|
||||||
} @else {
|
} @else {
|
||||||
<span>{{ i18n.t('transfer.ready') }}</span>
|
<span>{{ i18n.t('transfer.ready') }}</span>
|
||||||
@@ -44,7 +44,7 @@ import { TransfersService } from '../transfers.service';
|
|||||||
<!-- active panel info -->
|
<!-- active panel info -->
|
||||||
<div class="flex shrink-0 items-center gap-1.5">
|
<div class="flex shrink-0 items-center gap-1.5">
|
||||||
<ng-icon
|
<ng-icon
|
||||||
[name]="activePanel().sessionId() === 'local' ? 'mynaHardDrive' : 'mynaServers'"
|
[name]="activePanel().sessionId() === 'local' ? 'tdesignHardDrive' : 'tdesignServer'"
|
||||||
size="0.8rem"
|
size="0.8rem"
|
||||||
[class.text-primary]="activePanel().sessionId() !== 'local'"
|
[class.text-primary]="activePanel().sessionId() !== 'local'"
|
||||||
/>
|
/>
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import { Component, ElementRef, effect, inject, signal, viewChild } from '@angular/core';
|
||||||
|
import { NgIcon } from '@ng-icons/core';
|
||||||
|
import { ContextMenuService } from '@core/context-menu.service';
|
||||||
|
import { I18nService } from '@core/i18n.service';
|
||||||
|
import { PanelsService, type Workspace } from '@core/panels.service';
|
||||||
|
import { IS_MAC_CHROME, MOD_LABEL } from '@core/platform';
|
||||||
|
import { RpcService } from '@core/rpc.service';
|
||||||
|
import { UpdaterService } from '@core/updater.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-tab-bar',
|
||||||
|
imports: [NgIcon],
|
||||||
|
template: `
|
||||||
|
<!-- On macOS this row IS the titlebar (hiddenInset): taller, padded past the
|
||||||
|
traffic lights, and draggable. Elsewhere it's a normal compact row. -->
|
||||||
|
<div
|
||||||
|
class="relative flex items-center gap-0.5 border-b border-border bg-chrome pr-1.5 electrobun-webkit-app-region-drag"
|
||||||
|
[class.h-10]="isMacChrome"
|
||||||
|
[class.pl-24]="isMacChrome"
|
||||||
|
[class.pl-1.5]="!isMacChrome"
|
||||||
|
[class.pt-1]="!isMacChrome"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
#tabScroll
|
||||||
|
role="tablist"
|
||||||
|
class="flex min-w-0 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||||
|
>
|
||||||
|
@for (ws of panels.workspaces(); track ws.id; let i = $index) {
|
||||||
|
<div
|
||||||
|
[attr.data-tab-id]="ws.id"
|
||||||
|
class="group relative flex max-w-52 shrink-0 items-center electrobun-webkit-app-region-no-drag"
|
||||||
|
[class.bg-accent]="ws.id === panels.activeWorkspaceId()"
|
||||||
|
[class.opacity-50]="draggedId() === ws.id"
|
||||||
|
[style.box-shadow]="dropIndicatorShadow(i)"
|
||||||
|
(contextmenu)="onTabContextMenu($event, ws, i)"
|
||||||
|
(mousedown)="onTabMouseDown($event)"
|
||||||
|
(auxclick)="onAuxClick($event, ws.id)"
|
||||||
|
draggable="true"
|
||||||
|
(dragstart)="onDragStart($event, ws.id)"
|
||||||
|
(dragover)="onDragOver($event, i)"
|
||||||
|
(drop)="onDrop($event, i)"
|
||||||
|
(dragend)="onDragEnd()"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
[attr.aria-selected]="ws.id === panels.activeWorkspaceId()"
|
||||||
|
(click)="panels.setActiveWorkspace(ws.id)"
|
||||||
|
class="flex min-w-0 items-center gap-1.5 py-1.5 pr-1 pl-2.5 text-xs"
|
||||||
|
[class.text-foreground]="ws.id === panels.activeWorkspaceId()"
|
||||||
|
[class.text-muted-foreground]="ws.id !== panels.activeWorkspaceId()"
|
||||||
|
[title]="panels.workspaceName(ws)"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
[name]="panels.workspaceIsRemote(ws) ? 'tdesignServer' : 'tdesignHardDrive'"
|
||||||
|
size="0.85rem"
|
||||||
|
class="shrink-0"
|
||||||
|
[class.text-primary]="panels.workspaceIsRemote(ws)"
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{ panels.workspaceName(ws) }}</span>
|
||||||
|
</button>
|
||||||
|
@if (panels.workspaces().length > 1) {
|
||||||
|
<button
|
||||||
|
(click)="close($event, ws.id)"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-hidden="true"
|
||||||
|
[title]="i18n.t('tabBar.closeTab', { mod: modLabel })"
|
||||||
|
class="mr-1 grid size-4 shrink-0 place-items-center text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-border hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="tdesignClose" size="0.7rem" />
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<span class="mr-1 size-4 shrink-0"></span>
|
||||||
|
}
|
||||||
|
@if (ws.id === panels.activeWorkspaceId()) {
|
||||||
|
<div class="absolute inset-x-0 -bottom-px h-0.5 bg-primary"></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
(click)="panels.newWorkspace()"
|
||||||
|
[title]="i18n.t('tabBar.newTab', { mod: modLabel })"
|
||||||
|
class="ml-0.5 grid size-6 place-items-center text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="tdesignAdd" size=".8rem" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
|
<!-- auto-update tool: one accent pill that announces the update, becomes its
|
||||||
|
own progress bar while downloading, then turns into "Restart to apply" -->
|
||||||
|
@if (updater.state() !== 'idle' && updater.state() !== 'error') {
|
||||||
|
<button
|
||||||
|
(click)="onUpdateClick()"
|
||||||
|
[disabled]="updater.state() === 'downloading'"
|
||||||
|
[title]="i18n.t('tabBar.updateTooltip', { version: updater.version() })"
|
||||||
|
class="relative mr-1 grid h-6 place-items-center overflow-hidden px-2.5 text-[0.6875rem] font-medium bg-primary text-primary-foreground electrobun-webkit-app-region-no-drag hover:opacity-90 disabled:cursor-default"
|
||||||
|
>
|
||||||
|
@if (updater.state() === 'downloading') {
|
||||||
|
<span
|
||||||
|
class="absolute inset-y-0 left-0 bg-primary-foreground/25 transition-[width] duration-200"
|
||||||
|
[style.width.%]="updater.progress()"
|
||||||
|
></span>
|
||||||
|
}
|
||||||
|
<span class="relative flex items-center gap-1.5">
|
||||||
|
@switch (updater.state()) {
|
||||||
|
@case ('available') {
|
||||||
|
<ng-icon name="tdesignDownload" size=".8rem" />{{ i18n.t('tabBar.update') }}
|
||||||
|
}
|
||||||
|
@case ('downloading') {
|
||||||
|
<ng-icon name="tdesignLoading" size=".8rem" class="animate-spin" />{{ i18n.t('tabBar.updating') }} {{ updater.progress() }}%
|
||||||
|
}
|
||||||
|
@case ('ready') {
|
||||||
|
<ng-icon name="tdesignRefresh" size=".8rem" />{{ i18n.t('tabBar.restartToUpdate') }}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- connection manager (own native window) -->
|
||||||
|
<button
|
||||||
|
(click)="openManager()"
|
||||||
|
[title]="i18n.t('tabBar.manageConnections')"
|
||||||
|
class="grid h-6 px-2 py-1 place-items-center text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<!-- <ng-icon name="tdesignSetting" size="0.9rem" /> -->
|
||||||
|
{{ i18n.t('tabBar.connections') }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- settings (own native window) -->
|
||||||
|
<button
|
||||||
|
(click)="openSettings()"
|
||||||
|
[title]="i18n.t('tabBar.options')"
|
||||||
|
class="grid size-6 place-items-center text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="tdesignSetting" size="0.9rem" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class TabBar {
|
||||||
|
readonly panels = inject(PanelsService);
|
||||||
|
readonly i18n = inject(I18nService);
|
||||||
|
readonly updater = inject(UpdaterService);
|
||||||
|
private readonly contextMenu = inject(ContextMenuService);
|
||||||
|
private readonly rpc = inject(RpcService);
|
||||||
|
readonly isMacChrome = IS_MAC_CHROME;
|
||||||
|
readonly modLabel = MOD_LABEL;
|
||||||
|
|
||||||
|
// drag & drop reordering
|
||||||
|
readonly draggedId = signal<number | null>(null);
|
||||||
|
readonly dropTarget = signal<{ index: number; before: boolean } | null>(null);
|
||||||
|
|
||||||
|
private readonly tabScroll = viewChild.required<ElementRef<HTMLDivElement>>('tabScroll');
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// keep the active tab visible when the strip overflows
|
||||||
|
effect(() => {
|
||||||
|
const id = this.panels.activeWorkspaceId();
|
||||||
|
this.tabScroll()
|
||||||
|
.nativeElement.querySelector(`[data-tab-id="${id}"]`)
|
||||||
|
?.scrollIntoView({ inline: 'nearest', block: 'nearest' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close(event: MouseEvent, id: number): void {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.panels.closeWorkspace(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middle-click closes the tab, as in browsers. `auxclick` also fires for the
|
||||||
|
* right button, hence the button check; the last tab is refused by
|
||||||
|
* closeWorkspace itself.
|
||||||
|
*/
|
||||||
|
onAuxClick(event: MouseEvent, id: number): void {
|
||||||
|
if (event.button !== 1) return;
|
||||||
|
event.preventDefault();
|
||||||
|
this.close(event, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A middle press over the scrollable strip would start WebView2's autoscroll. */
|
||||||
|
onTabMouseDown(event: MouseEvent): void {
|
||||||
|
if (event.button === 1) event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Right-click a tab. The menu targets THIS tab, not the active one — its
|
||||||
|
* actions all carry the id — so right-clicking a background tab does not
|
||||||
|
* yank focus to it first, the way Explorer and every browser behave.
|
||||||
|
*/
|
||||||
|
onTabContextMenu(event: MouseEvent, workspace: Workspace, index: number): void {
|
||||||
|
event.preventDefault();
|
||||||
|
this.contextMenu.open(
|
||||||
|
event,
|
||||||
|
this.contextMenu.tabItems({
|
||||||
|
id: workspace.id,
|
||||||
|
index,
|
||||||
|
tabCount: this.panels.workspaces().length,
|
||||||
|
isRemote: this.panels.workspaceIsRemote(workspace),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
openManager(): void {
|
||||||
|
void this.rpc.request.openConnectionManager({});
|
||||||
|
}
|
||||||
|
|
||||||
|
openSettings(): void {
|
||||||
|
void this.rpc.request.openSettings({});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update pill: first click downloads, second click (when ready) applies + relaunches. */
|
||||||
|
onUpdateClick(): void {
|
||||||
|
const state = this.updater.state();
|
||||||
|
if (state === 'available') void this.updater.download();
|
||||||
|
else if (state === 'ready') this.updater.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insertion line at the drop position (inset shadow: no layout shift). */
|
||||||
|
dropIndicatorShadow(index: number): string | null {
|
||||||
|
const target = this.dropTarget();
|
||||||
|
if (!target || target.index !== index || this.draggedId() === null) return null;
|
||||||
|
return target.before
|
||||||
|
? 'inset 2px 0 0 var(--primary)'
|
||||||
|
: 'inset -2px 0 0 var(--primary)';
|
||||||
|
}
|
||||||
|
|
||||||
|
onDragStart(event: DragEvent, id: number): void {
|
||||||
|
this.draggedId.set(id);
|
||||||
|
// WebKit refuses to start a drag without data
|
||||||
|
event.dataTransfer?.setData('text/plain', String(id));
|
||||||
|
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
||||||
|
}
|
||||||
|
|
||||||
|
onDragOver(event: DragEvent, index: number): void {
|
||||||
|
if (this.draggedId() === null) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
||||||
|
const el = event.currentTarget as HTMLElement;
|
||||||
|
const before = event.clientX < el.getBoundingClientRect().left + el.offsetWidth / 2;
|
||||||
|
this.dropTarget.set({ index, before });
|
||||||
|
}
|
||||||
|
|
||||||
|
onDrop(event: DragEvent, index: number): void {
|
||||||
|
event.preventDefault();
|
||||||
|
const id = this.draggedId();
|
||||||
|
const target = this.dropTarget();
|
||||||
|
this.onDragEnd();
|
||||||
|
if (id === null || !target) return;
|
||||||
|
const list = this.panels.workspaces();
|
||||||
|
const fromIndex = list.findIndex((w) => w.id === id);
|
||||||
|
if (fromIndex < 0) return;
|
||||||
|
let toIndex = target.before ? index : index + 1;
|
||||||
|
if (fromIndex < toIndex) toIndex--; // account for removal before insert
|
||||||
|
if (toIndex !== fromIndex) this.panels.moveWorkspace(id, toIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDragEnd(): void {
|
||||||
|
this.draggedId.set(null);
|
||||||
|
this.dropTarget.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import { ToastService } from '../toast.service';
|
import { ToastService } from '@core/toast.service';
|
||||||
|
|
||||||
/** Toast stack overlay; shared by the main window and editor windows. */
|
/** Toast stack overlay; shared by the main window and editor windows. */
|
||||||
@Component({
|
@Component({
|
||||||
@@ -12,7 +12,7 @@ import { ToastService } from '../toast.service';
|
|||||||
<div
|
<div
|
||||||
(mouseenter)="toast.pause(t.id)"
|
(mouseenter)="toast.pause(t.id)"
|
||||||
(mouseleave)="toast.resume(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"
|
class="group pointer-events-auto relative flex items-start gap-2 overflow-hidden border border-border bg-card p-3 text-xs text-card-foreground shadow-lg"
|
||||||
>
|
>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
[name]="toastIcon(t.kind)"
|
[name]="toastIcon(t.kind)"
|
||||||
@@ -24,7 +24,7 @@ import { ToastService } from '../toast.service';
|
|||||||
(click)="toast.dismiss(t.id)"
|
(click)="toast.dismiss(t.id)"
|
||||||
class="shrink-0 text-muted-foreground hover:text-foreground"
|
class="shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaX" size="0.9rem" />
|
<ng-icon name="tdesignClose" size="0.9rem" />
|
||||||
</button>
|
</button>
|
||||||
<!-- auto-dismiss countdown; freezes with the timer while hovered -->
|
<!-- auto-dismiss countdown; freezes with the timer while hovered -->
|
||||||
<div
|
<div
|
||||||
@@ -43,8 +43,8 @@ export class Toasts {
|
|||||||
readonly toast = inject(ToastService);
|
readonly toast = inject(ToastService);
|
||||||
|
|
||||||
toastIcon(kind: 'error' | 'info' | 'success'): string {
|
toastIcon(kind: 'error' | 'info' | 'success'): string {
|
||||||
if (kind === 'error') return 'mynaXCircle';
|
if (kind === 'error') return 'tdesignCloseCircle';
|
||||||
if (kind === 'success') return 'mynaCheckCircle';
|
if (kind === 'success') return 'tdesignCheckCircle';
|
||||||
return 'mynaCheck';
|
return 'tdesignCheck';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+17
-10
@@ -1,14 +1,14 @@
|
|||||||
@if (transfers.expanded() && transfers.hasJobs()) {
|
@if (transfers.expanded() && transfers.hasJobs()) {
|
||||||
<div class="flex items-center justify-between border-t border-border bg-chrome px-3 py-1 text-[11px] text-muted-foreground">
|
<div class="flex items-center justify-between border-t border-border bg-chrome px-3 py-1 text-[0.6875rem] text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
{{ i18n.t('transfer.summary', { active: transfers.activeJobs().length, finished: transfers.finishedJobs().length }) }}
|
{{ i18n.t('transfer.summary', { active: transfers.activeJobs().length, finished: transfers.finishedJobs().length }) }}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
(click)="transfers.clearFinished()"
|
(click)="transfers.clearFinished()"
|
||||||
[disabled]="!transfers.finishedJobs().length"
|
[disabled]="!transfers.finishedJobs().length"
|
||||||
class="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
|
class="flex items-center gap-1 px-1.5 py-0.5 hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaTrash" size="0.75rem" /> {{ i18n.t('transfer.clearFinished') }}
|
<ng-icon name="tdesignDelete" size="0.75rem" /> {{ i18n.t('transfer.clearFinished') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="max-h-40 overflow-y-auto border-t border-border bg-chrome">
|
<div class="max-h-40 overflow-y-auto border-t border-border bg-chrome">
|
||||||
@@ -17,11 +17,18 @@
|
|||||||
<ng-icon [name]="stateIcon(job)" size="0.95rem" [class]="stateClass(job)" />
|
<ng-icon [name]="stateIcon(job)" size="0.95rem" [class]="stateClass(job)" />
|
||||||
<span class="w-40 shrink-0 truncate font-medium" [title]="job.srcPath">{{ job.name }}</span>
|
<span class="w-40 shrink-0 truncate font-medium" [title]="job.srcPath">{{ job.name }}</span>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="truncate text-[11px] text-muted-foreground">{{ progressLabel(job) }}</div>
|
<div class="truncate text-[0.6875rem] text-muted-foreground">{{ progressLabel(job) }}</div>
|
||||||
@if (isActive(job)) {
|
@if (isActive(job)) {
|
||||||
<div class="mt-0.5 h-1 overflow-hidden rounded-full bg-muted">
|
|
||||||
<div
|
<div
|
||||||
class="h-full rounded-full bg-primary transition-[width] duration-100"
|
role="progressbar"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
[attr.aria-valuenow]="jobPercent(job)"
|
||||||
|
[attr.aria-label]="job.name"
|
||||||
|
class="mt-0.5 h-1 overflow-hidden bg-muted"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-full bg-primary transition-[width] duration-100"
|
||||||
[style.width.%]="jobPercent(job)"
|
[style.width.%]="jobPercent(job)"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,17 +38,17 @@
|
|||||||
<button
|
<button
|
||||||
(click)="transfers.cancel(job.id)"
|
(click)="transfers.cancel(job.id)"
|
||||||
[title]="i18n.t('transfer.cancel')"
|
[title]="i18n.t('transfer.cancel')"
|
||||||
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-destructive"
|
class="shrink-0 p-1 text-muted-foreground hover:bg-accent hover:text-destructive"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaX" size="0.9rem" />
|
<ng-icon name="tdesignClose" size="0.9rem" />
|
||||||
</button>
|
</button>
|
||||||
} @else {
|
} @else {
|
||||||
<button
|
<button
|
||||||
(click)="transfers.dismiss(job.id)"
|
(click)="transfers.dismiss(job.id)"
|
||||||
[title]="i18n.t('transfer.dismiss')"
|
[title]="i18n.t('transfer.dismiss')"
|
||||||
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
class="shrink-0 p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaX" size="0.9rem" />
|
<ng-icon name="tdesignClose" size="0.9rem" />
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
+7
-7
@@ -1,10 +1,10 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import type { TransferSnapshot } from '@shared/types';
|
import type { TransferSnapshot } from '@shared/types';
|
||||||
import { formatEta } from '../format';
|
import { formatEta } from '@core/format';
|
||||||
import { I18nService } from '../i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import { SettingsService } from '../settings.service';
|
import { SettingsService } from '@core/settings.service';
|
||||||
import { TransfersService } from '../transfers.service';
|
import { TransfersService } from '@core/transfers.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-transfer-bar',
|
selector: 'app-transfer-bar',
|
||||||
@@ -33,12 +33,12 @@ export class TransferBar {
|
|||||||
stateIcon(job: TransferSnapshot): string {
|
stateIcon(job: TransferSnapshot): string {
|
||||||
switch (job.state) {
|
switch (job.state) {
|
||||||
case 'completed':
|
case 'completed':
|
||||||
return 'mynaCheckCircle';
|
return 'tdesignCheckCircle';
|
||||||
case 'failed':
|
case 'failed':
|
||||||
case 'cancelled':
|
case 'cancelled':
|
||||||
return 'mynaXCircle';
|
return 'tdesignCloseCircle';
|
||||||
default:
|
default:
|
||||||
return 'mynaSpinner';
|
return 'tdesignLoading';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,540 +0,0 @@
|
|||||||
import { Component, ElementRef, effect, inject, signal, viewChild } from '@angular/core';
|
|
||||||
import { NgIcon } from '@ng-icons/core';
|
|
||||||
import { BRANDING } from '@shared/branding';
|
|
||||||
import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
|
|
||||||
import type { AccentTone } from '@shared/types';
|
|
||||||
import { I18nService } from '../i18n.service';
|
|
||||||
import { PanelsService } from '../panels.service';
|
|
||||||
import { IS_MAC_CHROME, MOD_LABEL } from '../platform';
|
|
||||||
import { RpcService } from '../rpc.service';
|
|
||||||
import { SettingsService } from '../settings.service';
|
|
||||||
import { UpdaterService } from '../updater.service';
|
|
||||||
|
|
||||||
const THEMES = ['system', 'light', 'dark'] as const;
|
|
||||||
const DARK_TONES = ['dark', 'darker'] as const;
|
|
||||||
// Swatch colors are fixed mid-tones so every dot reads on both light and dark
|
|
||||||
// cards (the live --primary values would vanish against one of them). Names are
|
|
||||||
// translated via options.accentNames.<value>.
|
|
||||||
const ACCENTS: readonly { value: AccentTone; swatch: string }[] = [
|
|
||||||
{ value: 'graphite', swatch: 'oklch(0.552 0.016 285.9)' },
|
|
||||||
{ value: 'blue', swatch: 'oklch(0.65 0.17 255)' },
|
|
||||||
{ value: 'indigo', swatch: 'oklch(0.65 0.19 277)' },
|
|
||||||
{ value: 'purple', swatch: 'oklch(0.65 0.17 305)' },
|
|
||||||
{ value: 'rose', swatch: 'oklch(0.65 0.17 8)' },
|
|
||||||
{ value: 'orange', swatch: 'oklch(0.68 0.17 50)' },
|
|
||||||
{ value: 'green', swatch: 'oklch(0.65 0.15 150)' },
|
|
||||||
{ value: 'teal', swatch: 'oklch(0.65 0.12 185)' },
|
|
||||||
];
|
|
||||||
const SPEED_UNITS = [
|
|
||||||
{ value: 'MBps', label: 'MB/s' },
|
|
||||||
{ value: 'Mbps', label: 'Mbps' },
|
|
||||||
] as const;
|
|
||||||
const TEXT_SIZES = ['compact', 'default', 'large'] as const;
|
|
||||||
const ICON_COLOR_MODES = ['plain', 'multi'] as const;
|
|
||||||
const TRANSFER_MODES = ['single', 'multi'] as const;
|
|
||||||
const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-tab-bar',
|
|
||||||
imports: [NgIcon],
|
|
||||||
template: `
|
|
||||||
<!-- On macOS this row IS the titlebar (hiddenInset): taller, padded past the
|
|
||||||
traffic lights, and draggable. Elsewhere it's a normal compact row. -->
|
|
||||||
<div
|
|
||||||
class="relative flex items-center gap-0.5 border-b border-border bg-chrome pr-1.5 electrobun-webkit-app-region-drag"
|
|
||||||
[class.h-10]="isMacChrome"
|
|
||||||
[class.pl-24]="isMacChrome"
|
|
||||||
[class.pl-1.5]="!isMacChrome"
|
|
||||||
[class.pt-1]="!isMacChrome"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
#tabScroll
|
|
||||||
class="flex min-w-0 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
||||||
>
|
|
||||||
@for (ws of panels.workspaces(); track ws.id; let i = $index) {
|
|
||||||
<div
|
|
||||||
[attr.data-tab-id]="ws.id"
|
|
||||||
class="group relative flex max-w-52 shrink-0 items-center rounded-md electrobun-webkit-app-region-no-drag"
|
|
||||||
[class.bg-accent]="ws.id === panels.activeWorkspaceId()"
|
|
||||||
[class.opacity-50]="draggedId() === ws.id"
|
|
||||||
[style.box-shadow]="dropIndicatorShadow(i)"
|
|
||||||
draggable="true"
|
|
||||||
(dragstart)="onDragStart($event, ws.id)"
|
|
||||||
(dragover)="onDragOver($event, i)"
|
|
||||||
(drop)="onDrop($event, i)"
|
|
||||||
(dragend)="onDragEnd()"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
(click)="panels.setActiveWorkspace(ws.id)"
|
|
||||||
class="flex min-w-0 items-center gap-1.5 py-1.5 pr-1 pl-2.5 text-xs"
|
|
||||||
[class.text-foreground]="ws.id === panels.activeWorkspaceId()"
|
|
||||||
[class.text-muted-foreground]="ws.id !== panels.activeWorkspaceId()"
|
|
||||||
[title]="panels.workspaceName(ws)"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
[name]="panels.workspaceIsRemote(ws) ? 'mynaServers' : 'mynaHardDrive'"
|
|
||||||
size="0.85rem"
|
|
||||||
class="shrink-0"
|
|
||||||
[class.text-primary]="panels.workspaceIsRemote(ws)"
|
|
||||||
/>
|
|
||||||
<span class="truncate">{{ panels.workspaceName(ws) }}</span>
|
|
||||||
</button>
|
|
||||||
@if (panels.workspaces().length > 1) {
|
|
||||||
<button
|
|
||||||
(click)="close($event, ws.id)"
|
|
||||||
[title]="i18n.t('tabBar.closeTab', { mod: modLabel })"
|
|
||||||
class="mr-1 grid size-4 shrink-0 place-items-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-border hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaX" size="0.7rem" />
|
|
||||||
</button>
|
|
||||||
} @else {
|
|
||||||
<span class="mr-1 size-4 shrink-0"></span>
|
|
||||||
}
|
|
||||||
@if (ws.id === panels.activeWorkspaceId()) {
|
|
||||||
<div class="absolute inset-x-0 -bottom-px h-0.5 rounded-full bg-primary"></div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
(click)="panels.newWorkspace()"
|
|
||||||
[title]="i18n.t('tabBar.newTab', { mod: modLabel })"
|
|
||||||
class="ml-0.5 grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaPlus" size=".8rem" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
|
|
||||||
<!-- auto-update tool: one accent pill that announces the update, becomes its
|
|
||||||
own progress bar while downloading, then turns into "Restart to apply" -->
|
|
||||||
@if (updater.state() !== 'idle' && updater.state() !== 'error') {
|
|
||||||
<button
|
|
||||||
(click)="onUpdateClick()"
|
|
||||||
[disabled]="updater.state() === 'downloading'"
|
|
||||||
[title]="i18n.t('tabBar.updateTooltip', { version: updater.version() })"
|
|
||||||
class="relative mr-1 grid h-6 place-items-center overflow-hidden rounded-md px-2.5 text-[11px] font-medium bg-primary text-primary-foreground electrobun-webkit-app-region-no-drag hover:opacity-90 disabled:cursor-default"
|
|
||||||
>
|
|
||||||
@if (updater.state() === 'downloading') {
|
|
||||||
<span
|
|
||||||
class="absolute inset-y-0 left-0 bg-primary-foreground/25 transition-[width] duration-200"
|
|
||||||
[style.width.%]="updater.progress()"
|
|
||||||
></span>
|
|
||||||
}
|
|
||||||
<span class="relative flex items-center gap-1.5">
|
|
||||||
@switch (updater.state()) {
|
|
||||||
@case ('available') {
|
|
||||||
<ng-icon name="mynaDownload" size=".8rem" />{{ i18n.t('tabBar.update') }}
|
|
||||||
}
|
|
||||||
@case ('downloading') {
|
|
||||||
<ng-icon name="mynaSpinner" size=".8rem" class="animate-spin" />{{ i18n.t('tabBar.updating') }} {{ updater.progress() }}%
|
|
||||||
}
|
|
||||||
@case ('ready') {
|
|
||||||
<ng-icon name="mynaRefresh" size=".8rem" />{{ i18n.t('tabBar.restartToUpdate') }}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- connection manager (own native window) -->
|
|
||||||
<button
|
|
||||||
(click)="openManager()"
|
|
||||||
[title]="i18n.t('tabBar.manageConnections')"
|
|
||||||
class="grid h-6 px-2 py-1 place-items-center rounded-md text-[11px] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<!-- <ng-icon name="mynaCog" size="0.9rem" /> -->
|
|
||||||
{{ i18n.t('tabBar.connections') }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- global options -->
|
|
||||||
<button
|
|
||||||
(click)="settingsOpen.set(!settingsOpen())"
|
|
||||||
[title]="i18n.t('tabBar.options')"
|
|
||||||
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaDots" size="1.5rem" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
@if (settingsOpen()) {
|
|
||||||
<div class="fixed inset-0 z-20" (click)="settingsOpen.set(false)"></div>
|
|
||||||
<div
|
|
||||||
class="absolute top-full right-1.5 z-30 mt-1 w-70 rounded-lg border border-border bg-card p-3 text-card-foreground shadow-xl electrobun-webkit-app-region-no-drag"
|
|
||||||
>
|
|
||||||
<h3 class="text-xs font-semibold">{{ i18n.t('options.title') }}</h3>
|
|
||||||
|
|
||||||
<div class="mt-3 flex items-center justify-between gap-2">
|
|
||||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.language') }}</span>
|
|
||||||
<select
|
|
||||||
(change)="settings.setLocale($any($event.target).value)"
|
|
||||||
class="h-7 rounded-md border border-input bg-background px-1.5 text-[11px] outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
>
|
|
||||||
<option value="system" [selected]="settings.locale() === 'system'">
|
|
||||||
{{ i18n.t('options.system') }}
|
|
||||||
</option>
|
|
||||||
@for (loc of localeOptions; track loc) {
|
|
||||||
<option [value]="loc" [selected]="settings.locale() === loc">
|
|
||||||
{{ localeLabels[loc] }}
|
|
||||||
</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
||||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.theme.label') }}</span>
|
|
||||||
<div class="flex rounded-md border border-border p-0.5">
|
|
||||||
@for (theme of themes; track theme) {
|
|
||||||
<button
|
|
||||||
(click)="settings.setTheme(theme)"
|
|
||||||
class="rounded px-2 py-0.5 text-[11px]"
|
|
||||||
[class.bg-accent]="settings.theme() === theme"
|
|
||||||
[class.text-foreground]="settings.theme() === theme"
|
|
||||||
[class.text-muted-foreground]="settings.theme() !== theme"
|
|
||||||
>
|
|
||||||
{{ i18n.t('options.theme.' + theme) }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (settings.effectiveDark()) {
|
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
||||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.tone.label') }}</span>
|
|
||||||
<div class="flex rounded-md border border-border p-0.5">
|
|
||||||
@for (tone of darkTones; track tone) {
|
|
||||||
<button
|
|
||||||
(click)="settings.setDarkTone(tone)"
|
|
||||||
class="rounded px-2 py-0.5 text-[11px]"
|
|
||||||
[class.bg-accent]="settings.darkTone() === tone"
|
|
||||||
[class.text-foreground]="settings.darkTone() === tone"
|
|
||||||
[class.text-muted-foreground]="settings.darkTone() !== tone"
|
|
||||||
>
|
|
||||||
{{ i18n.t('options.tone.' + tone) }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
||||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.accent.label') }}</span>
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
@for (tone of accents; track tone.value) {
|
|
||||||
<button
|
|
||||||
(click)="settings.setAccent(tone.value)"
|
|
||||||
[title]="i18n.t('options.accentNames.' + tone.value)"
|
|
||||||
class="size-3 rounded-full border border-foreground/15"
|
|
||||||
[style.background-color]="tone.swatch"
|
|
||||||
[class.ring-2]="settings.accent() === tone.value"
|
|
||||||
[class.ring-foreground]="settings.accent() === tone.value"
|
|
||||||
[class.ring-offset-1]="settings.accent() === tone.value"
|
|
||||||
[class.ring-offset-card]="settings.accent() === tone.value"
|
|
||||||
></button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
||||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.fileIcons.label') }}</span>
|
|
||||||
<div class="flex rounded-md border border-border p-0.5">
|
|
||||||
@for (mode of iconColorModes; track mode) {
|
|
||||||
<button
|
|
||||||
(click)="settings.setIconColors(mode)"
|
|
||||||
class="rounded px-2 py-0.5 text-[11px]"
|
|
||||||
[class.bg-accent]="settings.iconColors() === mode"
|
|
||||||
[class.text-foreground]="settings.iconColors() === mode"
|
|
||||||
[class.text-muted-foreground]="settings.iconColors() !== mode"
|
|
||||||
>
|
|
||||||
{{ i18n.t('options.fileIcons.' + mode) }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
||||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.interface.label') }}</span>
|
|
||||||
<div class="flex rounded-md border border-border p-0.5">
|
|
||||||
@for (size of textSizes; track size) {
|
|
||||||
<button
|
|
||||||
(click)="settings.setTextSize(size)"
|
|
||||||
class="rounded px-2 py-0.5 text-[11px]"
|
|
||||||
[class.bg-accent]="settings.textSize() === size"
|
|
||||||
[class.text-foreground]="settings.textSize() === size"
|
|
||||||
[class.text-muted-foreground]="settings.textSize() !== size"
|
|
||||||
>
|
|
||||||
{{ i18n.t('options.interface.' + size) }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
||||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.speedUnit.label') }}</span>
|
|
||||||
<div class="flex rounded-md border border-border p-0.5">
|
|
||||||
@for (unit of speedUnits; track unit.value) {
|
|
||||||
<button
|
|
||||||
(click)="settings.setSpeedUnit(unit.value)"
|
|
||||||
class="rounded px-2 py-0.5 text-[11px]"
|
|
||||||
[class.bg-accent]="settings.speedUnit() === unit.value"
|
|
||||||
[class.text-foreground]="settings.speedUnit() === unit.value"
|
|
||||||
[class.text-muted-foreground]="settings.speedUnit() !== unit.value"
|
|
||||||
>
|
|
||||||
{{ unit.label }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
||||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.transfers.label') }}</span>
|
|
||||||
<div class="flex rounded-md border border-border p-0.5">
|
|
||||||
@for (mode of transferModes; track mode) {
|
|
||||||
<button
|
|
||||||
(click)="settings.setTransferMode(mode)"
|
|
||||||
class="rounded px-2 py-0.5 text-[11px]"
|
|
||||||
[class.bg-accent]="settings.transferMode() === mode"
|
|
||||||
[class.text-foreground]="settings.transferMode() === mode"
|
|
||||||
[class.text-muted-foreground]="settings.transferMode() !== mode"
|
|
||||||
>
|
|
||||||
{{ i18n.t('options.transfers.' + mode) }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (settings.transferMode() === 'multi') {
|
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2 pl-3">
|
|
||||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.atOnce') }}</span>
|
|
||||||
<div class="flex rounded-md border border-border p-0.5">
|
|
||||||
@for (count of concurrencyChoices; track count) {
|
|
||||||
<button
|
|
||||||
(click)="settings.setTransferConcurrency(count)"
|
|
||||||
class="rounded px-2 py-0.5 text-[11px] tabular-nums"
|
|
||||||
[class.bg-accent]="settings.transferConcurrency() === count"
|
|
||||||
[class.text-foreground]="settings.transferConcurrency() === count"
|
|
||||||
[class.text-muted-foreground]="settings.transferConcurrency() !== count"
|
|
||||||
>
|
|
||||||
{{ count }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
||||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.editor.label') }}</span>
|
|
||||||
<div class="flex rounded-md border border-border p-0.5">
|
|
||||||
<button
|
|
||||||
(click)="settings.setEditor('builtin')"
|
|
||||||
class="rounded px-2 py-0.5 text-[11px]"
|
|
||||||
[class.bg-accent]="settings.editor() === 'builtin'"
|
|
||||||
[class.text-foreground]="settings.editor() === 'builtin'"
|
|
||||||
[class.text-muted-foreground]="settings.editor() !== 'builtin'"
|
|
||||||
>
|
|
||||||
{{ i18n.t('options.editor.builtin') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="settings.setEditor('custom')"
|
|
||||||
class="rounded px-2 py-0.5 text-[11px]"
|
|
||||||
[class.bg-accent]="settings.editor() === 'custom'"
|
|
||||||
[class.text-foreground]="settings.editor() === 'custom'"
|
|
||||||
[class.text-muted-foreground]="settings.editor() !== 'custom'"
|
|
||||||
>
|
|
||||||
{{ i18n.t('options.editor.custom') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (settings.editor() === 'custom') {
|
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2 pl-3">
|
|
||||||
<span class="min-w-0 truncate text-xs text-muted-foreground" [title]="settings.editorApp()">
|
|
||||||
{{ editorAppName() }}
|
|
||||||
</span>
|
|
||||||
<div class="flex shrink-0 items-center gap-1">
|
|
||||||
<button
|
|
||||||
(click)="pickEditorApp()"
|
|
||||||
class="rounded-md border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
|
|
||||||
>
|
|
||||||
{{ i18n.t('common.choose') }}
|
|
||||||
</button>
|
|
||||||
@if (settings.editorApp()) {
|
|
||||||
<button
|
|
||||||
(click)="settings.setEditorApp('')"
|
|
||||||
[title]="i18n.t('options.resetDefault')"
|
|
||||||
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaX" size="0.7rem" />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
||||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.finishedTransfers.label') }}</span>
|
|
||||||
<div class="flex rounded-md border border-border p-0.5">
|
|
||||||
<button
|
|
||||||
(click)="settings.setAutoClearCompleted(true)"
|
|
||||||
class="rounded px-2 py-0.5 text-[11px]"
|
|
||||||
[class.bg-accent]="settings.autoClearCompleted()"
|
|
||||||
[class.text-foreground]="settings.autoClearCompleted()"
|
|
||||||
[class.text-muted-foreground]="!settings.autoClearCompleted()"
|
|
||||||
>
|
|
||||||
{{ i18n.t('options.finishedTransfers.autoClear') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="settings.setAutoClearCompleted(false)"
|
|
||||||
class="rounded px-2 py-0.5 text-[11px]"
|
|
||||||
[class.bg-accent]="!settings.autoClearCompleted()"
|
|
||||||
[class.text-foreground]="!settings.autoClearCompleted()"
|
|
||||||
[class.text-muted-foreground]="settings.autoClearCompleted()"
|
|
||||||
>
|
|
||||||
{{ i18n.t('options.finishedTransfers.keep') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Auto-refresh is always on for now. To expose a toggle, uncomment
|
|
||||||
the breadcrumbs in types.ts + settings.service, then this row:
|
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
||||||
<span class="text-xs text-muted-foreground">Auto-refresh</span>
|
|
||||||
<div class="flex rounded-md border border-border p-0.5">
|
|
||||||
<button (click)="settings.setAutoRefresh(true)" class="rounded px-2 py-0.5 text-[11px]"
|
|
||||||
[class.bg-accent]="settings.autoRefresh()"
|
|
||||||
[class.text-foreground]="settings.autoRefresh()"
|
|
||||||
[class.text-muted-foreground]="!settings.autoRefresh()">On</button>
|
|
||||||
<button (click)="settings.setAutoRefresh(false)" class="rounded px-2 py-0.5 text-[11px]"
|
|
||||||
[class.bg-accent]="!settings.autoRefresh()"
|
|
||||||
[class.text-foreground]="!settings.autoRefresh()"
|
|
||||||
[class.text-muted-foreground]="settings.autoRefresh()">Off</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
-->
|
|
||||||
|
|
||||||
<!-- about footer -->
|
|
||||||
<button
|
|
||||||
(click)="showAbout()"
|
|
||||||
class="mt-3 w-full border-t border-border pt-2 text-center text-[11px] text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
{{ appName }} · {{ buildLabel }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class TabBar {
|
|
||||||
readonly panels = inject(PanelsService);
|
|
||||||
readonly settings = inject(SettingsService);
|
|
||||||
readonly i18n = inject(I18nService);
|
|
||||||
readonly updater = inject(UpdaterService);
|
|
||||||
private readonly rpc = inject(RpcService);
|
|
||||||
readonly settingsOpen = signal(false);
|
|
||||||
readonly localeOptions = SUPPORTED;
|
|
||||||
readonly localeLabels = LOCALE_LABELS;
|
|
||||||
readonly isMacChrome = IS_MAC_CHROME;
|
|
||||||
readonly modLabel = MOD_LABEL;
|
|
||||||
readonly appName = BRANDING.appName;
|
|
||||||
readonly buildLabel = BRANDING.buildLabel;
|
|
||||||
readonly themes = THEMES;
|
|
||||||
readonly darkTones = DARK_TONES;
|
|
||||||
readonly accents = ACCENTS;
|
|
||||||
readonly iconColorModes = ICON_COLOR_MODES;
|
|
||||||
readonly speedUnits = SPEED_UNITS;
|
|
||||||
readonly textSizes = TEXT_SIZES;
|
|
||||||
readonly transferModes = TRANSFER_MODES;
|
|
||||||
readonly concurrencyChoices = CONCURRENCY_CHOICES;
|
|
||||||
|
|
||||||
// drag & drop reordering
|
|
||||||
readonly draggedId = signal<number | null>(null);
|
|
||||||
readonly dropTarget = signal<{ index: number; before: boolean } | null>(null);
|
|
||||||
|
|
||||||
private readonly tabScroll = viewChild.required<ElementRef<HTMLDivElement>>('tabScroll');
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// keep the active tab visible when the strip overflows
|
|
||||||
effect(() => {
|
|
||||||
const id = this.panels.activeWorkspaceId();
|
|
||||||
this.tabScroll()
|
|
||||||
.nativeElement.querySelector(`[data-tab-id="${id}"]`)
|
|
||||||
?.scrollIntoView({ inline: 'nearest', block: 'nearest' });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
close(event: MouseEvent, id: number): void {
|
|
||||||
event.stopPropagation();
|
|
||||||
this.panels.closeWorkspace(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */
|
|
||||||
editorAppName(): string {
|
|
||||||
const path = this.settings.editorApp();
|
|
||||||
if (!path) return this.i18n.t('options.systemDefault');
|
|
||||||
const base = path.split('/').filter(Boolean).pop() ?? path;
|
|
||||||
return base.replace(/\.app$/i, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
openManager(): void {
|
|
||||||
void this.rpc.request.openConnectionManager({});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Update pill: first click downloads, second click (when ready) applies + relaunches. */
|
|
||||||
onUpdateClick(): void {
|
|
||||||
const state = this.updater.state();
|
|
||||||
if (state === 'available') void this.updater.download();
|
|
||||||
else if (state === 'ready') this.updater.apply();
|
|
||||||
}
|
|
||||||
|
|
||||||
showAbout(): void {
|
|
||||||
void this.rpc.request.showAbout({});
|
|
||||||
}
|
|
||||||
|
|
||||||
async pickEditorApp(): Promise<void> {
|
|
||||||
const res = await this.rpc.request.pickEditorApp({});
|
|
||||||
if (res.ok && res.path) this.settings.setEditorApp(res.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Insertion line at the drop position (inset shadow: no layout shift). */
|
|
||||||
dropIndicatorShadow(index: number): string | null {
|
|
||||||
const target = this.dropTarget();
|
|
||||||
if (!target || target.index !== index || this.draggedId() === null) return null;
|
|
||||||
return target.before
|
|
||||||
? 'inset 2px 0 0 var(--primary)'
|
|
||||||
: 'inset -2px 0 0 var(--primary)';
|
|
||||||
}
|
|
||||||
|
|
||||||
onDragStart(event: DragEvent, id: number): void {
|
|
||||||
this.draggedId.set(id);
|
|
||||||
// WebKit refuses to start a drag without data
|
|
||||||
event.dataTransfer?.setData('text/plain', String(id));
|
|
||||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
|
||||||
}
|
|
||||||
|
|
||||||
onDragOver(event: DragEvent, index: number): void {
|
|
||||||
if (this.draggedId() === null) return;
|
|
||||||
event.preventDefault();
|
|
||||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
|
||||||
const el = event.currentTarget as HTMLElement;
|
|
||||||
const before = event.clientX < el.getBoundingClientRect().left + el.offsetWidth / 2;
|
|
||||||
this.dropTarget.set({ index, before });
|
|
||||||
}
|
|
||||||
|
|
||||||
onDrop(event: DragEvent, index: number): void {
|
|
||||||
event.preventDefault();
|
|
||||||
const id = this.draggedId();
|
|
||||||
const target = this.dropTarget();
|
|
||||||
this.onDragEnd();
|
|
||||||
if (id === null || !target) return;
|
|
||||||
const list = this.panels.workspaces();
|
|
||||||
const fromIndex = list.findIndex((w) => w.id === id);
|
|
||||||
if (fromIndex < 0) return;
|
|
||||||
let toIndex = target.before ? index : index + 1;
|
|
||||||
if (fromIndex < toIndex) toIndex--; // account for removal before insert
|
|
||||||
if (toIndex !== fromIndex) this.panels.moveWorkspace(id, toIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
onDragEnd(): void {
|
|
||||||
this.draggedId.set(null);
|
|
||||||
this.dropTarget.set(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-3
@@ -13,11 +13,11 @@
|
|||||||
attributes are baked in by the bun preload (buildPreload in
|
attributes are baked in by the bun preload (buildPreload in
|
||||||
src/bun/windows.ts) so forced themes are correct at first paint. -->
|
src/bun/windows.ts) so forced themes are correct at first paint. -->
|
||||||
<style>
|
<style>
|
||||||
html { background: oklch(1 0 0); }
|
html { background: oklch(0.995 0.0001 260); }
|
||||||
html[data-theme='dark'] { background: oklch(0.2178 0.002 107); }
|
html[data-theme='dark'] { background: oklch(0.195 0.009 260); }
|
||||||
html[data-theme='dark'][data-dark-tone='darker'] { background: oklch(0 0 0); }
|
html[data-theme='dark'][data-dark-tone='darker'] { background: oklch(0 0 0); }
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
html:not([data-theme='light']) { background: oklch(0.2178 0.002 107); }
|
html:not([data-theme='light']) { background: oklch(0.195 0.009 260); }
|
||||||
html:not([data-theme='light'])[data-dark-tone='darker'] { background: oklch(0 0 0); }
|
html:not([data-theme='light'])[data-dark-tone='darker'] { background: oklch(0 0 0); }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+5
-8
@@ -1,10 +1,7 @@
|
|||||||
import { bootstrapApplication } from '@angular/platform-browser';
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
import { appConfig } from './app/app.config';
|
import { appConfig } from '@core/app.config';
|
||||||
import { App } from './app/app';
|
import { APP_MODE } from '@core/platform';
|
||||||
import { ConnectionManager } from './app/connection-manager/connection-manager';
|
import { SHAD_ROOTS } from './app/skins/shadlike';
|
||||||
import { EditorWindow } from './app/editor-window/editor-window';
|
|
||||||
import { APP_MODE } from './app/platform';
|
|
||||||
|
|
||||||
// One bundle, three window kinds: the preload global picks the root component.
|
// One bundle, four window kinds: the preload global picks the root component.
|
||||||
const root = APP_MODE === 'editor' ? EditorWindow : APP_MODE === 'manager' ? ConnectionManager : App;
|
bootstrapApplication(SHAD_ROOTS[APP_MODE], appConfig).catch((err) => console.error(err));
|
||||||
bootstrapApplication(root, appConfig).catch((err) => console.error(err));
|
|
||||||
|
|||||||
+228
-179
@@ -1,42 +1,56 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
/* shadcn-style theme tokens. Light stays the zinc family; the dark neutrals are a
|
/* Theme tokens, adapted from Oxide's design system (docs.oxide.computer): a cool
|
||||||
warm gray family (hue ~107) anchored on #1a1a19 — see the dark blocks below.
|
neutral scale at hue 260 with almost no chroma, one green accent, opaque
|
||||||
The app follows the OS light/dark preference automatically.
|
strokes. Step names in the comments (n0…n1300, green-800, …) are Oxide's.
|
||||||
|
The app follows the OS light/dark preference unless a theme is forced.
|
||||||
|
|
||||||
Surface elevation ramp (both themes): --chrome (frame strips: tab/transfer/status
|
Surface layers, each its own shade so the window reads as parts: --chrome
|
||||||
bars, window toolbars) sits BELOW --background (content panes), which sits below
|
(frame strips: tab/transfer/status bars, window header/footer strips and
|
||||||
--card (popovers, dialogs), which sits below --accent (hover). */
|
sidebars), --toolbar (the top row of a file panel's header strip, and
|
||||||
|
secondary toolbars) stepping through --toolbar-2/-3 toward --background
|
||||||
|
(content: file lists, editor text, forms; --row-alt stripes every other file
|
||||||
|
row), and --card (popovers, dialogs). Dark climbs frame < list < header <
|
||||||
|
card; light steps greys out from near-white content: frame < header < list
|
||||||
|
= card. --accent is a translucent overlay, not a surface: hover/selected
|
||||||
|
states must read on every layer, and an opaque hover vanishes on a surface
|
||||||
|
of its own lightness. The design is square: there are no radius tokens. */
|
||||||
:root {
|
:root {
|
||||||
/* Native UI (scrollbars, form controls) follows the OS unless a theme is forced. */
|
/* Native UI (form controls) follows the OS unless a theme is forced. */
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(0.995 0.0001 260); /* n1300 */
|
||||||
--chrome: oklch(0.977 0.001 286.375);
|
--chrome: oklch(0.925 0.0001 260); /* n1000 */
|
||||||
--foreground: oklch(0.141 0.005 285.823);
|
--toolbar: oklch(0.95 0.0001 260); /* n1100 */
|
||||||
--card: oklch(1 0 0);
|
--row-alt: oklch(0.975 0.0001 260); /* n1200 */
|
||||||
--card-foreground: oklch(0.141 0.005 285.823);
|
--foreground: oklch(0.162 0.01 260); /* n0 */
|
||||||
--primary: oklch(0.21 0.006 285.885);
|
--card: oklch(0.995 0.0001 260);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.162 0.01 260);
|
||||||
--secondary: oklch(0.967 0.001 286.375);
|
--primary: oklch(0.474 0.1481 173.7); /* green-500 */
|
||||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
--primary-foreground: oklch(0.995 0.0001 260);
|
||||||
--muted: oklch(0.967 0.001 286.375);
|
/* Oxide's surface-accent: the fill of a selected/highlighted item (green-1200) */
|
||||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
--primary-surface: oklch(0.922 0.0586 163.7);
|
||||||
--accent: oklch(0.967 0.001 286.375);
|
--muted: oklch(0.95 0.0001 260); /* n1100 */
|
||||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
--muted-foreground: oklch(0.483 0.0041 260); /* n500 */
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--accent: oklch(0.162 0.01 260 / 5%);
|
||||||
--destructive-foreground: oklch(0.985 0 0);
|
--destructive: oklch(0.474 0.1285 21.3); /* red-500 */
|
||||||
--success: oklch(0.627 0.17 149.2);
|
--destructive-foreground: oklch(0.995 0.0001 260);
|
||||||
--border: oklch(0.92 0.004 286.32);
|
--success: oklch(0.474 0.1481 173.7); /* green-500 */
|
||||||
--input: oklch(0.92 0.004 286.32);
|
--border: oklch(0.9 0.0001 260); /* n900 */
|
||||||
--ring: oklch(0.705 0.015 286.067);
|
--input: oklch(0.79 0.0011 260); /* n800 */
|
||||||
--radius: 0.5rem;
|
--ring: oklch(0.563 0.1714 170.4); /* green-600 */
|
||||||
/* file-type icon colors ("Multi" mode) — fixed category hues, muted chroma */
|
--cursor-tint: color-mix(in oklab, var(--primary) 8%, transparent);
|
||||||
--icon-folder: oklch(0.58 0.13 250);
|
/* The file panel's header strip steps toward the list, a third per row.
|
||||||
--icon-link: oklch(0.56 0.1 190);
|
Declared once: the var()s resolve on :root, so dark and AMOLED (where both
|
||||||
--icon-image: oklch(0.58 0.13 295);
|
ends are black) follow without their own lines. */
|
||||||
--icon-media: oklch(0.6 0.13 10);
|
--toolbar-2: color-mix(in oklab, var(--toolbar) 66%, var(--background));
|
||||||
--icon-archive: oklch(0.62 0.13 60);
|
--toolbar-3: color-mix(in oklab, var(--toolbar) 33%, var(--background));
|
||||||
--icon-code: oklch(0.56 0.12 155);
|
/* file-type icon colors — fixed category hues from Oxide's scales (600 steps) */
|
||||||
|
--icon-folder: oklch(0.563 0.1139 278.7); /* blue-600 */
|
||||||
|
--icon-link: oklch(0.67 0.1216 275.3); /* blue-700 */
|
||||||
|
--icon-image: oklch(0.563 0.1328 312.1); /* purple-600 */
|
||||||
|
--icon-media: oklch(0.563 0.1487 18); /* red-600 */
|
||||||
|
--icon-archive: oklch(0.563 0.125 81.7); /* yellow-600 */
|
||||||
|
--icon-code: oklch(0.563 0.1714 170.4); /* green-600 */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark tokens apply when the user forces dark, or follows a dark system theme.
|
/* Dark tokens apply when the user forces dark, or follows a dark system theme.
|
||||||
@@ -47,110 +61,111 @@
|
|||||||
|
|
||||||
:root[data-theme='dark'] {
|
:root[data-theme='dark'] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--background: oklch(0.2178 0.002 107);
|
--background: oklch(0.195 0.009 260); /* n50 */
|
||||||
--chrome: oklch(0.1953 0.002 107);
|
--chrome: oklch(0.162 0.01 260); /* n0 */
|
||||||
--foreground: oklch(0.982 0.003 107);
|
--toolbar: oklch(0.275 0.0065 260); /* between n200 and n300 */
|
||||||
--card: oklch(0.245 0.002 107);
|
--row-alt: oklch(0.205 0.0085 260); /* between n50 and n100 */
|
||||||
--card-foreground: oklch(0.982 0.003 107);
|
--foreground: oklch(0.9 0.0001 260); /* n900 */
|
||||||
--primary: oklch(0.92 0.004 107);
|
/* above --toolbar, so a popover never reads darker than the strip it covers */
|
||||||
--primary-foreground: oklch(0.21 0.002 107);
|
--card: oklch(0.29 0.0065 260);
|
||||||
--secondary: oklch(0.302 0.003 107);
|
--card-foreground: oklch(0.9 0.0001 260);
|
||||||
--secondary-foreground: oklch(0.982 0.003 107);
|
--primary: oklch(0.77 0.1919 163.7); /* green-800 */
|
||||||
--muted: oklch(0.302 0.003 107);
|
--primary-foreground: oklch(0.162 0.01 260);
|
||||||
--muted-foreground: oklch(0.715 0.012 107);
|
--primary-surface: oklch(0.24 0.0722 183.7); /* green-200 */
|
||||||
--accent: oklch(0.302 0.003 107);
|
--muted: oklch(0.316 0.006 260); /* n300 */
|
||||||
--accent-foreground: oklch(0.982 0.003 107);
|
--muted-foreground: oklch(0.717 0.0021 260); /* n700 */
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--accent: oklch(1 0 0 / 7%);
|
||||||
--destructive-foreground: oklch(0.982 0.003 107);
|
--destructive: oklch(0.712 0.185 11.3); /* red-800 */
|
||||||
--success: oklch(0.696 0.17 149.2);
|
--destructive-foreground: oklch(0.162 0.01 260);
|
||||||
--border: oklch(1 0 0 / 14%);
|
--success: oklch(0.77 0.1919 163.7); /* green-800 */
|
||||||
--input: oklch(1 0 0 / 18%);
|
--border: oklch(0.316 0.006 260); /* n300 */
|
||||||
--ring: oklch(0.552 0.01 107);
|
--input: oklch(0.389 0.005 260); /* n400 */
|
||||||
--icon-folder: oklch(0.75 0.11 250);
|
--ring: oklch(0.563 0.1714 170.4); /* green-600 */
|
||||||
--icon-link: oklch(0.74 0.09 190);
|
--cursor-tint: color-mix(in oklab, var(--primary) 12%, transparent);
|
||||||
--icon-image: oklch(0.76 0.11 295);
|
--icon-folder: oklch(0.71 0.15 272); /* blue-800 */
|
||||||
--icon-media: oklch(0.76 0.11 10);
|
--icon-link: oklch(0.67 0.1216 275.3); /* blue-700 */
|
||||||
--icon-archive: oklch(0.77 0.11 65);
|
--icon-image: oklch(0.74 0.175 305.4); /* purple-800 */
|
||||||
--icon-code: oklch(0.75 0.1 155);
|
--icon-media: oklch(0.712 0.185 11.3); /* red-800 */
|
||||||
|
--icon-archive: oklch(0.837 0.14 75); /* yellow-800 */
|
||||||
|
--icon-code: oklch(0.77 0.1919 163.7); /* green-800 */
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root:not([data-theme='light']) {
|
:root:not([data-theme='light']) {
|
||||||
--background: oklch(0.2178 0.002 107);
|
--background: oklch(0.195 0.009 260);
|
||||||
--chrome: oklch(0.1953 0.002 107);
|
--chrome: oklch(0.162 0.01 260);
|
||||||
--foreground: oklch(0.982 0.003 107);
|
--toolbar: oklch(0.275 0.0065 260);
|
||||||
--card: oklch(0.245 0.002 107);
|
--row-alt: oklch(0.205 0.0085 260);
|
||||||
--card-foreground: oklch(0.982 0.003 107);
|
--foreground: oklch(0.9 0.0001 260);
|
||||||
--primary: oklch(0.92 0.004 107);
|
--card: oklch(0.29 0.0065 260);
|
||||||
--primary-foreground: oklch(0.21 0.002 107);
|
--card-foreground: oklch(0.9 0.0001 260);
|
||||||
--secondary: oklch(0.302 0.003 107);
|
--primary: oklch(0.77 0.1919 163.7);
|
||||||
--secondary-foreground: oklch(0.982 0.003 107);
|
--primary-foreground: oklch(0.162 0.01 260);
|
||||||
--muted: oklch(0.302 0.003 107);
|
--primary-surface: oklch(0.24 0.0722 183.7);
|
||||||
--muted-foreground: oklch(0.715 0.012 107);
|
--muted: oklch(0.316 0.006 260);
|
||||||
--accent: oklch(0.302 0.003 107);
|
--muted-foreground: oklch(0.717 0.0021 260);
|
||||||
--accent-foreground: oklch(0.982 0.003 107);
|
--accent: oklch(1 0 0 / 7%);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.712 0.185 11.3);
|
||||||
--destructive-foreground: oklch(0.982 0.003 107);
|
--destructive-foreground: oklch(0.162 0.01 260);
|
||||||
--success: oklch(0.696 0.17 149.2);
|
--success: oklch(0.77 0.1919 163.7);
|
||||||
--border: oklch(1 0 0 / 14%);
|
--border: oklch(0.316 0.006 260);
|
||||||
--input: oklch(1 0 0 / 18%);
|
--input: oklch(0.389 0.005 260);
|
||||||
--ring: oklch(0.552 0.01 107);
|
--ring: oklch(0.563 0.1714 170.4);
|
||||||
--icon-folder: oklch(0.75 0.11 250);
|
--cursor-tint: color-mix(in oklab, var(--primary) 12%, transparent);
|
||||||
--icon-link: oklch(0.74 0.09 190);
|
--icon-folder: oklch(0.71 0.15 272);
|
||||||
--icon-image: oklch(0.76 0.11 295);
|
--icon-link: oklch(0.67 0.1216 275.3);
|
||||||
--icon-media: oklch(0.76 0.11 10);
|
--icon-image: oklch(0.74 0.175 305.4);
|
||||||
--icon-archive: oklch(0.77 0.11 65);
|
--icon-media: oklch(0.712 0.185 11.3);
|
||||||
--icon-code: oklch(0.75 0.1 155);
|
--icon-archive: oklch(0.837 0.14 75);
|
||||||
|
--icon-code: oklch(0.77 0.1919 163.7);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark tone "darker" (data-dark-tone set by SettingsService; absent = grey "dark").
|
/* Dark tone "darker" (data-dark-tone set by SettingsService; absent = grey "dark").
|
||||||
True-black AMOLED palette: --chrome collapses into --background on purpose, so
|
True-black AMOLED palette: every surface layer (--chrome, --toolbar and the
|
||||||
the frame/content step of the default tone disappears and every large surface is
|
steps derived from it, --background, --row-alt) collapses to #000 on purpose
|
||||||
#000 (OLED pixels actually off). Elevation survives only through --card and
|
— OLED pixels actually off — so every NEW surface token needs a line here.
|
||||||
--accent; --border/--input are raised to 16%/18% because on pure black those
|
Elevation survives only through --card, --muted and --accent (opaque here:
|
||||||
lines are the only thing defining panes, bars and rows. Duplicated for
|
an overlay on black is too faint); the opaque strokes already read on black.
|
||||||
forced-dark and system-dark like the token blocks above — the extra attribute
|
Duplicated for forced-dark and system-dark like the token blocks above — the
|
||||||
outranks them. */
|
extra attribute outranks them. */
|
||||||
:root[data-theme='dark'][data-dark-tone='darker'] {
|
:root[data-theme='dark'][data-dark-tone='darker'] {
|
||||||
--background: oklch(0 0 0);
|
--background: oklch(0 0 0);
|
||||||
--chrome: oklch(0 0 0);
|
--chrome: oklch(0 0 0);
|
||||||
--card: oklch(0.21 0.002 107);
|
--toolbar: oklch(0 0 0);
|
||||||
--secondary: oklch(0.274 0.003 107);
|
--row-alt: oklch(0 0 0);
|
||||||
--muted: oklch(0.274 0.003 107);
|
--card: oklch(0.216 0.008 260); /* n100 */
|
||||||
--muted-foreground: oklch(0.705 0.012 107);
|
--muted: oklch(0.247 0.007 260); /* n200 */
|
||||||
--accent: oklch(0.274 0.003 107);
|
--accent: oklch(0.247 0.007 260); /* n200 */
|
||||||
--border: oklch(1 0 0 / 16%);
|
|
||||||
--input: oklch(1 0 0 / 18%);
|
|
||||||
}
|
}
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root:not([data-theme='light'])[data-dark-tone='darker'] {
|
:root:not([data-theme='light'])[data-dark-tone='darker'] {
|
||||||
--background: oklch(0 0 0);
|
--background: oklch(0 0 0);
|
||||||
--chrome: oklch(0 0 0);
|
--chrome: oklch(0 0 0);
|
||||||
--card: oklch(0.21 0.002 107);
|
--toolbar: oklch(0 0 0);
|
||||||
--secondary: oklch(0.274 0.003 107);
|
--row-alt: oklch(0 0 0);
|
||||||
--muted: oklch(0.274 0.003 107);
|
--card: oklch(0.216 0.008 260);
|
||||||
--muted-foreground: oklch(0.705 0.012 107);
|
--muted: oklch(0.247 0.007 260);
|
||||||
--accent: oklch(0.274 0.003 107);
|
--accent: oklch(0.247 0.007 260);
|
||||||
--border: oklch(1 0 0 / 16%);
|
|
||||||
--input: oklch(1 0 0 / 18%);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-chrome: var(--chrome);
|
--color-chrome: var(--chrome);
|
||||||
|
--color-toolbar: var(--toolbar);
|
||||||
|
--color-toolbar-2: var(--toolbar-2);
|
||||||
|
--color-toolbar-3: var(--toolbar-3);
|
||||||
|
--color-row-alt: var(--row-alt);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
--color-card-foreground: var(--card-foreground);
|
--color-card-foreground: var(--card-foreground);
|
||||||
--color-primary: var(--primary);
|
--color-primary: var(--primary);
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
--color-secondary: var(--secondary);
|
--color-primary-surface: var(--primary-surface);
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
|
||||||
--color-muted: var(--muted);
|
--color-muted: var(--muted);
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
|
||||||
--color-destructive: var(--destructive);
|
--color-destructive: var(--destructive);
|
||||||
--color-destructive-foreground: var(--destructive-foreground);
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
--color-success: var(--success);
|
--color-success: var(--success);
|
||||||
@@ -164,45 +179,26 @@
|
|||||||
--color-icon-media: var(--icon-media);
|
--color-icon-media: var(--icon-media);
|
||||||
--color-icon-archive: var(--icon-archive);
|
--color-icon-archive: var(--icon-archive);
|
||||||
--color-icon-code: var(--icon-code);
|
--color-icon-code: var(--icon-code);
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Interface-size setting (data-text set by SettingsService). Scales the whole
|
/* Panel-chrome metrics: file list, path bar, action buttons, source picker.
|
||||||
panel chrome: file list, path bar, action buttons, and the source picker
|
ALL REM, deliberately — the interface scale (settings.uiScale) works by
|
||||||
with its dropdown. The tab bar (including the options button) stays fixed.
|
setting the root font-size, so every rem value in the app scales from that
|
||||||
Default-tier values match the old hardcoded classes exactly. */
|
one lever. A px value here would freeze while everything around it grew.
|
||||||
|
There is only one tier now: the old data-text compact/large tiers are gone, replaced
|
||||||
|
by the continuous scale. Values are the old default tier verbatim
|
||||||
|
(13px -> 0.8125rem etc. at the 16px root), so scale 1.0 is pixel-identical
|
||||||
|
to the pre-scale app. */
|
||||||
:root {
|
:root {
|
||||||
--list-font: 13px;
|
--list-font: 0.8125rem;
|
||||||
--list-meta-font: 12px;
|
--list-meta-font: 0.75rem;
|
||||||
--path-font: 12px;
|
--path-font: 0.75rem;
|
||||||
--row-h: 28px;
|
--row-h: 1.75rem;
|
||||||
--path-ctl-h: 28px;
|
--path-ctl-h: 1.75rem;
|
||||||
--action-h: 24px;
|
--action-h: 1.5rem;
|
||||||
--action-font: 11px;
|
--action-font: 0.6875rem;
|
||||||
--action-icon: 0.9rem;
|
--action-icon: 0.9rem;
|
||||||
}
|
}
|
||||||
:root[data-text='compact'] {
|
|
||||||
--list-font: 12px;
|
|
||||||
--list-meta-font: 11px;
|
|
||||||
--path-font: 11px;
|
|
||||||
--row-h: 24px;
|
|
||||||
--path-ctl-h: 24px;
|
|
||||||
--action-h: 22px;
|
|
||||||
--action-font: 10px;
|
|
||||||
--action-icon: 0.85rem;
|
|
||||||
}
|
|
||||||
:root[data-text='large'] {
|
|
||||||
--list-font: 14px;
|
|
||||||
--list-meta-font: 13px;
|
|
||||||
--path-font: 13px;
|
|
||||||
--row-h: 32px;
|
|
||||||
--path-ctl-h: 32px;
|
|
||||||
--action-h: 28px;
|
|
||||||
--action-font: 12px;
|
|
||||||
--action-icon: 1.05rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Soft entrance for the panel busy states (connecting placeholder, load overlay). */
|
/* Soft entrance for the panel busy states (connecting placeholder, load overlay). */
|
||||||
@keyframes panel-fade-in {
|
@keyframes panel-fade-in {
|
||||||
@@ -229,43 +225,6 @@
|
|||||||
animation-play-state: paused;
|
animation-play-state: paused;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Accent tone (data-accent set by SettingsService; absent = graphite/zinc).
|
|
||||||
Each tone is one hue+chroma line; the formula blocks below derive --primary,
|
|
||||||
--ring and the cursor-row tint for light and both dark states. The dark
|
|
||||||
formula blocks (0,3,0) outrank the base dark token blocks and the light
|
|
||||||
formula block (0,2,0) in every dark state; graphite matches no [data-accent]
|
|
||||||
selector and keeps the zinc tokens untouched. */
|
|
||||||
:root {
|
|
||||||
/* graphite: cursor row identical to the old bg-accent highlight */
|
|
||||||
--cursor-tint: var(--accent);
|
|
||||||
}
|
|
||||||
:root[data-accent='blue'] { --tone-h: 255; --tone-c: 0.17; }
|
|
||||||
:root[data-accent='indigo'] { --tone-h: 277; --tone-c: 0.19; }
|
|
||||||
:root[data-accent='purple'] { --tone-h: 305; --tone-c: 0.17; }
|
|
||||||
:root[data-accent='rose'] { --tone-h: 8; --tone-c: 0.17; }
|
|
||||||
/* darkest orange that still reads orange; white button text lands ≈ 4.3:1 */
|
|
||||||
:root[data-accent='orange'] { --tone-h: 50; --tone-c: 0.18; --tone-l: 0.58; }
|
|
||||||
:root[data-accent='green'] { --tone-h: 150; --tone-c: 0.15; }
|
|
||||||
:root[data-accent='teal'] { --tone-h: 185; --tone-c: 0.12; }
|
|
||||||
|
|
||||||
:root[data-accent] {
|
|
||||||
--primary: oklch(var(--tone-l, 0.55) var(--tone-c) var(--tone-h));
|
|
||||||
--ring: oklch(0.62 calc(var(--tone-c) * 0.55) var(--tone-h));
|
|
||||||
--cursor-tint: color-mix(in oklab, var(--primary) 8%, transparent);
|
|
||||||
}
|
|
||||||
:root[data-theme='dark'][data-accent] {
|
|
||||||
--primary: oklch(0.72 calc(var(--tone-c) * 0.85) var(--tone-h));
|
|
||||||
--ring: oklch(0.62 calc(var(--tone-c) * 0.55) var(--tone-h));
|
|
||||||
--cursor-tint: color-mix(in oklab, var(--primary) 12%, transparent);
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root[data-accent]:not([data-theme='light']) {
|
|
||||||
--primary: oklch(0.72 calc(var(--tone-c) * 0.85) var(--tone-h));
|
|
||||||
--ring: oklch(0.62 calc(var(--tone-c) * 0.55) var(--tone-h));
|
|
||||||
--cursor-tint: color-mix(in oklab, var(--primary) 12%, transparent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -276,3 +235,93 @@ body {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Native controls, restyled square to match the rest. WebKit draws checkboxes,
|
||||||
|
radios and selects natively and ignores border-radius/background until
|
||||||
|
appearance: none. In @layer base so utilities (size-3.5, pl-1 …) still win —
|
||||||
|
which is also why the selects use pl-*, never px-*: a px utility would
|
||||||
|
override the padding-right the caret needs. */
|
||||||
|
@layer base {
|
||||||
|
input[type='checkbox'],
|
||||||
|
input[type='radio'] {
|
||||||
|
appearance: none;
|
||||||
|
display: inline-grid;
|
||||||
|
place-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 0.875rem;
|
||||||
|
height: 0.875rem;
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid var(--input);
|
||||||
|
background-color: var(--background);
|
||||||
|
}
|
||||||
|
input[type='checkbox']:checked,
|
||||||
|
input[type='radio']:checked {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background-color: var(--primary);
|
||||||
|
}
|
||||||
|
/* the check: an L of two borders, rotated */
|
||||||
|
input[type='checkbox']:checked::after {
|
||||||
|
content: '';
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.28rem;
|
||||||
|
border: solid var(--primary-foreground);
|
||||||
|
border-width: 0 0 0.125rem 0.125rem;
|
||||||
|
transform: translateY(-0.06rem) rotate(-45deg);
|
||||||
|
}
|
||||||
|
/* radios are square too; a square dot tells them apart from checkboxes */
|
||||||
|
input[type='radio']:checked::after {
|
||||||
|
content: '';
|
||||||
|
width: 0.375rem;
|
||||||
|
height: 0.375rem;
|
||||||
|
background-color: var(--primary-foreground);
|
||||||
|
}
|
||||||
|
input[type='checkbox']:focus-visible,
|
||||||
|
input[type='radio']:focus-visible {
|
||||||
|
outline: 2px solid var(--ring);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
input[type='checkbox']:disabled,
|
||||||
|
input[type='radio']:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No native arrow: a caret drawn from two currentColor triangles follows the
|
||||||
|
theme (a data-URI svg can't). The opened option list stays native — macOS
|
||||||
|
draws that popup itself. */
|
||||||
|
select {
|
||||||
|
appearance: none;
|
||||||
|
padding-right: 1.5rem;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, transparent 50%, currentColor 50%),
|
||||||
|
linear-gradient(135deg, currentColor 50%, transparent 50%);
|
||||||
|
background-position:
|
||||||
|
calc(100% - 0.8rem) 55%,
|
||||||
|
calc(100% - 0.5rem) 55%;
|
||||||
|
background-size: 0.3rem 0.3rem;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* number inputs (the manager's port field): no native spin buttons */
|
||||||
|
input[type='number']::-webkit-inner-spin-button,
|
||||||
|
input[type='number']::-webkit-outer-spin-button {
|
||||||
|
appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Square scrollbars. Styling them replaces macOS's auto-hiding overlay
|
||||||
|
scrollbars with always-visible ones — the inherent cost. */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 0.625rem;
|
||||||
|
height: 0.625rem;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track,
|
||||||
|
::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--border);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: var(--input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+14
-2
@@ -4,12 +4,24 @@
|
|||||||
"compileOnSave": false,
|
"compileOnSave": false,
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
/* @shared -> the bun/UI shared code (src/shared). @core -> the skin-agnostic
|
||||||
|
UI layer (services, platform, formatters). @core is an ALIAS rather than a
|
||||||
|
relative path on purpose: skin components live at varying depths under
|
||||||
|
app/skins/<skin>/, and an alias keeps their imports identical wherever a
|
||||||
|
component is moved to.
|
||||||
|
electrobun/view -> the SDK Hutch projects into .hutch/devkit (the npm
|
||||||
|
`electrobun` package only exports a throwing stub). Exact target from the
|
||||||
|
devkit's export map; `bunx electrobun prepare` must have run. */
|
||||||
"paths": {
|
"paths": {
|
||||||
"@shared/*": ["../src/shared/*"]
|
"@shared/*": ["../src/shared/*"],
|
||||||
|
"@core/*": ["./src/app/core/*"],
|
||||||
|
"electrobun/view": ["../.hutch/devkit/api/browser/index.ts"]
|
||||||
},
|
},
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
"noPropertyAccessFromIndexSignature": true,
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
"noImplicitReturns": true,
|
/* Off because the devkit's browser SDK is raw TS compiled inside this
|
||||||
|
program, and 2.0.2's transport send() returns on only one path. */
|
||||||
|
"noImplicitReturns": false,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user