Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5be41db032 | ||
|
|
5d23809d48 | ||
|
|
3f7145d69a | ||
|
|
fa2cec7136 | ||
|
|
e28314054f | ||
|
|
8f8f230711 | ||
|
|
d361297690 | ||
|
|
0eb5608873 | ||
|
|
9bd237d89c | ||
|
|
bfc2147c77 | ||
|
|
318760efa6 | ||
|
|
141d675bf7 | ||
|
|
f3bec7aeaf | ||
|
|
99bfd3abc2 | ||
|
|
a83264c77d | ||
|
|
e3a5750b01 | ||
|
|
c3d97ce524 | ||
|
|
f7f02083fe | ||
|
|
2bac7678ca | ||
|
|
6c05e39ce1 |
@@ -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
|
||||||
|
|||||||
+3
-3
@@ -8,6 +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
|
||||||
# headless screenshot workspace (scripts/shot.ts)
|
.hutch/
|
||||||
.shot/
|
.cottontail-tmp/
|
||||||
|
|||||||
@@ -35,13 +35,15 @@ Dense reference material lives in [docs/](docs/): [architecture](docs/architectu
|
|||||||
a standard titlebar.
|
a standard titlebar.
|
||||||
- **Menu-bar (tray) icon**: template icon that adapts to light/dark; click it
|
- **Menu-bar (tray) icon**: template icon that adapts to light/dark; click it
|
||||||
to bring the app to the front.
|
to bring the app to the front.
|
||||||
- **Edit files** (F4 / toolbar / File menu): a built-in editor works on local
|
- **Edit files** (F4 / ⌘E / toolbar / File menu): a built-in editor works on local
|
||||||
and remote files directly; or pick an external app (Options → Editor →
|
and remote files directly; or pick an external app (Settings → Editor →
|
||||||
Custom → Choose…). External edits of remote files download to a temp folder,
|
Custom → Choose…). External edits of remote files download to a temp folder,
|
||||||
are watched, and every save auto-uploads (visible in the transfer list) until
|
are watched, and every save auto-uploads (visible in the transfer list) until
|
||||||
the session disconnects. Leftover temp files from a crash are offered for
|
the session disconnects. Leftover temp files from a crash are offered for
|
||||||
cleanup at the next start.
|
cleanup at the next start.
|
||||||
- Browse, sort (name/size/modified, folders first), keyboard navigation.
|
- Browse, sort (name/size/modified, folders first), full keyboard control
|
||||||
|
with remappable shortcuts (see below). Enter / double-click on a local file
|
||||||
|
can open it in its default app (Settings → File list; off by default).
|
||||||
- Create folder / file, rename, delete (recursive), change permissions (chmod).
|
- Create folder / file, rename, delete (recursive), change permissions (chmod).
|
||||||
- Upload, download, and remote→remote send — files **and** whole folders —
|
- Upload, download, and remote→remote send — files **and** whole folders —
|
||||||
with a transfer queue, per-job progress, and cancel.
|
with a transfer queue, per-job progress, and cancel.
|
||||||
@@ -53,10 +55,10 @@ Dense reference material lives in [docs/](docs/): [architecture](docs/architectu
|
|||||||
| Layer | Choice |
|
| Layer | Choice |
|
||||||
| ---------- | ------------------------------------------------------------- |
|
| ---------- | ------------------------------------------------------------- |
|
||||||
| Runtime | Bun (main process, all SSH/SFTP + file IO) |
|
| Runtime | Bun (main process, all SSH/SFTP + file IO) |
|
||||||
| Desktop | Electrobun 1.18 (native webview, typed RPC bridge) |
|
| Desktop | Electrobun 2.0 via Hutch (native webview, typed RPC bridge) |
|
||||||
| Frontend | Angular 22 (standalone, signals, zoneless) in `ui/` |
|
| Frontend | Angular 22 (standalone, signals, zoneless) in `ui/` |
|
||||||
| Styling | Tailwind CSS v4, shadcn-style tokens (no component library) |
|
| Styling | Tailwind CSS v4, Oxide-derived tokens (no component library) |
|
||||||
| Icons | @ng-icons/mynaui |
|
| Icons | TDesign shapes redrawn on 14/16px pixel grids (`@ng-icons`) |
|
||||||
| SSH/SFTP | `ssh2` (pure-JS under Bun) |
|
| SSH/SFTP | `ssh2` (pure-JS under Bun) |
|
||||||
|
|
||||||
The repo is a Bun workspace: the Electrobun app at the root, the Angular app in
|
The repo is a Bun workspace: the Electrobun app at the root, the Angular app in
|
||||||
@@ -83,6 +85,10 @@ ui/src/app/ Angular components + signal services
|
|||||||
Details and diagnostics in [platform notes](docs/platform-notes.md).
|
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
|
||||||
|
|
||||||
@@ -107,22 +113,19 @@ Versioning is Sublime-style: **Build n**, where n is the commit count
|
|||||||
`src/shared/version.gen.ts` by every build script (and `postinstall`).
|
`src/shared/version.gen.ts` by every build script (and `postinstall`).
|
||||||
|
|
||||||
One artifact per invocation, on the matching host — electrobun cannot
|
One artifact per invocation, on the matching host — electrobun cannot
|
||||||
cross-compile between OSes:
|
cross-compile (Hutch builds for its own OS and arch):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
bun run release -- --target mac-arm64 # on the Mac (native)
|
bun run release -- --target mac-arm64 # on an Apple Silicon Mac
|
||||||
bun run release -- --target mac-x64 # on the same arm64 Mac, via Rosetta 2
|
|
||||||
bun run release -- --target win-x64 # on a Windows 11 x64 machine
|
bun run release -- --target win-x64 # on a Windows 11 x64 machine
|
||||||
bun run release -- --target linux-x64 # on a Linux x64 machine
|
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); on Linux a portable `.tar.gz` (unpack and run
|
(unzip anywhere and run); on Linux Electrobun's `-Setup.tar.gz` installer.
|
||||||
`miniSCP.sh`, which sets the NVIDIA workaround then execs `bin/launcher`).
|
There is no Intel Mac build: Electrobun 2.x publishes no macOS x64 core.
|
||||||
The mac-x64 build fetches electrobun's x64 CLI once
|
CI: `.gitea/workflows/release.yml` runs the
|
||||||
into `~/.cache/miniscp/` and runs it under Rosetta so the whole pipeline
|
|
||||||
believes it is on an Intel Mac. CI: `.gitea/workflows/release.yml` runs the
|
|
||||||
same script on a macos + windows runner matrix once act_runners are
|
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.
|
||||||
|
|
||||||
@@ -137,7 +140,7 @@ compiled from `assets/icon.iconset` into `AppIcon.icns` automatically.
|
|||||||
|
|
||||||
For a distributable (optimized, with auto-update channel) build, use
|
For a distributable (optimized, with auto-update channel) build, use
|
||||||
`bunx electrobun build --env=stable` (see the
|
`bunx electrobun build --env=stable` (see the
|
||||||
[Electrobun docs](https://framework.blackboard.sh/electrobun/) for signing,
|
[Electrobun docs](https://electrobun.dev/electrobun/) for signing,
|
||||||
notarization, and release hosting).
|
notarization, and release hosting).
|
||||||
|
|
||||||
## Rebranding
|
## Rebranding
|
||||||
@@ -177,13 +180,102 @@ Saved connections live in a plain JSON file in the OS app-data directory
|
|||||||
|
|
||||||
## Keyboard shortcuts
|
## Keyboard shortcuts
|
||||||
|
|
||||||
`Tab` switch panel · `↑`/`↓`/`PgUp`/`PgDn`/`Home`/`End` move cursor ·
|
The whole app works from the keyboard, and **every command can be remapped** in
|
||||||
`Space` select · `Enter` open folder · `Backspace` parent ·
|
**Settings → Keyboard**: press **+** on a row and type the keys, see conflicts,
|
||||||
`Ctrl/Cmd+A` select all · `F5` send to other panel · `F2` rename ·
|
reset one row or all. Remaps apply live in every window, the right-click menus'
|
||||||
`F7` new folder · `F8`/`Delete` delete · `Cmd/Ctrl+T` new tab ·
|
hints and the toolbar tooltips follow, and so does the macOS menu bar. The
|
||||||
`Cmd/Ctrl+W` close tab · `Ctrl+Tab` next tab.
|
defaults:
|
||||||
|
|
||||||
(On Macs, function keys may need `fn` — every action also has a toolbar button.)
|
| Command | macOS | Windows / Linux |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **File list** | | |
|
||||||
|
| Move up | `↑` | `↑` |
|
||||||
|
| Move down | `↓` | `↓` |
|
||||||
|
| Page up | `PgUp` | `PgUp` |
|
||||||
|
| Page down | `PgDn` | `PgDn` |
|
||||||
|
| First item | `Home` | `Home` |
|
||||||
|
| Last item | `End` | `End` |
|
||||||
|
| Select / deselect | `Space` | `Space` `Ins` |
|
||||||
|
| Select all | `⌘A` | `Ctrl+A` |
|
||||||
|
| Open | `↩` `⌘↓` | `Enter` |
|
||||||
|
| Parent folder | `⌫` `⌘↑` | `Backspace` `Alt+↑` |
|
||||||
|
| Switch panel | `⇥` `⇧⇥` | `Tab` `Shift+Tab` |
|
||||||
|
| Show the item's menu | `⇧F10` `⌃↩` | `Shift+F10` `Menu` |
|
||||||
|
| **Files** | | |
|
||||||
|
| Send to the other panel | `F5` | `F5` |
|
||||||
|
| Edit | `⌘E` `F4` | `F4` `Ctrl+E` |
|
||||||
|
| Rename | `F2` | `F2` |
|
||||||
|
| New folder | `⇧⌘N` `F7` | `F7` `Ctrl+Shift+N` |
|
||||||
|
| New file | `⌘N` `⇧F4` | `Shift+F4` `Ctrl+N` |
|
||||||
|
| Permissions | `⌘I` | `Ctrl+I` |
|
||||||
|
| Delete | `F8` `⌦` | `F8` `Del` |
|
||||||
|
| Move to Trash | `⌘⌫` | `Ctrl+Del` |
|
||||||
|
| Copy path | `⌥⌘C` | `Ctrl+Shift+C` |
|
||||||
|
| Reveal in file manager | `⌥⌘R` | `Alt+Shift+R` |
|
||||||
|
| Open in default app | `⌘O` | `Ctrl+O` |
|
||||||
|
| Refresh | `⌘R` | `Ctrl+R` |
|
||||||
|
| Go to path | `⌘L` | `Ctrl+L` |
|
||||||
|
| Choose what the panel shows | `⌘K` | `Ctrl+K` |
|
||||||
|
| **Sorting** | | |
|
||||||
|
| Sort by name | `⌥1` | `Alt+1` |
|
||||||
|
| Sort by size | `⌥2` | `Alt+2` |
|
||||||
|
| Sort by modified | `⌥3` | `Alt+3` |
|
||||||
|
| Sort by kind | `⌥4` | `Alt+4` |
|
||||||
|
| Sort by rights | `⌥5` | `Alt+5` |
|
||||||
|
| Sort by owner | `⌥6` | `Alt+6` |
|
||||||
|
| Sort by group | `⌥7` | `Alt+7` |
|
||||||
|
| **Tabs** | | |
|
||||||
|
| New tab | `⌘T` | `Ctrl+T` |
|
||||||
|
| Close tab | `⌘W` | `Ctrl+W` |
|
||||||
|
| Close other tabs | `⌥⌘W` | `Ctrl+Shift+W` |
|
||||||
|
| Close tabs to the right | — | — |
|
||||||
|
| Next tab | `⌃⇥` `⇧⌘]` | `Ctrl+Tab` `Ctrl+PgDn` |
|
||||||
|
| Previous tab | `⌃⇧⇥` `⇧⌘[` | `Ctrl+Shift+Tab` `Ctrl+PgUp` |
|
||||||
|
| Go to tab 1 | `⌘1` | `Ctrl+1` |
|
||||||
|
| Go to tab 2 | `⌘2` | `Ctrl+2` |
|
||||||
|
| Go to tab 3 | `⌘3` | `Ctrl+3` |
|
||||||
|
| Go to tab 4 | `⌘4` | `Ctrl+4` |
|
||||||
|
| Go to tab 5 | `⌘5` | `Ctrl+5` |
|
||||||
|
| Go to tab 6 | `⌘6` | `Ctrl+6` |
|
||||||
|
| Go to tab 7 | `⌘7` | `Ctrl+7` |
|
||||||
|
| Go to tab 8 | `⌘8` | `Ctrl+8` |
|
||||||
|
| Go to last tab | `⌘9` | `Ctrl+9` |
|
||||||
|
| Move tab left | `⌃⇧PgUp` | `Ctrl+Shift+PgUp` |
|
||||||
|
| Move tab right | `⌃⇧PgDn` | `Ctrl+Shift+PgDn` |
|
||||||
|
| Disconnect tab | `⌘D` | `Ctrl+D` |
|
||||||
|
| **Window** | | |
|
||||||
|
| Settings | `⌘,` | `Ctrl+,` |
|
||||||
|
| Connection manager | `⇧⌘K` | `Ctrl+Shift+K` |
|
||||||
|
| Show / hide transfers | `⌘J` | `Ctrl+J` |
|
||||||
|
| Install update | — | — |
|
||||||
|
| Bigger interface | `⌘=` `⇧⌘=` `⌘Num +` | `Ctrl+=` `Ctrl+Shift+=` `Ctrl+Num +` |
|
||||||
|
| Smaller interface | `⌘-` | `Ctrl+-` |
|
||||||
|
| Interface at 100% | `⌘0` | `Ctrl+0` |
|
||||||
|
| Close window | `⌘W` | `Ctrl+W` |
|
||||||
|
| **Editor** | | |
|
||||||
|
| Save | `⌘S` | `Ctrl+S` |
|
||||||
|
| Find | `⌘F` | `Ctrl+F` |
|
||||||
|
| Find next | `⌘G` | `F3` |
|
||||||
|
| Find previous | `⇧⌘G` | `Shift+F3` |
|
||||||
|
| Wrap lines | `⌥Z` | `Alt+Z` |
|
||||||
|
| **Connection manager** | | |
|
||||||
|
| New connection | `⌘N` | `Ctrl+N` |
|
||||||
|
| New stack | `⇧⌘N` | `Ctrl+Shift+N` |
|
||||||
|
| Save connection | `⌘S` | `Ctrl+S` |
|
||||||
|
| Connect / open stack | `⌘↩` | `Ctrl+Enter` |
|
||||||
|
| Delete connection or stack | `⌘⌫` | `Del` |
|
||||||
|
| Rename stack | `F2` | `F2` |
|
||||||
|
| Import from WinSCP | `⌘O` | `Ctrl+O` |
|
||||||
|
|
||||||
|
**Fixed keys** (not remappable): Esc closes menus, dialogs and the find bar —
|
||||||
|
in the file list it clears the filter, then cancels a connect, then clears the
|
||||||
|
selection. Enter / Esc confirm / cancel dialogs and Tab walks their controls
|
||||||
|
(on macOS too, without the system's Keyboard navigation setting). Typing in the
|
||||||
|
file list filters it (Backspace trims); Shift + any movement key selects the
|
||||||
|
rows the cursor passes. ↑ ↓ Home End Enter work in every menu.
|
||||||
|
|
||||||
|
(On Macs, function keys may need `fn` — every F-key command also has a ⌘ key
|
||||||
|
or a toolbar button.)
|
||||||
|
|
||||||
## Same-file protection
|
## Same-file protection
|
||||||
|
|
||||||
@@ -220,7 +312,7 @@ 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**
|
Right-click menus are **HTML, on every platform**
|
||||||
([ui/src/app/context-menu.ts](ui/src/app/context-menu.ts)) rather than native —
|
([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
|
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
|
implementation beats two that drift. Only reveal / open-with-default / copy-path
|
||||||
cross to the backend, through the `shellItemAction` RPC.
|
cross to the backend, through the `shellItemAction` RPC.
|
||||||
@@ -232,11 +324,12 @@ electrobun APIs the app uses are implemented on Windows; known issues are
|
|||||||
upstream:
|
upstream:
|
||||||
|
|
||||||
- **HiDPI blur** ([electrobun#324](https://github.com/blackboardsh/electrobun/issues/324)):
|
- **HiDPI blur** ([electrobun#324](https://github.com/blackboardsh/electrobun/issues/324)):
|
||||||
WebView2 renders at 1x on scaled displays until upstream adds DPI awareness.
|
WebView2 rendered at 1x on scaled displays on Electrobun 1.18; 2.x ships
|
||||||
|
per-monitor DPI setup — to re-check on the first 2.x Windows build.
|
||||||
- **Notifications** use legacy balloon style, not Windows toast.
|
- **Notifications** use legacy balloon style, not Windows toast.
|
||||||
- **Unsigned binaries**: both the Setup.exe installer and the portable exe
|
- **Unsigned binaries**: both the Setup.exe installer and the portable exe
|
||||||
trigger SmartScreen/Mark-of-the-Web warnings on first run. No Start Menu
|
trigger SmartScreen/Mark-of-the-Web warnings on first run. The installer
|
||||||
entry or uninstaller (installer unpacks to `%LOCALAPPDATA%`).
|
unpacks to `%LOCALAPPDATA%` and, since Electrobun 2.x, ships an uninstaller.
|
||||||
- `connections.json` is protected with an `icacls` current-user ACL instead of
|
- `connections.json` is protected with an `icacls` current-user ACL instead of
|
||||||
POSIX 0600 (which is meaningless on Windows).
|
POSIX 0600 (which is meaningless on Windows).
|
||||||
|
|
||||||
@@ -248,7 +341,8 @@ portable zip runs from any folder.
|
|||||||
|
|
||||||
## Linux (beta)
|
## Linux (beta)
|
||||||
|
|
||||||
Linux x64 is supported as a **beta** target and builds a portable tarball.
|
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
|
One hard requirement: the AppIndicator library listed under
|
||||||
[Prerequisites](#prerequisites) — electrobun links it for the tray, so without
|
[Prerequisites](#prerequisites) — electrobun links it for the tray, so without
|
||||||
it the app aborts at startup with `ERR_DLOPEN_FAILED` rather than merely losing
|
it the app aborts at startup with `ERR_DLOPEN_FAILED` rather than merely losing
|
||||||
@@ -259,12 +353,12 @@ its tray icon.
|
|||||||
HTML, so nothing is missing in practice.
|
HTML, so nothing is missing in practice.
|
||||||
- **Tray visibility** depends on the desktop: KDE, XFCE, MATE and Cinnamon show
|
- **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.
|
it; GNOME 3.26+ needs the AppIndicator extension. The app works either way.
|
||||||
- **Unsigned and unpackaged**: no AppImage, `.deb`, desktop entry or icon
|
- **Unsigned**, and no AppImage or `.deb` — unpack the `-Setup.tar.gz` and run
|
||||||
installation — unpack the tarball and run `miniSCP.sh`.
|
its `installer`.
|
||||||
- **NVIDIA GPUs** need `WEBKIT_DISABLE_DMABUF_RENDERER=1` or the window renders
|
- **NVIDIA GPUs** need `WEBKIT_DISABLE_DMABUF_RENDERER=1` or the window renders
|
||||||
black. Every launch path (dev scripts and the packaged `miniSCP.sh` wrapper)
|
black. The dev scripts set it automatically when an NVIDIA driver is
|
||||||
sets it automatically when an NVIDIA driver is detected — see
|
detected; an installed build needs it set by hand (e.g. in its desktop
|
||||||
[gotchas](docs/gotchas.md#linux) if you add a new way to launch the app.
|
entry) — see [gotchas](docs/gotchas.md#linux).
|
||||||
|
|
||||||
Full dependency table, diagnostics and the first-boot smoke test:
|
Full dependency table, diagnostics and the first-boot smoke test:
|
||||||
[platform notes](docs/platform-notes.md).
|
[platform notes](docs/platform-notes.md).
|
||||||
@@ -272,17 +366,17 @@ Full dependency table, diagnostics and the first-boot smoke test:
|
|||||||
## 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, Linux desktop integration (AppImage/.deb/.desktop entry —
|
across restarts, Linux AppImage/.deb packages (the release is Electrobun's
|
||||||
the release is a portable tarball).
|
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",
|
||||||
},
|
},
|
||||||
@@ -21,13 +21,12 @@
|
|||||||
"name": "ui",
|
"name": "ui",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7.css": "^0.21.1",
|
|
||||||
"@angular/common": "^22.0.0",
|
"@angular/common": "^22.0.0",
|
||||||
"@angular/compiler": "^22.0.0",
|
"@angular/compiler": "^22.0.0",
|
||||||
"@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",
|
||||||
},
|
},
|
||||||
@@ -46,8 +45,6 @@
|
|||||||
"@parcel/watcher",
|
"@parcel/watcher",
|
||||||
],
|
],
|
||||||
"packages": {
|
"packages": {
|
||||||
"7.css": ["7.css@0.21.1", "", {}, "sha512-Q+/aFUpGemoR/Zltj4a7yvHonyxfb+9cgiwBL+j/Lm+H28EOoLzWoJki7MdGPV+3mLCvKSCVqr3M2x2uNYyeWw=="],
|
|
||||||
|
|
||||||
"@algolia/abtesting": ["@algolia/abtesting@1.18.0", "", { "dependencies": { "@algolia/client-common": "5.52.0", "@algolia/requester-browser-xhr": "5.52.0", "@algolia/requester-fetch": "5.52.0", "@algolia/requester-node-http": "5.52.0" } }, "sha512-8siuLG+FIns1AjZ/g2SDVwHz9S+ObacDQISEJvS8XsNei1zl3FXqfqQrBpmrG7ACWCyesXHbicMJtvRbg00FEw=="],
|
"@algolia/abtesting": ["@algolia/abtesting@1.18.0", "", { "dependencies": { "@algolia/client-common": "5.52.0", "@algolia/requester-browser-xhr": "5.52.0", "@algolia/requester-fetch": "5.52.0", "@algolia/requester-node-http": "5.52.0" } }, "sha512-8siuLG+FIns1AjZ/g2SDVwHz9S+ObacDQISEJvS8XsNei1zl3FXqfqQrBpmrG7ACWCyesXHbicMJtvRbg00FEw=="],
|
||||||
|
|
||||||
"@algolia/client-abtesting": ["@algolia/client-abtesting@5.52.0", "", { "dependencies": { "@algolia/client-common": "5.52.0", "@algolia/requester-browser-xhr": "5.52.0", "@algolia/requester-fetch": "5.52.0", "@algolia/requester-node-http": "5.52.0" } }, "sha512-wtwPgyPmO7b7sQPVgoK29c1VpfS08DnnJCmxX/oU1pV2DlMRJCzQcLN7JSloYpodyKHwM8+9wOzlAM0co3TDmA=="],
|
"@algolia/client-abtesting": ["@algolia/client-abtesting@5.52.0", "", { "dependencies": { "@algolia/client-common": "5.52.0", "@algolia/requester-browser-xhr": "5.52.0", "@algolia/requester-fetch": "5.52.0", "@algolia/requester-node-http": "5.52.0" } }, "sha512-wtwPgyPmO7b7sQPVgoK29c1VpfS08DnnJCmxX/oU1pV2DlMRJCzQcLN7JSloYpodyKHwM8+9wOzlAM0co3TDmA=="],
|
||||||
@@ -136,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=="],
|
||||||
@@ -256,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=="],
|
||||||
@@ -310,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=="],
|
||||||
|
|
||||||
@@ -478,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=="],
|
||||||
@@ -500,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=="],
|
||||||
|
|
||||||
@@ -516,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=="],
|
||||||
@@ -586,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=="],
|
||||||
@@ -614,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=="],
|
||||||
|
|
||||||
@@ -642,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=="],
|
||||||
@@ -698,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=="],
|
||||||
@@ -740,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=="],
|
||||||
@@ -754,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=="],
|
||||||
@@ -810,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=="],
|
||||||
|
|
||||||
@@ -862,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=="],
|
||||||
@@ -906,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=="],
|
||||||
@@ -948,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=="],
|
||||||
@@ -1044,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=="],
|
||||||
@@ -1108,10 +1061,10 @@
|
|||||||
|
|
||||||
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
|
||||||
|
|
||||||
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
|
"@npmcli/agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||||
|
|
||||||
"@npmcli/agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
"@npmcli/agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||||
|
|
||||||
"@npmcli/agent/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
|
"@npmcli/agent/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
|
||||||
@@ -1148,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=="],
|
||||||
@@ -1168,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=="],
|
||||||
@@ -1190,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=="],
|
||||||
|
|||||||
+314
-295
@@ -6,15 +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 / WebKitGTK on Linux. Zero privileged access — everything
|
WebView2 on Windows / WebKitGTK on Linux. Zero privileged access — everything
|
||||||
goes through RPC.
|
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
|
||||||
|
|
||||||
@@ -30,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
|
||||||
|
|
||||||
@@ -39,22 +45,22 @@ 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, settings, ctx?)` injects
|
`src/bun/windows.ts` — `buildPreload(mode, settings, ctx?)` injects
|
||||||
`__APP_PLATFORM`, `__APP_MODE` (`main|editor|manager`), `__APP_SKIN`
|
`__APP_PLATFORM`, `__APP_MODE` (`main|editor|manager|settings`), `__APP_CTX`
|
||||||
(`modern|win7`), `__APP_CTX` before any page script. `ui/src/main.ts` selects on
|
before any page script, plus the persisted theme attributes and interface
|
||||||
BOTH axes: the skin picks a component tree, `APP_MODE` picks a root within it
|
scale so the first paint is already right. `ui/src/main.ts` picks the root
|
||||||
(see "UI layout" below). All roots use selector `app-root` (index.html hosts
|
component from `APP_MODE`; all four roots use selector `app-root` (index.html
|
||||||
only that tag). Both globals are preload-baked because both are needed before
|
hosts only that tag). `chromeOptions()` is the ONLY place the native frame is
|
||||||
first paint — settings arrive over an async RPC, far too late to choose a tree
|
decided, spread by the main window and `openSecondaryWindow`: macOS `hiddenInset` +
|
||||||
without flashing the wrong UI. `chromeOptions(skin)` is the ONLY place the
|
`trafficLightOffset {x:12,y:4}` (2.x semantics — see gotchas; the UI renders an
|
||||||
native frame is decided: win7 is frameless + transparent everywhere, modern
|
`h-10 pl-24` drag-region header via `IS_MAC_CHROME`); other platforms keep the
|
||||||
keeps the native titlebar (macOS `hiddenInset` + `trafficLightOffset
|
native titlebar and a compact `h-9` non-drag header.
|
||||||
{x:12,y:13}`, where the shadlike tree renders an `h-10 pl-24` drag-region header
|
|
||||||
via `IS_MAC_CHROME`).
|
|
||||||
|
|
||||||
`WindowRegistry<Rec>` (same file): keyed window map with dedupe/activate,
|
`WindowRegistry<Rec>` (same file): keyed window map with dedupe/activate,
|
||||||
cascade placement off an anchor frame, focus tracking. electrobun's emitter
|
cascade placement off an anchor frame, focus tracking. electrobun's emitter
|
||||||
@@ -62,18 +68,22 @@ has **no `off()`** — `track()` closures capture only `(this, key)` so closed
|
|||||||
records can be garbage collected. A future "window follows window" grouping
|
records can be garbage collected. A future "window follows window" grouping
|
||||||
belongs here (`anchor.on('move')` + `setPosition`).
|
belongs here (`anchor.on('move')` + `setPosition`).
|
||||||
|
|
||||||
### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/skins/*/editor-window/`)
|
### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/skins/shadlike/editor-window/`)
|
||||||
|
|
||||||
One window per file, key `sessionId+path` (sessionId, not fsKey: saves route
|
One window per file, key `sessionId+path` (sessionId, not fsKey: saves route
|
||||||
through a concrete session, and `local` is one shared session anyway).
|
through a concrete session, and `local` is one shared session anyway).
|
||||||
Re-opening focuses the existing window. The webview fetches its own content
|
Re-opening focuses the existing window. The webview fetches its own content
|
||||||
(`readFile`) — `openEditor` just creates the window with ctx in the preload.
|
(`readFile`) — `openEditor` just creates the window with ctx in the preload.
|
||||||
|
|
||||||
**Close safety** (electrobun window `close` is NOT cancellable — verified, no
|
**Close safety** (designed when electrobun window `close` was NOT cancellable —
|
||||||
`allow` response like `before-quit` has):
|
1.18.1 had no `allow` response like `before-quit`; 2.x adds a cancellable
|
||||||
1. ⌘W (routed via the app menu → `editorCloseRequest`) / Close button →
|
`will-close` for the titlebar close, not used yet — see gotchas):
|
||||||
styled in-window Save/Don't Save/Cancel; the confirm path calls
|
1. ⌘W (the page's own `close-window` key; a ⌘W it doesn't claim falls through
|
||||||
`editorClose` which sets `suppressPrompt` before `win.close()`.
|
to the app menu, which bun routes back as `editorCloseRequest`) / Close
|
||||||
|
button → styled in-window Save/Don't Save/Cancel, which takes focus (Save;
|
||||||
|
⌘S = Save, Esc = Cancel, Tab cycles) so keys can't edit the text behind it;
|
||||||
|
the confirm path calls `editorClose` which sets `suppressPrompt` before
|
||||||
|
`win.close()`.
|
||||||
2. Traffic-light close → the window is already gone, but the editor mirrored
|
2. Traffic-light close → the window is already gone, but the editor mirrored
|
||||||
its draft to bun (`editorState`, 400 ms debounce + flush on blur/dirty-flip)
|
its draft to bun (`editorState`, 400 ms debounce + flush on blur/dirty-flip)
|
||||||
→ native Save/Discard box from the held draft. A failed save (dead session)
|
→ native Save/Discard box from the held draft. A failed save (dead session)
|
||||||
@@ -89,12 +99,18 @@ to the main window (panels showing that directory refresh). Editor upgrades:
|
|||||||
scroll-synced line gutter (hidden when wrap is on — wrapped line numbers would
|
scroll-synced line gutter (hidden when wrap is on — wrapped line numbers would
|
||||||
need per-edit height measurement), Alt/⌥Z wrap toggle, custom find bar
|
need per-edit height measurement), Alt/⌥Z wrap toggle, custom find bar
|
||||||
(matches computed in TS, `setSelectionRange` + scrollTop math — deliberately
|
(matches computed in TS, `setSelectionRange` + scrollTop math — deliberately
|
||||||
not `findInPage`, which is a shim on WebView2).
|
not `findInPage`, which is a shim on WebView2). Find next / previous (⌘G /
|
||||||
|
⇧⌘G, F3 / ⇧F3) step from the caret with the bar open or closed — the query
|
||||||
|
survives closing it. Focus after a state change goes through
|
||||||
|
`afterNextRender({ injector })`: under zoneless CD a microtask runs BEFORE the
|
||||||
|
render that creates the `@if` element.
|
||||||
|
|
||||||
### Connection-manager window (`src/bun/manager-window.ts`)
|
### Connection-manager window (`src/bun/manager-window.ts`)
|
||||||
|
|
||||||
Single instance, centered over main, **modal to main only** (editors stay
|
Single instance, centered over main, **modal to main only** (editors stay
|
||||||
usable). No native modality exists, so three layers approximate it: bun
|
usable). No native modality exists (re-checked on 2.0.2: no parent/owner/sheet
|
||||||
|
option or input lock in any SDK or the core's exports; `styleMask.DocModalWindow`
|
||||||
|
is only the cosmetic NSWindow bit), so three layers approximate it: bun
|
||||||
bounces main's `focus` event back to the manager; the main webview renders a
|
bounces main's `focus` event back to the manager; the main webview renders a
|
||||||
click-swallowing overlay (`managerState` message); main drops its keyboard
|
click-swallowing overlay (`managerState` message); main drops its keyboard
|
||||||
shortcuts and bun drops main-targeted menu actions while open. Connect /
|
shortcuts and bun drops main-targeted menu actions while open. Connect /
|
||||||
@@ -105,6 +121,52 @@ main's `ConnectionsService` reloads; a reload-retry in `App.findConnection`
|
|||||||
covers the save→connect race. `setAlwaysOnTop` is deliberately NOT used (it
|
covers the save→connect race. `setAlwaysOnTop` is deliberately NOT used (it
|
||||||
would float over editor windows).
|
would float over editor windows).
|
||||||
|
|
||||||
|
Keyboard (the `manager` keymap scope): ⌘N new connection (focus lands in
|
||||||
|
Name), ⇧⌘N new stack, ⌘S save, ⌘↩ connect — or, on a focused stack row, open
|
||||||
|
it in tabs — ⌘⌫ / Del delete the focused row (with a confirm), F2 rename the
|
||||||
|
focused stack, ⌘O import, ⌘W close. The list's rows are real buttons
|
||||||
|
(`data-row`; `data-stack` / `data-conn` say what they are): ↑ ↓ Home End move
|
||||||
|
FOCUS only — loading a connection would replace a half-edited form without
|
||||||
|
asking — Enter opens the focused connection in the form, ← / → collapse and
|
||||||
|
expand a stack (← on a connection climbs to its stack), and a stack's
|
||||||
|
hover-only actions also show while the keyboard is in its row. Enter in a form
|
||||||
|
field = Connect; Esc leaves a field first, then closes the window. Focus
|
||||||
|
returns where it was after a dialog (`focusin` memory + the FocusService
|
||||||
|
tick) and falls back to the edited connection's row whenever it drops to
|
||||||
|
`<body>`.
|
||||||
|
|
||||||
|
### Settings window (`src/bun/settings-window.ts` + `ui/src/app/skins/shadlike/settings-window/`)
|
||||||
|
|
||||||
|
Single instance, centered over main, **not modal**: every control is a plain
|
||||||
|
`SettingsService` setter, so each edit is an ordinary save that bun
|
||||||
|
rebroadcasts — the other windows follow live while it stays open. Opened by
|
||||||
|
the tab-bar gear and the open-settings key (⌘, / Ctrl+,, `openSettings` from
|
||||||
|
main) and, on macOS, the app menu's "Settings…". Its RPC serves only
|
||||||
|
`getSettings`/`saveSettings`/`pickEditorApp`/`showAbout`/`settingsClose`. Its
|
||||||
|
page claims its own close-window key (`settingsClose`); a ⌘W it doesn't claim
|
||||||
|
falls through to the app menu's Close Tab, and bun checks the Settings
|
||||||
|
window's focus FIRST and closes it (see gotchas). Sidebar sections (↑ / ↓ move
|
||||||
|
between them, focus starts on the current one, Tab is walked by `cycleFocus`):
|
||||||
|
Appearance, Transfers, Editor, File list (visible columns — the column-header
|
||||||
|
right-click menu toggles the same setting — and "Enter / double-click on a
|
||||||
|
file": nothing | open local files in their default app), Keyboard, About
|
||||||
|
(opens the native About box). The native title is `options.title`, retitled on
|
||||||
|
a locale switch.
|
||||||
|
|
||||||
|
**Keyboard** is the remap editor over the shared keymap: rows by group with a
|
||||||
|
search field (label or key text), a chip per combo (× removes), **+** records
|
||||||
|
(the next non-modifier keydown, taken before every other key in the window so
|
||||||
|
⌘W records instead of closing; Esc cancels; a click elsewhere or a blur ends
|
||||||
|
it), ↺ resets a row, Reset all (inline confirm). Reserved combos are refused
|
||||||
|
without `preventDefault` (⌘Q still quits); editor-scope combos must be chords
|
||||||
|
or F-keys (the editor's text area always has focus); a combo owned by an
|
||||||
|
overlapping-scope command offers **Replace** (moves it). Rows flag conflicts
|
||||||
|
and plain character keys on file-list rows (they no longer start a filter).
|
||||||
|
Fixed keys are listed read-only below. Every edit is `ShortcutsService.rebind`
|
||||||
|
→ `settings.keybindings` (only rows that differ from the defaults) → the
|
||||||
|
ordinary save + broadcast, so menus, tooltips and the macOS menu bar follow
|
||||||
|
live.
|
||||||
|
|
||||||
## Bun modules
|
## Bun modules
|
||||||
|
|
||||||
- `fs/provider.ts` — `FileSystemProvider` interface: uniform local/SFTP ops +
|
- `fs/provider.ts` — `FileSystemProvider` interface: uniform local/SFTP ops +
|
||||||
@@ -154,8 +216,16 @@ would float over editor windows).
|
|||||||
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.
|
||||||
- `menu.ts` — macOS-only app menu, rebuilt on every connections change (the
|
- `menu.ts` — macOS-only app menu, rebuilt on every connections change (the
|
||||||
Connect submenu mirrors the store). ⌘W is intercepted by the menu, so bun
|
Connect submenu mirrors the store), language switch and keyboard remap.
|
||||||
routes `close-tab` to the focused editor window first. Labels go through
|
**Accelerators come from the shared keymap** (`effectiveKeymap` +
|
||||||
|
`toAccelerator`, the first combo the native parser can express faithfully:
|
||||||
|
⌘/⌃ chords only — a bare key means ⌘+key to it — no ⌫ / ⌦ / Shift+punctuation);
|
||||||
|
⌘Q stays fixed. The keybindings are read through a getter AFTER the
|
||||||
|
connections load, so a remap saved meanwhile isn't lost. The page claims the
|
||||||
|
keys it handles first, so the menu only runs what falls through; a
|
||||||
|
`close-tab` that still arrives while a Settings or editor window is focused
|
||||||
|
closes THAT window, and other main-targeted actions bring the main window
|
||||||
|
forward first. Labels go through
|
||||||
backend `i18n.t` and the menu is rebuilt on a locale change too.
|
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
|
||||||
@@ -171,39 +241,23 @@ 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 layout: one core, two skin trees
|
## UI layout
|
||||||
|
|
||||||
```
|
```
|
||||||
ui/src/app/
|
ui/src/app/
|
||||||
core/ app.config.ts, icons.ts, platform.ts, format.ts, file-icons.ts,
|
core/ app.config.ts, icons.ts, platform.ts, format.ts, file-icons.ts,
|
||||||
and all 12 *.service.ts — everything skin-agnostic
|
metrics.ts and every *.service.ts — the non-visual layer
|
||||||
skins/
|
skins/shadlike/ the component tree: four window roots (SHAD_ROOTS barrel,
|
||||||
shadlike/ the modern (shadcn-style) tree + shadlike.css
|
picked by main.ts), dialogs, context menu
|
||||||
7like/ the Windows 7 tree + 7like.css + fonts/ + title-bar/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Each skin owns a **complete** component tree, including its own three window
|
Components reach `core/` through the **`@core/*`** tsconfig alias (alongside
|
||||||
roots, and exports a barrel (`SHAD_ROOTS` / `SEVEN_ROOTS`) with the same
|
`@shared/*` → `src/shared`), which is deliberate: components sit at varying
|
||||||
`{ main, manager, editor }` shape. `main.ts` therefore knows nothing about
|
depths and an alias keeps their imports identical wherever a file moves. Do not
|
||||||
either tree's internals: `APP_SKIN` picks the barrel, `APP_MODE` picks the root.
|
replace it with relative paths.
|
||||||
Both trees are imported statically into one bundle — the inactive skin costs
|
|
||||||
bundle size but no runtime work, which keeps bootstrap synchronous.
|
|
||||||
|
|
||||||
Duplicate selectors across trees (`app-file-panel` exists in both) are safe:
|
**Where a new rule goes**: behaviour → `core/`; markup and appearance → the
|
||||||
standalone components resolve through their importer's `imports:` array, never
|
component; tokens → `ui/src/styles.css`.
|
||||||
a global registry, and the two trees are never mounted together.
|
|
||||||
|
|
||||||
Only `7like` has a `title-bar/`. The modern skin defers to the native frame,
|
|
||||||
which is exactly what `IS_MAC_CHROME` accounts for.
|
|
||||||
|
|
||||||
**Why this split was cheap**: the service layer was already skin-agnostic —
|
|
||||||
`git diff main...feat/7css-skin` touched none of it. Components reach it through
|
|
||||||
the **`@core/*`** tsconfig alias (alongside `@shared/*` → `src/shared`), which
|
|
||||||
is deliberate: skin 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**: shared behaviour → `core/`; markup and appearance for
|
|
||||||
one look → that skin's folder; tokens both skins consume → `ui/src/styles.css`.
|
|
||||||
|
|
||||||
## UI services (`ui/src/app/core/`)
|
## UI services (`ui/src/app/core/`)
|
||||||
|
|
||||||
@@ -211,8 +265,9 @@ one look → that skin's folder; tokens both skins consume → `ui/src/styles.cs
|
|||||||
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
|
||||||
@@ -235,56 +290,106 @@ one look → that skin's folder; tokens both skins consume → `ui/src/styles.cs
|
|||||||
enqueue and runs the collision flow (same-path auto-skip, type-mismatch
|
enqueue and runs the collision flow (same-path auto-skip, type-mismatch
|
||||||
skip, overwrite dialog with all/none batch modes).
|
skip, overwrite dialog with all/none batch modes).
|
||||||
- `settings.service.ts` — one signal per setting; **imperative apply pattern**
|
- `settings.service.ts` — one signal per setting; **imperative apply pattern**
|
||||||
(`applyTheme/DarkTone/Accent/TextSize` set `data-*` attributes on
|
(`applyTheme/DarkTone` set `data-*` attributes on `<html>`;
|
||||||
`<html>`); `applyAll` also serves the `settingsChanged` broadcast so editor
|
`applyUiScale` sets its inline `font-size`); `applyAll` also serves the
|
||||||
and manager windows restyle live (they render no settings UI, so no
|
`settingsChanged` broadcast, so every window follows a save made in any
|
||||||
persist echo). New setting = five touches here + types.ts (backend merges
|
other. Every window RPC serves `saveSettings` (editor and manager windows
|
||||||
automatically).
|
save too — the scale shortcuts — and the Settings window is nothing but
|
||||||
|
saves); bun's `settingsSaved()` in index.ts runs the
|
||||||
|
side effects once and rebroadcasts to every window **except the sender**, so
|
||||||
|
there is no echo to fight a held Cmd+. `applyAll` never persists. Saves are
|
||||||
|
whole-object, so **nothing persists until the first `getSettings` landed**
|
||||||
|
(`loaded`): a ⌘+ pressed while a window opens would otherwise write the
|
||||||
|
defaults over every remap. New setting = five touches here + types.ts
|
||||||
|
(backend merges automatically; sanitize hand-editable shapes in
|
||||||
|
`SettingsStore.load`).
|
||||||
- `focus.service.ts` — dependency-free `tick`/`bump()`; dialogs bump it, the
|
- `focus.service.ts` — dependency-free `tick`/`bump()`; dialogs bump it, the
|
||||||
main window's panels listen. Exists so dialogs don't inject PanelsService
|
main window's panels listen — but a panel only takes focus while NO dialog or
|
||||||
(whose constructor spawns a workspace — fatal in secondary windows).
|
menu is up (a run of overwrite questions reuses one dialog instance; focusing
|
||||||
|
the list behind it handed Enter/Delete to the panel). Exists so dialogs don't
|
||||||
|
inject PanelsService (whose constructor spawns a workspace — fatal in
|
||||||
|
secondary windows).
|
||||||
|
- `shortcuts.service.ts` — the keyboard layer over `src/shared/keymap.ts`; see
|
||||||
|
"Keyboard" below.
|
||||||
|
- `focus-cycle.ts` — `cycleFocus(root, event)`: Tab / ⇧Tab by hand (see
|
||||||
|
"Keyboard").
|
||||||
- `connections.service.ts` — store mirror + ssh-config hosts; reloads on the
|
- `connections.service.ts` — store mirror + ssh-config hosts; reloads on the
|
||||||
`connectionsChanged` push.
|
`connectionsChanged` push.
|
||||||
- `i18n.service.ts` — runtime translator. `locale` signal + `t('ns.key', params)`
|
- `i18n.service.ts` — runtime translator. `locale` signal + `t('ns.key', params)`
|
||||||
(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` — **one file-command dispatcher**,
|
||||||
against an explicit panel; `menuTarget` remembers the right-clicked row.
|
`run(id, { side, panel, entry })`, for every entry point: list keys (the
|
||||||
- `context-menu.service.ts` + `context-menu.ts` — the panel and tab right-click
|
cursor row), row-menu picks (the right-clicked row, captured in the menu's
|
||||||
|
`onPick` closure), the macOS menu bar's File/View items and the panel
|
||||||
|
toolbar. Ids are keymap `FileCommand`s (`open`, `open-default`, `send`,
|
||||||
|
`edit`, `rename`, `new-folder`, `new-file`, `chmod`, `delete`, `trash`,
|
||||||
|
`copy-path`, `reveal`, `refresh`, `sort-*`). Selection if any, else the
|
||||||
|
target row; `..` stands for the folder. `open` enters folders and opens a
|
||||||
|
LOCAL file in its default app only when `openFilesOnEnter` is set (Settings
|
||||||
|
→ File list); `open-default` (the menu's Open on a local file) always does.
|
||||||
|
`trash` walks the selection one `moveToTrash` at a time. Main-window only
|
||||||
|
(it injects TransfersService).
|
||||||
|
- `context-menu.service.ts` + `skins/shadlike/context-menu.ts` — the panel and tab right-click
|
||||||
menus, in HTML. Electrobun's native `showContextMenu` is a no-op on Linux, so it was
|
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
|
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,
|
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
|
the header column picker, and the panel **source picker** — Local, drives,
|
||||||
component renders it at the pointer with edge-flip clamping, arrow/Enter/Escape
|
connections under per-stack `header` items, `~/.ssh/config` hosts — which
|
||||||
keys, and close-on-blur/resize/scroll. Picked items are emitted verbatim as
|
replaced a bespoke popover so it gets the same keys and look) and holds the
|
||||||
`ctx-*` / `col:*` / `tab:*` strings through `RpcService.emitMenuAction`, so they
|
open request as a signal; the component renders it at a point
|
||||||
land in the SAME `App.handleMenuAction` dispatcher the macOS app menubar uses —
|
(`MenuPoint`: a MouseEvent, or a row rect for a keyboard-opened menu) with
|
||||||
one handler, two sources. The three shell actions the webview cannot perform
|
edge-flip clamping, a scrolling clamped box, and close-on-blur/resize/scroll.
|
||||||
(reveal, open-with-default, copy path) go back to bun via the `shellItemAction`
|
Items carry `shortcut` (a keymap id: its first key is the soft hint at 60%
|
||||||
RPC, which is all that remains of the old native menu.
|
of the row colour, and pressing it while the menu is open picks the item)
|
||||||
|
and `icon` (drawn in the check column; only the everyday verbs get one —
|
||||||
|
Send, Edit, Rename, Delete, New Folder, New/Close Tab, the toolbar's own
|
||||||
|
glyphs). Keys: ↑↓ Home End move the one highlight (mouse hover shares it),
|
||||||
|
Enter/Space pick, Esc closes, items are not Tab stops; every handled key
|
||||||
|
stops propagation so the window handler never runs it twice. A keyboard-opened
|
||||||
|
menu starts with its first item highlighted (`initialIndex`). A request's
|
||||||
|
`onPick` takes the pick (row menu → `FileActionsService.run`, source picker →
|
||||||
|
`FilePanel`); otherwise `col:*` / `tab:*` strings go through
|
||||||
|
`RpcService.emitMenuAction` to the SAME `App.handleMenuAction` dispatcher the
|
||||||
|
macOS app menubar uses. The three shell actions the webview cannot perform
|
||||||
|
(reveal, open-with-default, copy path) go back to bun via the
|
||||||
|
`shellItemAction` RPC, which is all that remains of the old native menu.
|
||||||
`tabItems()` builds the workspace-tab menu (`tab:new`, and
|
`tabItems()` builds the workspace-tab menu (`tab:new`, and
|
||||||
`tab:close|close-others|close-right|disconnect:<id>`), served by
|
`tab:close|close-others|close-right|disconnect:<id>`), served by
|
||||||
`PanelsService.closeOtherWorkspaces` / `closeWorkspacesToRight` /
|
`PanelsService.closeOtherWorkspaces` / `closeWorkspacesToRight` /
|
||||||
`disconnectWorkspace`. **The target id travels inside the action string**: the
|
`disconnectWorkspace`. **The target id travels inside the action string**: the
|
||||||
right-clicked tab is not necessarily the active one, so nothing on that path
|
right-clicked tab is not necessarily the active one, so nothing on that path
|
||||||
may fall back to `activeWorkspaceId`. Illegal entries are `disabled`, not
|
may fall back to `activeWorkspaceId`. Illegal entries are `disabled`, not
|
||||||
omitted — Win7 greys out, and a menu whose items move between right-clicks is
|
omitted — a menu whose items move between right-clicks is worse than one with
|
||||||
worse than one with a dead row. 7.css hangs that state on
|
a dead row. The disabled styling (`pointer-events: none`) only stops the
|
||||||
`li[role=menuitem][aria-disabled]` (opacity + `pointer-events: none`), which
|
pointer; the keyboard path never consults it — so `pick()` and the arrow-key
|
||||||
the keyboard path never consults — so `pick()` and the arrow-key walker check
|
walker check the flag themselves.
|
||||||
the flag themselves, in both skins.
|
|
||||||
- `file-icons.ts` — extension → category map driving glyphs, colors
|
- `file-icons.ts` — extension → category map driving glyphs, colors
|
||||||
(fixed `--icon-*` tokens, never accent-tinted) and the Kind column.
|
(fixed `--icon-*` tokens from Oxide's hue scales) and the Kind column.
|
||||||
|
- `icons.ts` — the one icon registry, passed to `provideIcons` in `app.config.ts`.
|
||||||
|
A name is `<icon><px>`: the pixel grid its glyph is drawn for, which must match
|
||||||
|
the size token where it renders (`server14` at `--icon-size-sm`, `server16` at
|
||||||
|
`-md`, `server24` at `-xl`). The one exception is `tdesignLoading`, which spins.
|
||||||
|
Every glyph is TDesign's shape redrawn on its pixel grid in `pixel-icons.ts`,
|
||||||
|
as whole-pixel runs plus 1.25px slants (rules in gotchas). Only `closeCircle24`
|
||||||
|
(TDesign's own 24px grid) and the spinner still come from
|
||||||
|
`@ng-icons/tdesign-icons`. Templates can't type-check
|
||||||
|
icon names, and an unregistered one renders nothing (a console warning only), so
|
||||||
|
`icons.spec.ts` checks every `<ng-icon>` in the skins. TS code that picks an icon
|
||||||
|
takes the `Icon14` / `Icon16` types.
|
||||||
- `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`,
|
- `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`,
|
||||||
`EDITOR_CONTEXT`, `IS_MAC`/`IS_MAC_CHROME`, `IS_WINDOWS`, `IS_LINUX`,
|
`EDITOR_CONTEXT`, `IS_MAC`/`IS_MAC_CHROME`, `IS_WINDOWS`, `IS_LINUX`) +
|
||||||
`MOD_LABEL`/`ALT_LABEL`) + `blockNativeContextMenu` (kills WebKit's
|
`blockNativeContextMenu` (kills WebKit's Reload/Inspect menu outside text
|
||||||
Reload/Inspect menu outside text fields — the app draws its own menu instead).
|
fields — the app draws its own menu instead) + `blockReloadKeys` (swallows
|
||||||
|
unclaimed F5 / Ctrl+R / Ctrl+F5 / Ctrl+Shift+R on Windows, where WebView2
|
||||||
|
would reload the page and drop every session; each window root calls it
|
||||||
|
last).
|
||||||
- `updater.service.ts` — auto-updater state for the tab-bar pill. Signals
|
- `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
|
||||||
@@ -294,219 +399,133 @@ one look → that skin's folder; tokens both skins consume → `ui/src/styles.cs
|
|||||||
`src/bun/index.ts`; feed is `release.baseUrl` (`https://miniscp.com/dl`, the
|
`src/bun/index.ts`; feed is `release.baseUrl` (`https://miniscp.com/dl`, the
|
||||||
`/dl` host in `site/`). See build-and-release.md "Auto-updater".
|
`/dl` host in `site/`). See build-and-release.md "Auto-updater".
|
||||||
|
|
||||||
|
## Keyboard
|
||||||
|
|
||||||
|
**One keymap** — `src/shared/keymap.ts`, shared so the webviews and bun (the
|
||||||
|
macOS menu bar) read the same table. `BINDINGS` lists every command: `id`,
|
||||||
|
`group` (Settings sections), `scopes`, and default combos per platform
|
||||||
|
(`mac` / `other`, best first: the first is the hint menus and tooltips show).
|
||||||
|
Combos are platform-neutral strings (`Mod+Shift+N`: Mod = ⌘ on macOS, Ctrl
|
||||||
|
elsewhere; `Ctrl` a literal ⌃ only macOS tells apart). Letters match the
|
||||||
|
layout's character (AZERTY's A is A) and fall back to the physical key under
|
||||||
|
Alt (⌥Z types Ω) and on non-Latin layouts; digits match physically (AZERTY
|
||||||
|
types "&" on 1); punctuation by character first (AZERTY's comma is on the M
|
||||||
|
key), physical second (⇧] makes "}"). **Matching is exact**: ⌃F is not ⌘F,
|
||||||
|
Ctrl+Tab is not Tab, and neither the Windows key nor AltGr is ever part of a
|
||||||
|
binding. `effectiveKeymap(overrides)` applies `AppSettings.keybindings`
|
||||||
|
(rows that differ from the defaults; `[]` unbinds) and drops anything
|
||||||
|
malformed. `conflicts()` / `ownerOf()` work over overlapping scopes; the
|
||||||
|
spec (`keymap.spec.ts`) proves the defaults conflict-free, round-trippable and
|
||||||
|
unshadowed on both platforms.
|
||||||
|
|
||||||
|
**Scopes.** `panel` = the focused file list (navigation, file commands,
|
||||||
|
sorting); `main` = anywhere in the main window (tabs, window commands); `editor`
|
||||||
|
/ `manager` / `settings` per window; `any` = every window (interface scale).
|
||||||
|
`panel` sits inside `main`, so they conflict-check together.
|
||||||
|
|
||||||
|
**`ShortcutsService`** resolves keydowns against the effective keymap
|
||||||
|
(reactive: a remap applies live in every window via the settings broadcast) and
|
||||||
|
formats hints — `label(id)` / `labels(id)` / `tip(text, id)` /
|
||||||
|
`tipFixed(text, 'Shift+Enter')`, with **localized key names** off macOS
|
||||||
|
(`keys.names.*`: German says Strg+Entf). `claim()` = resolve + `preventDefault`
|
||||||
|
(so the macOS menu never runs it twice) + auto-repeat only for movement-like
|
||||||
|
commands (holding ⌘W closes one tab). IME composition is never a shortcut.
|
||||||
|
From a text field only `firesInTextField()` combos count: ⌘/Ctrl and Alt chords
|
||||||
|
and F-keys, but not plain keys, not text-editing chords (⌘⌫, ⌥←, ⌘A) and not
|
||||||
|
Ctrl+Alt off macOS (AltGr).
|
||||||
|
|
||||||
|
**Dispatch** (one place per window): an open context menu owns its keys; an
|
||||||
|
open dialog owns Enter / Esc / Tab while the main window swallows window
|
||||||
|
commands and the panel commands the macOS menu bar carries (an unclaimed ⌘T
|
||||||
|
would otherwise reach the menu and open a tab behind the dialog); the focused
|
||||||
|
list (`FilePanel.onKeydown`) runs, in order, the fixed filter-editing keys
|
||||||
|
while a query is typed, the `panel` keymap (Shift + a movement key extends the
|
||||||
|
selection over the rows passed — a fixed rule that follows remaps), lets
|
||||||
|
`main` matches bubble, then type-to-filter, then Esc's fixed chain (clear the
|
||||||
|
filter → cancel a connect → clear the selection); `App` runs `main`. Every root
|
||||||
|
ends with `blockReloadKeys`. Keys that land on `<body>` (a click dropped focus)
|
||||||
|
are forwarded to the active list (`PanelsService.forwardListKey`), and `App`'s
|
||||||
|
`(mousedown)` keeps focus on the list after chrome-button clicks.
|
||||||
|
|
||||||
|
**Remaps** live in `AppSettings.keybindings` (sanitized on load), edited in
|
||||||
|
Settings → Keyboard (see the Settings window). `ShortcutsService.rebind`
|
||||||
|
stores only rows that differ from their defaults.
|
||||||
|
|
||||||
|
**macOS key equivalents are page-first.** WKWebView hands ⌘-keys to the page
|
||||||
|
before the app menu (WebKit `WebViewImpl::performKeyEquivalent`); only keys the
|
||||||
|
page doesn't `preventDefault` reach the menu bar. So the page owns every app
|
||||||
|
shortcut, and a window that doesn't claim a key lets it fall through to the
|
||||||
|
menu (⌘W in a window whose close-window was remapped away still hits Close Tab,
|
||||||
|
which bun routes to the focused Settings / editor window first).
|
||||||
|
|
||||||
|
**Focus without Keyboard navigation.** WKWebView's Tab skips buttons,
|
||||||
|
checkboxes and radios unless the user turned on System Settings → Keyboard →
|
||||||
|
Keyboard navigation, so `cycleFocus(root, event)` walks Tab / ⇧Tab by hand
|
||||||
|
(DOM order, wrap, a radio group is one stop): every dialog (it is also their
|
||||||
|
focus trap), the transfer list (⌘J opens it focused), the editor's save prompt.
|
||||||
|
Dialogs carry `role="dialog" aria-modal`, take initial focus (the chmod dialog
|
||||||
|
on its octal field, Enter applies) and stop Esc from propagating — a
|
||||||
|
window-level Esc (the connection manager closes on it) must not see it too. A
|
||||||
|
base-layer `:focus-visible` ring in `--ring` marks the focused control.
|
||||||
|
|
||||||
|
**Opening a row's menu from the keyboard**: ⇧F10 / the Menu key / ⌃↩ (macOS)
|
||||||
|
open the cursor row's menu under the row, first item highlighted; the
|
||||||
|
selection is kept, its commands act on it like the F-keys do.
|
||||||
|
|
||||||
## Theming
|
## Theming
|
||||||
|
|
||||||
`ui/src/styles.css`: shadcn-style OKLCH tokens; dark values are duplicated in
|
`ui/src/styles.css`: OKLCH tokens adapted from **Oxide's design system**
|
||||||
`:root[data-theme='dark']` AND a `prefers-color-scheme` media block (system
|
(docs.oxide.computer: a cool neutral scale at hue 260 with almost no chroma,
|
||||||
mode) — every dark token change goes in BOTH. 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). **Icon sizes** follow
|
||||||
pixel-identical to the old neutral hover. **Interface scale** (`settings.uiScale`,
|
TDesign's rule (an icon is 2px larger than its text, in whole pixels) through
|
||||||
|
four rem tokens, and every `<ng-icon>` takes one: `--icon-size-sm` 14px (beside
|
||||||
|
11–12px text, small icon buttons), `-md` 16px (13–14px text: file rows, window
|
||||||
|
titles; the path-bar buttons), `-lg` 20px and `-xl` 24px (lone spinners and
|
||||||
|
status marks). The glyphs are drawn on the sm, md and xl grids (`icons.ts`).
|
||||||
|
**Interface scale** (`settings.uiScale`,
|
||||||
0.5–4 in 0.1 steps): one root font-size lever — `applyUiScale` sets
|
0.5–4 in 0.1 steps): one root font-size lever — `applyUiScale` sets
|
||||||
`html { font-size: 16px * scale }`, and since EVERY size in the app is rem
|
`html { font-size: 16px * scale }`, and since EVERY size in the app is rem
|
||||||
(panel tokens like `--row-h`, all Tailwind utilities, ng-icon sizes, `--w7-fs`
|
(panel tokens like `--row-h`, all Tailwind utilities, ng-icon sizes), the
|
||||||
and the 7.css bridge in 7like.css), both skins scale whole, chrome included.
|
whole UI scales, chrome included.
|
||||||
Driven by the tab-bar stepper, Ctrl/Cmd +/−/0, and preload-baked for a
|
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
|
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
|
migrates old `textSize` values. TS that measures rem-sized CSS converts through
|
||||||
`@core/metrics` `remPx()` — never an assumed 16.
|
`@core/metrics` `remPx()` — never an assumed 16.
|
||||||
|
|
||||||
### Skins (`settings.skin`: `modern | win7`)
|
|
||||||
|
|
||||||
A fifth theming axis, and the only one that is a **class** (`.win7` on
|
|
||||||
`<html>`) rather than a `data-*` attribute — `7.css/dist/7.scoped.css` is
|
|
||||||
precompiled against a literal `.win7`, so the name is not ours to pick.
|
|
||||||
Default `modern` = class absent, matching the delete-when-default rule.
|
|
||||||
`buildPreload` is the **only** writer of that class, ever — there is
|
|
||||||
deliberately no `SettingsService.applySkin`.
|
|
||||||
|
|
||||||
**Unlike the other four axes, this one is launch-time only.** It selects a
|
|
||||||
whole component tree (see "UI layout") as well as CSS, and neither half can
|
|
||||||
change on a live window: the tree is bootstrapped once from the preload-baked
|
|
||||||
`__APP_SKIN`, and `titleBarStyle`/`transparent` are `BrowserWindow` constructor
|
|
||||||
options with no setters. Clicking a skin therefore only persists it; the
|
|
||||||
options row shows the saved value as pending (`skinPending()` compares
|
|
||||||
`settings.skin()` against `APP_SKIN`) and the running UI is untouched until the
|
|
||||||
next app start.
|
|
||||||
|
|
||||||
The bun side pins this: `initSessionSkin` freezes the skin at startup, before
|
|
||||||
any window exists, and both `buildPreload` and `chromeOptions` read that pinned
|
|
||||||
value rather than the live settings snapshot. Otherwise an editor or manager
|
|
||||||
opened after a pending change would boot the *new* tree and frame while the
|
|
||||||
main window still ran the old one.
|
|
||||||
|
|
||||||
**Stylesheet split.** `ui/src/styles.css` holds only what BOTH skins consume —
|
|
||||||
the `@layer` statement, the Tailwind import, OKLCH tokens, `@theme inline`,
|
|
||||||
panel-chrome tokens, animations, the accent axis, and the `html, body` base. Each
|
|
||||||
skin's own rules live in its folder (`skins/7like/7like.css`,
|
|
||||||
`skins/shadlike/shadlike.css`) and are `@import`ed from there. `shadlike.css` is
|
|
||||||
near-empty by nature: that skin IS the shared token system plus inline Tailwind.
|
|
||||||
Two constraints: the `@layer` statement must stay the FIRST statement in
|
|
||||||
`styles.css` (it is one of only two rule types allowed above `@import`), and
|
|
||||||
`@font-face` `url()`s resolve relative to the file that declares them — which is
|
|
||||||
why 7like's Noto Sans lives at `skins/7like/fonts/`, inside the skin.
|
|
||||||
|
|
||||||
**Cascade** (`ui/src/styles.css` first lines + `skins/7like/7like.css`): the
|
|
||||||
explicit `@layer theme, base, components, utilities, skin, skinfix;` statement
|
|
||||||
precedes every import. 7.css is imported with `layer(skin)`, i.e. **above
|
|
||||||
`utilities`**,
|
|
||||||
so a bare `button` rule in 7.css outranks `rounded-md` on that same button —
|
|
||||||
that inversion is what reskins ~1900 inline utility classes with no template
|
|
||||||
edits. Consequences: (1) any utility later added to a `button`/`input`/
|
|
||||||
`select`/`textarea`/`table` silently loses under the skin; (2) neutralizers
|
|
||||||
that want the Tailwind value back must sit **inside `@layer skin`**, not
|
|
||||||
`skinfix` — `revert-layer` rolls back one layer, so from `skin` it lands on
|
|
||||||
`utilities`, but from `skinfix` it lands back on 7.css and does nothing.
|
|
||||||
`skinfix` is for rules that state an explicit value (`.w7-btn`).
|
|
||||||
|
|
||||||
**Token bridge** (`skins/7like/7like.css`): `:root.win7` re-points the existing
|
|
||||||
semantic tokens at Win7 values, so utilities Tailwind still controls come out
|
|
||||||
right too. It adds NO new tokens on purpose — a new colour would need another
|
|
||||||
`@theme inline` line. The Win7 surface ramp **inverts** the modern one:
|
|
||||||
`--background` (content panes) is Explorer white and `--chrome` is the darker
|
|
||||||
`#f0f0f0` window surface. `--radius: 5px` so `rounded-md`
|
|
||||||
(`calc(--radius - 2px)`, 74 uses) lands exactly on 7.css's 3px `--w7-el-bdr`.
|
|
||||||
`--primary`/`--ring` are deliberately left alone so the accent axis stays live;
|
|
||||||
`:root.win7[data-accent]` feeds it to `--w7-w-bg`, which is authentic (Win7
|
|
||||||
shipped "Window Color"). Dark Win7 is hand-authored (Microsoft never shipped
|
|
||||||
one) as ~20 `--w7-*` overrides, duplicated forced + system like every other
|
|
||||||
dark block. `--w7-font` is restated as a rem `font` shorthand so the interface
|
|
||||||
scale reaches it and 7.css's `pt` shorthand stops resetting weight/line-height;
|
|
||||||
the rest of 7.css's px metrics are re-expressed in rem by the scale bridge in
|
|
||||||
7like.css's `skinfix`.
|
|
||||||
|
|
||||||
**Markup hooks**: `role="progressbar"` (+aria) on the transfer bars,
|
|
||||||
`has-scrollbar` on scroll containers (7.css only styles `::-webkit-scrollbar`
|
|
||||||
under that class), `w7-toolbar` on every command row — the three window header
|
|
||||||
rows plus each file panel's session strip and path bar (their buttons go flat
|
|
||||||
with a hover glow — Explorer's command bar was flat, unlike dialog push
|
|
||||||
buttons; it only targets `button`, so the path `<input>` keeps its 7.css
|
|
||||||
textbox treatment), `w7-flat`
|
|
||||||
as the per-button escape hatch for the same treatment outside a toolbar (the
|
|
||||||
status-bar transfers summary is a text toggle, not a push button), `w7-btn`
|
|
||||||
on the 10 dialog action buttons. The flat treatment is split across two
|
|
||||||
layers on purpose: `background: revert-layer` must sit in `skin` to reach
|
|
||||||
`utilities`, while `border-color`/`box-shadow` sit in `skinfix` because 7.css
|
|
||||||
restates `border-color` at (0,3,1) and no sane `.w7-flat` selector outranks
|
|
||||||
that.
|
|
||||||
|
|
||||||
**Tabs**: the workspace strip is 7.css's real tab component —
|
|
||||||
`menu[role=tablist]` + one `button[role=tab][aria-selected]` per workspace, so
|
|
||||||
the framework draws the joined inactive tabs and the raised active one. The
|
|
||||||
selector is a DESCENDANT (`menu[role=tablist] button`), so the `+` button, the
|
|
||||||
update pill and the right-hand tools deliberately sit outside the `<menu>`.
|
|
||||||
Tabs are exempted from the `w7-toolbar` flattening via `:not([role='tab'])` —
|
|
||||||
they want the full Aero slab their neighbours are stripped of. There is no
|
|
||||||
per-tab ✕ (Win7 tab controls had none, and a nested button is invalid HTML):
|
|
||||||
closing is `Ctrl/Cmd+W` or middle-click. `role="tabpanel"` on the panel area
|
|
||||||
was considered and rejected — its `padding: 14px`, `margin-bottom: 9px` and
|
|
||||||
border fight the split-pane layout for a join that cannot read anyway with the
|
|
||||||
panel toolbar row in between.
|
|
||||||
|
|
||||||
**Frame transparency**: the rounded frame only reads as rounded because
|
|
||||||
nothing paints an opaque full-bleed ground behind it. That is not free —
|
|
||||||
`html, body` paint `var(--background)` **unlayered**, which beats every
|
|
||||||
`@layer`, so `.win7` resets them to transparent in an equally unlayered rule
|
|
||||||
next to the `font-family` one. A maximized window drops the radius (7.css
|
|
||||||
declares it three times: `.window`, `::before`, `::after` — all three must be
|
|
||||||
zeroed); the frame reads the state off its own child with
|
|
||||||
`:has(> .title-bar.is-maximized)`, which `title-bar.ts` publishes from its
|
|
||||||
existing `maximized` signal rather than subscribing to `windowState` twice.
|
|
||||||
|
|
||||||
**Selection band**: the file list's selected row is a FLAT Win7-Explorer blue
|
|
||||||
(`--w7-sel-bg`, `#cbe8f6`) with **no border** — not 7.css's `--w7-el-grad-h`,
|
|
||||||
which is its Aero element-hover ramp and stays reserved for buttons and tabs
|
|
||||||
that still want the gloss, and not its `#aaddfa` row outline, which stacked a
|
|
||||||
second edge inside the fill and, with the cursor row's dotted rect, drew three
|
|
||||||
concentric outlines on one row. Every row still reserves `border: 1px solid
|
|
||||||
transparent` for geometry; the fill paints under it (`background-clip`
|
|
||||||
defaults to `border-box`). The unfocused pane gets `--w7-sel-bg-idle`,
|
|
||||||
Explorer's grey inactive selection — the band never disappears with focus,
|
|
||||||
only weakens (the commander convention above). Both are dark-overridden in
|
|
||||||
BOTH dark blocks. The context menu's item highlight uses the same
|
|
||||||
`--w7-sel-bg`, so selection reads identically wherever it appears.
|
|
||||||
|
|
||||||
**Context menu**: pure 7.css via its real menu contract —
|
|
||||||
`ul[role=menu] > li[role=menuitem] > button` for actions,
|
|
||||||
`input[type=checkbox] + label` for the checkable column toggles, dividers as
|
|
||||||
`.has-divider` on the PRECEDING item (an `::after` bevel, not a node). The
|
|
||||||
contract is exact: an earlier iteration rendered `div > button`, matched
|
|
||||||
nothing, and wrongly concluded "7.css has no menu component" — it has a full
|
|
||||||
one (`dist/gui/menu.css`), including the gutter stripe, disabled state and
|
|
||||||
submenu arrows. Keyboard moves REAL focus between items (7.css styles
|
|
||||||
`:focus-visible` like `:hover`); Enter/Space activate buttons natively. Only
|
|
||||||
menu.css's hardcoded light literals are restated for dark, in `skinfix` — plus
|
|
||||||
three overrides keyed on the app's own `w7-ctx` marker: `position: fixed`
|
|
||||||
(7.css's `position: relative` on `ul[role=menu]` had been silently winning),
|
|
||||||
the flat `--w7-sel-bg` highlight in place of its gradient wash, and
|
|
||||||
`content: none` on the item buttons' pseudo-elements, which kills both the
|
|
||||||
1s Aero afterglow and the infinite `pulse-anim` on a focused item (see
|
|
||||||
gotchas.md). `scripts/menu-probe.ts` regression-tests placement AND highlight
|
|
||||||
flatness, each with a control that proves the check can fail.
|
|
||||||
|
|
||||||
**Font**: Segoe UI is absent off Windows and not redistributable. Noto Sans —
|
|
||||||
the substitute 7.css itself names — is vendored (OFL-1.1, from
|
|
||||||
`@fontsource/noto-sans`) into `ui/src/fonts/`; under `src/` and not `public/`
|
|
||||||
so esbuild emits it hashed into `media/` and rewrites the `url()`.
|
|
||||||
|
|
||||||
**Window chrome**: `chromeOptions(skin)` in `src/bun/windows.ts` is the single
|
|
||||||
place either skin's frame is decided, spread by both the main window and
|
|
||||||
`openSecondaryWindow`. `win7` uses `titleBarStyle: "hidden"` on **every**
|
|
||||||
platform — that clears `Titled` from the Cocoa style mask, which is also the
|
|
||||||
only way to remove the macOS traffic lights. **Linux honours it too**: the GTK
|
|
||||||
wrapper calls `gtk_window_set_decorated(false)` and logs "GTK: Created window
|
|
||||||
without decorations (custom titlebar)" (verified on GNOME/Wayland), so the
|
|
||||||
frameless Aero chrome is not a mac/win-only feature. The webview then draws the whole
|
|
||||||
frame via `ui/src/app/skins/7like/title-bar/title-bar.ts` (7.css's `.title-bar` >
|
|
||||||
`.title-bar-text` + `.title-bar-controls`, buttons keyed by `aria-label`),
|
|
||||||
mounted by all three of that skin's window roots. Because `titleBarStyle` is
|
|
||||||
fixed at construction, **a skin change only reaches the frame on the next app
|
|
||||||
start** — and nothing about the skin flips live, deliberately. `IS_WIN7_CHROME` in
|
|
||||||
`platform.ts` reads the preload-baked `__APP_SKIN` (settings resolve too late
|
|
||||||
for layout) and forces `IS_MAC_CHROME` false, since there are no traffic
|
|
||||||
lights to reserve `pl-24` for.
|
|
||||||
|
|
||||||
**Window resize (STOPGAP)**: undecorating the window also removes the WM's
|
|
||||||
resize borders, and electrobun 1.18.1 has no native resize gesture — so
|
|
||||||
`skins/7like/window-resize/` synthesizes one: a bottom-right grip drags the
|
|
||||||
frame through `windowGetFrame`/`windowSetFrame` → `BrowserWindow.setFrame`.
|
|
||||||
Mounted by all three win7 roots, hidden while maximized by a CSS `:has()` rule
|
|
||||||
(no second `windowState` subscription), and the grip carries
|
|
||||||
`electrobun-webkit-app-region-no-drag` or the drag region would move the window
|
|
||||||
instead. **One corner, not eight handles**: the handles are invisible and paint
|
|
||||||
above the frame, so a north strip or top corner overlays the caption buttons and
|
|
||||||
steals their `:hover`, which reads as the Aero glow flickering on those buttons.
|
|
||||||
The bottom-right corner is the only one that overlaps nothing (it sits in the
|
|
||||||
6px gutter `.window-body`'s margin leaves). The frame math is a dependency-free module so
|
|
||||||
`scripts/resize-math-probe.ts` can assert all eight edges without a window.
|
|
||||||
**This is meant to be deleted**: the component header carries the removal
|
|
||||||
checklist and `scripts/check-electrobun-resize.ts` (wired into `postinstall`)
|
|
||||||
prints it when upstream ships the primitive. See platform-notes.md.
|
|
||||||
|
|
||||||
**Window controls**: new `windowMinimize` / `windowToggleMaximize` /
|
|
||||||
`windowClose` / `windowIsMaximized` requests, built by
|
|
||||||
`windowControlHandlers(getWindow, onClose?)` in `rpc-handlers.ts` and served by
|
|
||||||
all three window kinds (each passing a late-bound getter for its own window —
|
|
||||||
the window takes the rpc in its constructor, same reason `events` is
|
|
||||||
late-bound). `onClose` is overridden per window kind so the X reuses the
|
|
||||||
existing close path: editors run their dirty prompt, the manager runs
|
|
||||||
`managerClose`, main falls through to the cancellable `before-quit` guard.
|
|
||||||
electrobun emits **no maximize event**, so `trackMaximizeState` derives it from
|
|
||||||
`resize` + the synchronous `isMaximized()` and pushes `windowState` — which
|
|
||||||
also covers Aero Snap and Win+Up. Double-click-to-maximize is likewise absent
|
|
||||||
from electrobun's drag regions and is implemented in the title-bar component.
|
|
||||||
|
|||||||
+115
-69
@@ -10,73 +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.
|
||||||
|
- **Electrobun 2.x is driven by Hutch.** The `electrobun` devDependency (exact
|
||||||
|
`2.0.2`) is only a bootstrap: its first command downloads the paired Hutch
|
||||||
|
(0.27.1) from the Electrobun GitHub release, checks its SHA-256 against
|
||||||
|
`hutch-artifacts.json`, caches it under `~/.hutch/npm/electrobun/<ver>/`, and
|
||||||
|
forwards every `electrobun …` to `hutch electrobun …`. Hutch then fetches the
|
||||||
|
Electrobun core, its build-time Cottontail and the pinned Bun 1.4.0 (the
|
||||||
|
runtime the app ships) into `~/.hutch` (~200 MB on a fresh machine) and
|
||||||
|
projects the SDK into the gitignored `.hutch/devkit/` (plus a gitignored
|
||||||
|
`.cottontail-tmp/` scratch dir). The devDependency version IS the toolchain
|
||||||
|
pin: moving Electrobun = bump it + `bun install`. No Node needed — the
|
||||||
|
bootstrap's `#!/usr/bin/env node` runs under `bunx`.
|
||||||
|
- `bunx electrobun prepare` (re)projects `.hutch/devkit`. It runs from
|
||||||
|
`postinstall` **and** at the start of `dev`/`start`/`build`/`release` (~0.5 s
|
||||||
|
when current — the slot the v1 CLI re-sign step held; that step is gone,
|
||||||
|
Hutch's binaries carry valid signatures). tsc (the root tsconfig extends
|
||||||
|
`.hutch/devkit/tsconfig.json`) and the Angular build / `ng serve` (the
|
||||||
|
`electrobun/view` path in `ui/tsconfig.json`) resolve the SDK there *before*
|
||||||
|
any electrobun command would project it; without it both fail on unresolved
|
||||||
|
`electrobun/*` imports. Bun stays the package manager: Hutch's own resolver
|
||||||
|
(`hutch.lock`, no lifecycle scripts) only runs on `hutch install`, which
|
||||||
|
nothing calls — hence no `hutch.config.ts`.
|
||||||
|
|
||||||
## Release builds
|
## Release builds
|
||||||
|
|
||||||
`bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64|linux-x64]`
|
`bun scripts/release.ts [--target mac-arm64|win-x64|linux-x64]`
|
||||||
(default: host).
|
(default: host).
|
||||||
Steps: gen-version → gen-icons → Angular build → `electrobun build
|
Steps: gen-version → electrobun prepare → gen-icons → Angular build → empty
|
||||||
--env=stable` → copy/rename artifacts into
|
`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 and `linux-x64` on a
|
matching machine; `win-x64` on a non-Windows host and `linux-x64` on a
|
||||||
non-Linux host both error out.
|
non-Linux host both error out. **Electrobun 2.x publishes no macOS x64 core
|
||||||
- **mac-x64 from the arm64 Mac (Rosetta)**: the script downloads electrobun's
|
(nor an x64 Hutch)**, so mac-x64 was dropped — the v1 trick of running the x64
|
||||||
x64 CLI binary once (`electrobun-cli-darwin-x64.tar.gz` from the GitHub
|
CLI under Rosetta has nothing left to run. `--target mac-x64`, and any build
|
||||||
release matching the installed version, cached at
|
on an Intel Mac, fail with that message.
|
||||||
`~/.cache/miniscp/electrobun-cli-x64-<ver>/`) and runs it directly —
|
- **Runtime downloads**: on first use per Electrobun version Hutch fetches the
|
||||||
Rosetta translates it, the CLI sees `process.arch === "x64"`, downloads the
|
platform core from `github.com/blackboardsh/electrobun/releases`
|
||||||
`dist-macos-x64` core into the electrobun package dir on first use, and
|
(SHA-256-checked against `electrobun-artifacts.json`), plus Cottontail and the
|
||||||
packages a genuine x64 app. Note: Bun's `fetch`→`Bun.write` streaming hung
|
pinned Bun, into `~/.hutch` — network is a build-time requirement on every
|
||||||
on the GitHub redirect chain; the script uses `curl -fsSL` deliberately.
|
fresh machine/runner.
|
||||||
- **Core binary downloads**: the installed npm package ships mac-arm64
|
- **`artifacts/` is emptied before the build** — `site/deploy.sh` publishes it
|
||||||
binaries only; other `dist-<os>-<arch>` cores are fetched from
|
verbatim, so it must hold exactly this build (stale files, e.g. v1-named
|
||||||
`github.com/blackboardsh/electrobun/releases` at build time — network to
|
installers, would otherwise ship).
|
||||||
GitHub is a build-time requirement on every fresh machine/runner.
|
- **Artifact shapes** (stable channel, in `artifacts/`): 2.x installers carry
|
||||||
- **Artifact shapes** (stable channel, in `artifacts/`): macOS →
|
**no channel prefix**; only the updater feed keeps `stable-<os>-<arch>-` (the
|
||||||
`stable-macos-<arch>-miniSCP.dmg` + `.app.tar.zst` + `update.json`
|
names v1.18.1+ clients request). macOS → `macos-arm64-miniSCP.dmg` +
|
||||||
(updater feed); Windows → `stable-win-x64-miniSCP-Setup.zip`
|
`stable-macos-arm64-miniSCP.app.tar.zst` + `stable-macos-arm64-update.json`
|
||||||
(self-extracting installer → `%LOCALAPPDATA%`) plus the script's own
|
(verified); Windows → `win-x64-miniSCP-Setup.zip` (setup exe + hidden payload)
|
||||||
**portable zip** made from the assembled app folder under
|
+ the `stable-win-x64-*` feed, plus the script's own **portable zip** of
|
||||||
`build/stable-win-x64/` (layout to confirm on the first real Windows build —
|
`build/stable-win-x64/miniSCP` (warns instead of failing if that folder is
|
||||||
the step warns instead of failing); Linux → only
|
missing); Linux → `linux-x64-miniSCP-Setup.tar.gz` (a self-extracting
|
||||||
`stable-linux-x64-miniSCP.tar.zst` + `update.json`, i.e. the updater feed and
|
`installer` + README) + the `stable-linux-x64-*` feed. The official Linux
|
||||||
nothing installable, so the script's **`.tar.gz` of the assembled app folder**
|
installer IS the release artifact — the v1 portable tarball and its
|
||||||
under `build/stable-linux-x64/` IS the release artifact (same best-effort
|
`miniSCP.sh` NVIDIA wrapper are gone, so an installed build on NVIDIA needs
|
||||||
warn). No AppImage/.deb/.desktop — unpack and run the `miniSCP.sh` wrapper the
|
`WEBKIT_DISABLE_DMABUF_RENDERER=1` from the user (see gotchas.md). The
|
||||||
script writes beside `bin/launcher` (it applies the NVIDIA/DMABUF workaround,
|
Windows/Linux names come from the 2.x docs — confirm on the first real 2.x
|
||||||
which the app cannot set for itself — see gotchas.md).
|
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
|
- **Linux builds need the AppIndicator dev libraries present to RUN** (not to
|
||||||
build) — see platform-notes.md; a packaged tarball started on a machine
|
build) — see platform-notes.md; an installed build started on a machine
|
||||||
without them dies at `dlopen`.
|
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)
|
||||||
|
|
||||||
@@ -84,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)
|
||||||
|
|
||||||
@@ -116,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`.
|
||||||
|
- **`applyUpdate()` asks the before-quit guard first (2.x).** With transfers running or dirty
|
||||||
|
editors the guard vetoes and shows its Quit Anyway / Cancel box. `events.applyUpdate` marks the
|
||||||
|
quit as update-initiated, so Quit Anyway re-runs `Updater.applyUpdate()` (a plain `Utils.quit()`
|
||||||
|
would drop the downloaded update); Cancel leaves the pill at "Restart to apply".
|
||||||
- **UI**: `UpdaterService` ([ui/src/app/core/updater.service.ts](../ui/src/app/core/updater.service.ts)) holds signals
|
- **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/7like/tab-bar/tab-bar.ts))
|
(`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
|
||||||
|
|
||||||
@@ -137,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.
|
||||||
|
|||||||
+255
-247
@@ -20,25 +20,15 @@ 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`.
|
||||||
- **The win7 skin layer outranks Tailwind utilities.** 7.css is imported
|
|
||||||
`layer(skin)`, ordered above `utilities`, so any utility you add to a
|
|
||||||
`button`/`input`/`select`/`textarea`/`table` is silently dead under that
|
|
||||||
skin. If it matters, neutralize in the `@layer skin` block in
|
|
||||||
`app/skins/7like/7like.css` — and note `revert-layer` only reaches
|
|
||||||
`utilities` from **inside `skin`**; used from `skinfix` it falls back onto
|
|
||||||
7.css and is a no-op. This bit once already.
|
|
||||||
- `@import … layer(name)` works through `@tailwindcss/postcss` (it wraps the
|
|
||||||
imported AST), but the **deep path is mandatory**: 7.css ships no `exports`
|
|
||||||
map and Tailwind's resolver reads only `style`/`mainFields`, so a bare
|
|
||||||
`@import "7.css"` resolves to nothing. Use `7.css/dist/7.scoped.css`.
|
|
||||||
- Stylesheet `url()` must resolve at BUILD time: assets referenced from
|
- Stylesheet `url()` must resolve at BUILD time: assets referenced from
|
||||||
`styles.css` live under `ui/src/` (esbuild emits them hashed into `media/`
|
`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
|
and rewrites the url), NOT `ui/public/` — a public path fails the build with
|
||||||
@@ -53,6 +43,64 @@ touching the adjacent code.
|
|||||||
- **A component `template:` is a TS template literal, so no backticks inside
|
- **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
|
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.
|
literal and the errors point at the prose, not at the quoting.
|
||||||
|
- **Reach `core/` through the `@core/*` alias, never a relative path.**
|
||||||
|
Components sit at varying depths and get moved; the alias is what let 67
|
||||||
|
imports survive two relocations untouched. A `../../core/...` path is a
|
||||||
|
future merge conflict.
|
||||||
|
- **The build DOES type-check components nothing imports.** ngtsc compiles the
|
||||||
|
whole program, so an unreferenced component still fails `bun run build:ui` on
|
||||||
|
a type or template error (verified by injecting an error into an
|
||||||
|
unreferenced file).
|
||||||
|
- **`overflow-x: auto` also clips vertically.** CSS refuses to pair `visible`
|
||||||
|
with a non-visible axis, so the computed `overflow-y` becomes `auto` too:
|
||||||
|
anything poking out of a horizontally scrolling strip (a raised tab, an
|
||||||
|
underline at `-bottom-px`) is silently clipped. Reserve padding instead.
|
||||||
|
|
||||||
|
## Theming (`ui/src/styles.css`)
|
||||||
|
|
||||||
|
- **A new surface token needs an AMOLED line, or AMOLED stops being
|
||||||
|
all-black.** The two `[data-dark-tone='darker']` blocks re-declare every
|
||||||
|
surface (`--chrome`, `--toolbar`, `--background`, `--row-alt`, …) as `#000`;
|
||||||
|
a token added only to the dark blocks shows through in AMOLED. Tokens derived
|
||||||
|
in `:root` by `color-mix` (`--toolbar-2/-3`) follow on their own.
|
||||||
|
- **Hover/selected color is a translucent overlay (`--accent`).** An opaque
|
||||||
|
hover vanishes on a surface of its own lightness — light's old 0.967 hover
|
||||||
|
on the 0.965 header strip did exactly that. Keep `--accent` alpha-based when
|
||||||
|
retoning surfaces.
|
||||||
|
- **A native control ignores `border-radius` and background until
|
||||||
|
`appearance: none`.** WebKit draws checkboxes, radios and selects itself;
|
||||||
|
the square restyle in the `@layer base` block depends on it. Put `pl-*`,
|
||||||
|
never `px-*`, on a `<select>`: utilities outrank the base layer, so a `px`
|
||||||
|
utility would erase the padding-right the drawn caret lives in.
|
||||||
|
- **A custom `::-webkit-scrollbar` turns off macOS overlay scrollbars.** The
|
||||||
|
moment the pseudo-element is styled, WKWebView draws classic always-visible
|
||||||
|
scrollbars that take layout width. Deliberate here (square design); don't
|
||||||
|
reach for it to "tint" scrollbars without accepting that.
|
||||||
|
- **An icon size is one of the four `--icon-size-*` tokens, never a one-off
|
||||||
|
rem.** TDesign glyphs are 24-unit SVGs with 2-unit strokes, so a fractional
|
||||||
|
size (0.9rem = 14.4px) lands every stroke between device pixels and blurs
|
||||||
|
it. Per-template picks had drifted to 12 sizes, and neighbours stopped
|
||||||
|
matching: a 14.4px cog beside a 12.8px +, and a 12px + beside 9.6px × in
|
||||||
|
the shortcut chips.
|
||||||
|
- **On a 1x screen, TDesign's glyphs smear at 14 and 16px, so every icon is
|
||||||
|
redrawn on its pixel grid (`pixel-icons.ts`).** Their 24-unit strokes land
|
||||||
|
between pixels, and a ~1px line covers two pixels at half ink. This is
|
||||||
|
geometry, not Electrobun: headless Chrome renders the same. The drawing rules
|
||||||
|
each cost a debugging round:
|
||||||
|
- **Straight lines are runs of whole pixels** (`h`, `v`, `box`) that end on
|
||||||
|
pixel edges. Never rely on caps or joins:
|
||||||
|
- WebKit drops square caps on a `<path>` with several subpaths.
|
||||||
|
- Skia (WebView2, WebKitGTK) strokes 1px lines as hairlines at 1x, which
|
||||||
|
leave a joined corner pixel three-quarters inked.
|
||||||
|
- **Slants and curves (`slant`) are 1.25px**, so they read as heavy as the
|
||||||
|
crisp 1px runs. Put their ends, vertices and a circle's extremes on pixel
|
||||||
|
centres; a 45° line is cleanest where x−y or x+y is whole.
|
||||||
|
- **A glyph is exact only on its own grid**: a 14px glyph blurs at 16px, so
|
||||||
|
the name's suffix must match the size token (`icons.spec.ts`). It is also
|
||||||
|
exact only at UI scale 100/200/300%.
|
||||||
|
- **Check new glyphs at 1x in WebKit and Chrome, not resvg.** resvg skews a
|
||||||
|
shape's closing edge and leaves path corners partly inked, so its pixels
|
||||||
|
don't predict either browser.
|
||||||
|
|
||||||
## The interface scale (root font-size lever)
|
## The interface scale (root font-size lever)
|
||||||
|
|
||||||
@@ -64,149 +112,73 @@ touching the adjacent code.
|
|||||||
`webviewGetPageZoom` returns a hardcoded `1.0` from `.rodata`, so the failure
|
`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
|
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.
|
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
|
- **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
|
root font-size, so a px value freezes while everything around it grows. The
|
||||||
three px reservoirs were: the panel-chrome tokens (`--row-h` etc.), 93
|
px reservoirs were the panel-chrome tokens (`--row-h` etc.) and 93
|
||||||
`text-[11px]` arbitraries, and 7.css itself — 370 px values, zero rem, with
|
`text-[11px]` arbitraries. 1px hairlines and focus rects stay px deliberately
|
||||||
`--w7-font` reaching only 15 rules' typography. 7like.css's `skinfix` carries
|
(crisp at any scale).
|
||||||
a scale bridge re-expressing 7.css's vars and hardcoded box metrics in rem.
|
|
||||||
1px hairlines and focus rects stay px deliberately (crisp at any scale; the
|
|
||||||
`tr` border exists to stop selection shifting rows).
|
|
||||||
- **Restating a 7.css rule in `skinfix` also outranks 7.css's VARIANTS of that
|
|
||||||
rule — by layer, regardless of specificity.** The title-bar-controls
|
|
||||||
restatement silently shrank the close button from 48px to 29px (7.css widens
|
|
||||||
it via `button[aria-label=Close]`), and the tablist padding flattened the
|
|
||||||
active tab's 4px raise. Caught only by the harness pixel diff. When bridging
|
|
||||||
a rule, grep 7.scoped.css for every variant that overrides the same
|
|
||||||
properties and restate those too.
|
|
||||||
- **px constants in TS that mirror rem-sized CSS are latent scale bugs.** The
|
- **px constants in TS that mirror rem-sized CSS are latent scale bugs.** The
|
||||||
shadlike context menu's `MENU_W = 224` was a hardcoded mirror of `w-56`
|
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
|
(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,
|
`@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
|
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.
|
`charWidth` is cached keyed on the computed font string for the same reason.
|
||||||
|
|
||||||
## Hover states and the frame edge
|
|
||||||
|
|
||||||
- **A child's box-shadow is NOT clipped by its parent's border or radius.** The
|
|
||||||
close button's hover glow (7.css: `0 0 7px 3px #e68e75` on a `::before`)
|
|
||||||
painted across the window's top border and onto the desktop beyond it — the
|
|
||||||
caption buttons sit flush against the top edge (`border-top: 0`), and the
|
|
||||||
frame's rounded transparent corners clip nothing. Fixed with
|
|
||||||
`clip-path: inset(0 0 -1rem 0)` on `.w7-frame > .title-bar`: glow stops at
|
|
||||||
the frame like the real DWM, bottom bleed (authentic) stays open. Note
|
|
||||||
clip-path also clips hit-testing — safe here because the drag region is the
|
|
||||||
title-bar's own box, entirely inside the clip.
|
|
||||||
- **Hover states CAN be tested headlessly**: `scripts/hover-probe.ts` copies
|
|
||||||
the built stylesheet with `:hover` rewritten to `.simhover`, renders the
|
|
||||||
frame markup with the state forced, and pixel-scans artifact regions,
|
|
||||||
exiting non-zero on hover-introduced red outside the frame. Firefox
|
|
||||||
`--screenshot` has no pointer and `import -window` captures the real
|
|
||||||
pointer's state, so the class rewrite is the only programmatic hover on this
|
|
||||||
machine. Re-run it after touching title-bar or hover styling.
|
|
||||||
|
|
||||||
## The two skin trees (`ui/src/app/skins/`)
|
|
||||||
|
|
||||||
- **`.win7` is not a name we may change.** `7.scoped.css` is precompiled with
|
|
||||||
every selector prefixed by that literal class, so the skin id in
|
|
||||||
`AppSettings` stays `"win7"` even though its folder is `7like/`. The folder
|
|
||||||
names are cosmetic; the class and the persisted setting value are not.
|
|
||||||
- **Reach `core/` through the `@core/*` alias, never a relative path.** Skin
|
|
||||||
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.
|
|
||||||
- **`@font-face` `url()` resolves relative to the file that declares it.** When
|
|
||||||
the win7 rules moved three directories deeper, `./fonts/...` would silently
|
|
||||||
have 404'd — build still green, app just renders a fallback font. The fonts
|
|
||||||
therefore live inside the skin that uses them.
|
|
||||||
- **The `@layer` statement must stay the first statement in `ui/src/styles.css`.**
|
|
||||||
It is one of only two rule types allowed above `@import`, and it is what sorts
|
|
||||||
`skin` above `utilities`. Every `@import` (Tailwind and both skin sheets) must
|
|
||||||
also precede all other rules.
|
|
||||||
- **Duplicate selectors across skins are safe.** `app-file-panel` exists in both
|
|
||||||
trees; standalone components resolve through their importer's `imports:`
|
|
||||||
array, never a global registry, and the trees are never mounted together.
|
|
||||||
- **The build DOES type-check components nothing imports.** ngtsc compiles the
|
|
||||||
whole program, so a skin that is currently unreachable still fails
|
|
||||||
`bun run build:ui` on a type or template error. Verified deliberately by
|
|
||||||
injecting an error into an unreferenced file — worth knowing before assuming
|
|
||||||
a green build proves nothing about the inactive skin.
|
|
||||||
|
|
||||||
## The win7 skin (7.css)
|
|
||||||
|
|
||||||
- **Unlayered declarations beat every `@layer`, regardless of specificity.**
|
|
||||||
`html, body { background: … }` in styles.css is unlayered, so no `skinfix`
|
|
||||||
rule can override it — the win7 transparent-corner reset has to be unlayered
|
|
||||||
too. The frame's rounded corners only read as rounded because nothing paints
|
|
||||||
an opaque full-bleed ground behind them; anything that does fills the corners
|
|
||||||
and the window becomes a rectangle with a curve drawn on it.
|
|
||||||
- **7.css restates `border-color` at (0,3,1)** via
|
|
||||||
`.win7 button:not(:disabled):not(:hover)` and its `:hover` twin. A two-class
|
|
||||||
selector like `.win7 .w7-flat` (0,2,0) loses to it, so paint suppression for
|
|
||||||
flat buttons lives in `skinfix` where layer order settles it. The
|
|
||||||
`.w7-toolbar` arm only ever won by tying on specificity and landing later in
|
|
||||||
the same layer — an accident `w7-flat` did not share, which is why it
|
|
||||||
rendered a bordered slab while its neighbours went flat.
|
|
||||||
- **`overflow-x: auto` also clips vertically.** CSS refuses to pair `visible`
|
|
||||||
with a non-visible axis, so the computed `overflow-y` becomes `auto` too.
|
|
||||||
7.css raises the active tab with `margin-top: -2px`; inside the horizontally
|
|
||||||
scrolling tab strip that put its top border outside the scroll box and
|
|
||||||
deleted it silently. The strip reserves `padding-top: 2px` to compensate —
|
|
||||||
mirroring the `padding-left: 3px` 7.css already reserves for the same tab's
|
|
||||||
`-3px` left margin.
|
|
||||||
- **7.css hardcodes light-mode literals outside its own var system** —
|
|
||||||
`color: #222` on tablist buttons, black/white bevels in the ListView and
|
|
||||||
menu. Each one has to be routed through a `--w7-*` token declared in
|
|
||||||
`:root.win7` and overridden in **both** dark blocks (the `[data-theme]`
|
|
||||||
attribute one *and* the `prefers-color-scheme` one; system dark sets no
|
|
||||||
attribute).
|
|
||||||
- **The skin layer can also override `position`, and that breaks LAYOUT, not
|
|
||||||
just paint.** `ul[role=menu]` carries `position: relative` in 7.css (for the
|
|
||||||
gutter stripe's `::before` and submenu anchoring), which outranked Tailwind's
|
|
||||||
`.fixed` on the app's own context menu. `[style.left/top.px]` then became
|
|
||||||
*flow* offsets from the menu's static position — the last child of the
|
|
||||||
`h-screen` frame — so every right-click menu rendered below the viewport,
|
|
||||||
unreachable behind `overflow: hidden`. The backdrop is a plain `<div>`, which
|
|
||||||
7.css does not match, so it stayed genuinely fixed and ate the next click:
|
|
||||||
the symptom was "right-click does nothing, then the app ignores me once".
|
|
||||||
Fixed with a `position: fixed` restatement in `skinfix`, scoped to a `w7-ctx`
|
|
||||||
marker class so 7.css's own menus keep theirs. Regressed in `0ae2a62`, when
|
|
||||||
the menu root became a real `ul[role=menu]` — i.e. adopting the framework's
|
|
||||||
markup is what exposed it. **A resting pixel diff cannot catch this class of
|
|
||||||
bug**: nothing about the idle window changes. `scripts/menu-probe.ts` renders
|
|
||||||
the menu at declared coordinates and scans whether it landed there, with a
|
|
||||||
marker-less control menu as its own discriminator.
|
|
||||||
- **7.css's button effects reach anything that IS a button — including menu
|
|
||||||
items — and one of them fades out over a FULL SECOND.** `button::before` is
|
|
||||||
an Aero glow overlay with `opacity: 0`, raised to 1 on hover, and
|
|
||||||
`button:not(:hover):before` carries `transition: opacity 1s linear`.
|
|
||||||
menu.css suppresses it with `content: none` on the **`:hover` arm only**, so
|
|
||||||
while an item is hovered nothing paints, but the moment the pointer leaves,
|
|
||||||
the pseudo returns with opacity animating 1 → 0: a blue ghost trailing the
|
|
||||||
cursor down the menu. Killing `content` in *every* state is the fix —
|
|
||||||
suppressing the transition alone leaves a glow that simply snaps. Two more
|
|
||||||
from the same family: `button:focus` runs `pulse-anim` *infinitely*
|
|
||||||
(`1s ease infinite alternate`), which matters because the context menu moves
|
|
||||||
REAL focus between items for keyboard nav, and the hover paint is 7.css's
|
|
||||||
translucent gradient wash — the same one the ListView selection dropped.
|
|
||||||
All three are overridden together, scoped to `w7-ctx`.
|
|
||||||
- **Its selectors are exact, and a near-miss looks exactly like "7.css doesn't
|
|
||||||
support this".** `menu[role=tablist] button` is a DESCENDANT selector, so
|
|
||||||
every button inside the strip becomes a tab — the `+` and the right-hand
|
|
||||||
tools must live outside it. `.window.glass > .title-bar` is direct-child, so
|
|
||||||
an inner wrapper breaks the glass coupling. A wrong guess here already
|
|
||||||
produced one hand-written lookalike that was deleted.
|
|
||||||
|
|
||||||
## Electrobun
|
## Electrobun
|
||||||
|
|
||||||
- **Window `close` is not cancellable** (response type `{}`; only the
|
- **The `electrobun` npm package (2.x) is a bootstrap whose every export is a
|
||||||
app-level `before-quit` supports `event.response = {allow:false}`). All
|
throwing stub** ("Electrobun 2.x APIs come from the Hutch devkit, not
|
||||||
unsaved-state guards must either intercept app-controlled close paths
|
node_modules"). Every `electrobun/*` import must resolve through the
|
||||||
(⌘W, buttons) or survive the close (editor draft mirroring).
|
projected `.hutch/devkit/`: the root tsconfig extends
|
||||||
|
`.hutch/devkit/tsconfig.json`, and `ui/tsconfig.json` maps `electrobun/view`
|
||||||
|
to `../.hutch/devkit/api/browser/index.ts` (the exact export-map target). Let
|
||||||
|
an import fall through to `node_modules/electrobun` and the webview throws at
|
||||||
|
load. tsc and the UI build fail on unresolved `electrobun/*` imports until
|
||||||
|
`bunx electrobun prepare` has run (postinstall and every dev/build script do
|
||||||
|
it). Never point anything into `~/.hutch` — that store is Hutch-owned.
|
||||||
|
- **`build.mainProcess` defaults to Cottontail in 2.x**, and Cottontail ignores
|
||||||
|
the whole `build.bun` block — the ssh2 externals included, silently (verified
|
||||||
|
with `bunx electrobun config --env=stable`). `mainProcess: "bun"` in
|
||||||
|
`electrobun.config.ts` is load-bearing.
|
||||||
|
- **The browser SDK is raw TS compiled inside the Angular program**, so the UI's
|
||||||
|
strictness flags apply to upstream code: 2.0.2's `createTransport().send`
|
||||||
|
returns on one path only, which is why `ui/tsconfig.json` has
|
||||||
|
`noImplicitReturns: false`. Its `import "./global.d.ts"` also prints "File
|
||||||
|
'…/global.d.ts' not found in TypeScript compilation" on every build — benign,
|
||||||
|
and adding the file to the program does not silence it.
|
||||||
|
- **Window `close` is not cancellable, but 2.x added `will-close`.** The `close`
|
||||||
|
event's response type is `{}`; `will-close` (fired by the titlebar/traffic-light
|
||||||
|
close and `win.requestClose()`) honors `event.response = {allow:false}`, and
|
||||||
|
`win.close()` skips it. The editor close safety predates this: all
|
||||||
|
unsaved-state guards still intercept app-controlled close paths (⌘W, buttons)
|
||||||
|
or survive the close (editor draft mirroring) — a `will-close` prompt is a
|
||||||
|
possible follow-up.
|
||||||
|
- **`Updater.applyUpdate()` asks the `before-quit` handlers first (2.x)**, so the
|
||||||
|
transfers/unsaved guard can veto an update restart. Its "Quit Anyway" must
|
||||||
|
re-run `Updater.applyUpdate()` (`events.applyUpdate` flags the quit as
|
||||||
|
update-initiated) — a plain `Utils.quit()` there quits without installing.
|
||||||
|
`Utils.quit()` runs the same approval, which is why the guard checks
|
||||||
|
`quitConfirmed` first.
|
||||||
|
- **macOS ⌘-keys go to the PAGE first; only unclaimed ones reach the app
|
||||||
|
menu.** WKWebView's `performKeyEquivalent` hands key equivalents to the page
|
||||||
|
while the webview is first responder (WebKit `WebViewImpl.mm`: "lets
|
||||||
|
webpages have a crack at intercepting key-modified keypresses"); a key the
|
||||||
|
page `preventDefault`s never reaches the menu. Old comments here claimed the
|
||||||
|
opposite ("the menu intercepts ⌘W first") — what was really happening is that
|
||||||
|
windows which didn't claim ⌘W let it fall through to the menu's Close Tab.
|
||||||
|
Two consequences: a handled shortcut MUST `preventDefault` (`claim()` does),
|
||||||
|
or the menu runs it a second time; and a ⌘W a secondary window doesn't claim
|
||||||
|
still arrives as the MAIN window's `close-tab` action — bun checks the
|
||||||
|
focused Settings window, then editors, first and closes THAT, so a new
|
||||||
|
window kind must either claim close-window or join that check.
|
||||||
- **One rpc instance per window** — BrowserView binding steals the rpc's
|
- **One rpc instance per window** — BrowserView binding steals the rpc's
|
||||||
transport; a shared instance silently sends to the wrong webview.
|
transport; a shared instance silently sends to the wrong webview.
|
||||||
- **RPC before socket open is silently lost.** Everything gates on the
|
- **RPC before socket open was silently lost on 1.18.1.** 2.x Electroview queues
|
||||||
socket-open promise (RpcService `ready`).
|
sends while its `hostSocket` (v1: `bunSocket`) is CONNECTING and falls back to
|
||||||
|
the native bridge; RpcService still gates everything on the socket-open
|
||||||
|
promise (`ready`) as belt-and-braces.
|
||||||
- The emitter has no `off()`. Window event closures must not capture records
|
- The emitter has no `off()`. Window event closures must not capture records
|
||||||
you want garbage collected.
|
you want garbage collected.
|
||||||
- `electrobun build` defaults to the **dev** channel; release builds need
|
- `electrobun build` defaults to the **dev** channel; release builds need
|
||||||
@@ -218,12 +190,65 @@ touching the adjacent code.
|
|||||||
- `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.
|
single-character only.
|
||||||
- **`ContextMenu.showContextMenu` is a silent no-op on Linux** (electrobun's own
|
- **`ContextMenu.showContextMenu` is a silent no-op on Linux** (electrobun's own
|
||||||
`dist/api/bun/proc/linux.md` says so; the app also prints "Application menus are
|
`.hutch/devkit/api/sdks/main/proc/linux.md` says so, still in 2.0.2; the app
|
||||||
not supported on Linux" at boot). Because the UI suppresses the webview's own
|
also prints "Application menus are not supported on Linux" at boot). Because
|
||||||
right-click menu, that left EVERY panel context menu dead on Linux — no error,
|
the UI suppresses the webview's own right-click menu, that left EVERY panel
|
||||||
nothing happens. The menu is plain HTML now (`ui/src/app/skins/*/context-menu.ts`) on
|
context menu dead on Linux — no error, nothing happens. The menu is plain
|
||||||
every platform; don't reintroduce the native one for "nativeness" on mac/win
|
HTML now (`ui/src/app/skins/shadlike/context-menu.ts`) on every platform;
|
||||||
without keeping a Linux path.
|
don't reintroduce the native one for "nativeness" on mac/win without keeping
|
||||||
|
a Linux path.
|
||||||
|
- **Window-open flash prevention is three-layer** — remove any one and the
|
||||||
|
white/inverted blink returns: (1) windows are created `hidden: true` and
|
||||||
|
revealed by `showWhenReady` on the webview's first `dom-ready` (800 ms
|
||||||
|
fallback), (2) the bun preload bakes the persisted `data-theme`/tone
|
||||||
|
attributes and the interface scale before first paint (`buildPreload`),
|
||||||
|
(3) `ui/src/index.html` carries an inline `<style>` with the html background
|
||||||
|
per theme — its oklch values MUST stay in sync with the `--background` tokens
|
||||||
|
in styles.css. (The win7 branch once replaced (3) with a transparent rule and
|
||||||
|
the modern skin silently lost it — the other two layers do not cover it.)
|
||||||
|
- Dev vs prod URL: `getMainViewUrl()` resolves the ng-serve dev server only
|
||||||
|
when `DEV_SERVER_URL` is set AND the updater channel is `dev` AND the
|
||||||
|
server answers a HEAD request; secondary windows must reuse the resolved
|
||||||
|
URL, never hardcode `views://`.
|
||||||
|
- Upstream Bun panic (`FFI_Callback_threadsafe_call` → `web_worker.spin`):
|
||||||
|
rare segfault under long sessions; upstream electrobun×Bun issue, restart
|
||||||
|
recovers, nothing app-side to fix. Seen on Bun 1.3.13 (Electrobun 1.18.1);
|
||||||
|
2.0.2 ships Bun 1.4.0 — unconfirmed whether that fixed it.
|
||||||
|
- **Preloads and frames are construction-time only.** The preload is a
|
||||||
|
persistent `WebKitUserScript` at DOCUMENT_START, frozen at webview
|
||||||
|
construction — `webview.loadURL()` re-runs the OLD one
|
||||||
|
(`updatePreloadScriptToWebView` is only reachable from electrobun's internal
|
||||||
|
`<electrobun-webview>` handler). `titleBarStyle`/`transparent` have no
|
||||||
|
setters. Anything `buildPreload` bakes is a first-paint snapshot the UI must
|
||||||
|
correct live over RPC. Recreating the main window is not an escape hatch:
|
||||||
|
`exitOnLastWindowClosed`, push closures and the tray capture it, and there
|
||||||
|
is no `off()`.
|
||||||
|
- **`trafficLightOffset` changed meaning in 2.x.** 1.18.1 nudged the buttons
|
||||||
|
by the offset but clamped them inside the stock titlebar, so `{12, 13}` really
|
||||||
|
meant "as low as the titlebar allows" (left 21 / top 18 on macOS 26). 2.x adds
|
||||||
|
the full offset to macOS's default position (9/9 in the 32pt Tahoe titlebar)
|
||||||
|
and grows the titlebar to fit — the same numbers dropped the lights 4pt.
|
||||||
|
`chromeOptions()` now passes `{12, 4}`: left 21 / top 13, the 14pt buttons
|
||||||
|
vertically centred in the 40px `h-10` header (1.18.1's clamped spot, top 18,
|
||||||
|
was 5pt low). The default shifts with macOS's titlebar metrics (28pt / 7
|
||||||
|
before macOS 26), so re-check after an OS release. `win.setWindowButtonPosition(x, top)` is the absolute
|
||||||
|
alternative (re-applied on resize/fullscreen) if per-version drift matters.
|
||||||
|
- **Drag regions ignore `preventDefault`.** The preload starts a window move
|
||||||
|
from a bubble-phase `mousedown` on `document` and never reads
|
||||||
|
`defaultPrevented`; a `pointerdown` handler doesn't stop it either (the
|
||||||
|
compat `mousedown` still fires). `isAppRegionDragTarget` lets a `no-drag`
|
||||||
|
region win — the `.electrobun-webkit-app-region-no-drag` class (or, since
|
||||||
|
2.x, `app-region: no-drag` CSS) on the element is the only reliable
|
||||||
|
suppressor (`.hutch/devkit/api/preload/dragRegions.ts`; re-checked on 2.0.2).
|
||||||
|
- **No resize, maximize-event or double-click-maximize primitives (1.18.1, still
|
||||||
|
absent from 2.0.2's `BrowserWindow`).**
|
||||||
|
`titleBarStyle: "hidden"` drops the whole frame (Cocoa `Titled` — the only
|
||||||
|
way to remove the traffic lights; `gtk_window_set_decorated(false)` on
|
||||||
|
Linux) and with it the WM resize borders; nothing restores them (no
|
||||||
|
`startWindowResize`, no `gtk_window_begin_resize_drag`, no
|
||||||
|
`setResizable`/min-size options — only `setFrame`/`getFrame`). No maximize
|
||||||
|
event exists (derive it from `resize` + `isMaximized()`), and drag regions
|
||||||
|
don't double-click-maximize. A custom-chrome window has to build all three.
|
||||||
|
|
||||||
## Linux
|
## Linux
|
||||||
|
|
||||||
@@ -243,12 +268,15 @@ touching the adjacent code.
|
|||||||
- **The app cannot apply this to itself.** WebKit reads the variable when it
|
- **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
|
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
|
child processes** (verified: a spawned child sees it unset). Setting it in
|
||||||
`src/bun/index.ts` — even in a module imported before `electrobun/bun` —
|
`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.
|
does nothing. It must be in the environment before the app process starts.
|
||||||
- Hence every launch path injects it: `scripts/linux-gpu.ts` (`launchEnv()`)
|
- Only the dev launch paths inject it: `scripts/linux-gpu.ts` (`launchEnv()`)
|
||||||
for `bun run dev` / `bun run start`, and the `miniSCP.sh` wrapper that
|
for `bun run dev` / `bun run start`. **A new dev launch path must call
|
||||||
`release.ts` writes beside `bin/launcher` in the tarball. **A new launch
|
`launchEnv()` or it will render black on NVIDIA.** The release is the
|
||||||
path must call `launchEnv()` or it will render black on NVIDIA.**
|
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
|
- Gated on `/proc/driver/nvidia` so Intel/AMD keep hardware compositing, and
|
||||||
an explicit `WEBKIT_DISABLE_DMABUF_RENDERER` from the user always wins.
|
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
|
- A "does it boot?" log check does NOT catch this — grep for the GBM line, or
|
||||||
@@ -266,100 +294,63 @@ touching the adjacent code.
|
|||||||
pattern appears in the killing command's OWN argv. Break the literal with a
|
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`
|
character class — `pkill -f 'dev-linux-x[6]4'` — the way `ps | grep [x]yz`
|
||||||
has always worked.
|
has always worked.
|
||||||
- **A frameless window cannot be resized, and `preventDefault` will not save
|
- **Launching the app steals focus — ask first.** `bun run start` opens a real
|
||||||
you from the drag region.** Undecorating the GTK window (what
|
window that takes focus, which interrupts whatever the user is doing; there
|
||||||
`titleBarStyle: "hidden"` does) takes the WM's resize borders with it, and
|
is no Xvfb/xvfb-run on this machine and Wayland offers no
|
||||||
electrobun exposes no resize gesture — hence the synthesized grip in
|
launch-without-focus. Batch every live check into a single launch.
|
||||||
`ui/src/app/skins/7like/window-resize/` (a STOPGAP; that file carries the
|
|
||||||
removal checklist, and `scripts/check-electrobun-resize.ts` fires from
|
|
||||||
`postinstall` when upstream makes it deletable).
|
|
||||||
**An invisible resize handle over a control steals that control's hover.** A
|
|
||||||
full eight-handle set shipped briefly and was reverted: the north strip ran
|
|
||||||
the whole width across the caption buttons and the north-east square sat on
|
|
||||||
the close button's corner, so crossing one dropped the button's `:hover`
|
|
||||||
mid-glow and looked like a rendering artifact in 7.css — nothing in the CSS
|
|
||||||
was wrong. Only the bottom-right corner overlaps nothing (it lands in the 6px
|
|
||||||
gutter `.window-body { margin: 0 6px 6px }` leaves). Any new handle must be
|
|
||||||
checked against what sits under it; south/east/west are innocent, north and
|
|
||||||
both top corners are not.
|
|
||||||
Three more traps live in that code, all verified against
|
|
||||||
`dist/api/bun/preload/dragRegions.ts`:
|
|
||||||
- It starts a window **move** from a `mousedown` listener on `document`, in
|
|
||||||
the **bubble** phase. It never reads `defaultPrevented`, so
|
|
||||||
`event.preventDefault()` does **nothing** to stop it.
|
|
||||||
- A `pointerdown` handler does not stop it either: the compatibility
|
|
||||||
`mousedown` still fires afterwards, and stopping propagation on a *pointer*
|
|
||||||
event has no effect on a *mouse* event. This is the trap that looks fixed
|
|
||||||
in every test until the window jumps.
|
|
||||||
- `isAppRegionDrag` checks `.electrobun-webkit-app-region-no-drag` **first**
|
|
||||||
and short-circuits, so that class — on the handle itself — is the only
|
|
||||||
reliable suppressor. Without it, every resize drag also drags the window.
|
|
||||||
Also: deltas must come from `screenX/screenY`. North/west resizes move the
|
|
||||||
window's origin, which moves the webview's client origin under a stationary
|
|
||||||
pointer, so `clientX` feeds that movement back into the next delta and the
|
|
||||||
window runs away. And the minimum-size clamp must re-anchor the opposite
|
|
||||||
edge — clamping the width alone lets `x` keep tracking the pointer, walking
|
|
||||||
the window off-screen while it looks frozen (`scripts/resize-math-probe.ts`
|
|
||||||
asserts exactly that, and was verified to catch it).
|
|
||||||
- **Prefer the headless probes over launching the app.** `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. `scripts/shot.ts` (+ `--dark`), `hover-probe.ts` and
|
|
||||||
`menu-probe.ts` all render through headless Firefox and open nothing. Launch
|
|
||||||
the real app only for what Gecko genuinely cannot answer — WebKitGTK
|
|
||||||
behaviour, the native frame, transparency, live RPC — ask first, and batch
|
|
||||||
every live check into a single launch.
|
|
||||||
- **X11 capture cannot answer "is this pixel transparent?"** for the frameless
|
|
||||||
window. `import -window` grabs the window's own pixels with no compositing,
|
|
||||||
so a transparent corner and an opaque black one both come out black;
|
|
||||||
`png32:` just fills alpha with 1 and proves nothing. XWayland's root window
|
|
||||||
is not the composited desktop, so `import -window root` fails too. Frame
|
|
||||||
transparency and the maximized corners need a human looking at the screen —
|
|
||||||
say so rather than reporting a screenshot as confirmation.
|
|
||||||
- Benign startup noise on Linux — non-fatal, the app runs normally:
|
- Benign startup noise on Linux — non-fatal, the app runs normally:
|
||||||
`libayatana-appindicator is deprecated. Please use libayatana-appindicator-glib`
|
`libayatana-appindicator is deprecated. Please use libayatana-appindicator-glib`
|
||||||
(upstream electrobun's choice, not ours).
|
(upstream electrobun's choice, not ours).
|
||||||
- **Window-open flash prevention is three-layer** — remove any one and the
|
|
||||||
white/inverted blink returns: (1) windows are created `hidden: true` and
|
## Keyboard (`src/shared/keymap.ts`, `ui/src/app/core/shortcuts.service.ts`)
|
||||||
revealed by `showWhenReady` on the webview's first `dom-ready` (800 ms
|
|
||||||
fallback), (2) the bun preload bakes the persisted `data-theme`/tone/accent
|
- **WKWebView skips buttons, checkboxes and radios on Tab** unless the user
|
||||||
attributes before first paint (`buildPreload`), (3) `ui/src/index.html`
|
turned on System Settings → Keyboard → Keyboard navigation (or presses ⌥Tab).
|
||||||
carries an inline `<style>` with the html background per theme — its oklch
|
Dialogs, Settings and the manager were mouse-only on a default Mac. Tab is
|
||||||
values MUST stay in sync with the `--background` tokens in styles.css.
|
walked by hand: `cycleFocus(root, event)` (core/focus-cycle.ts) — use it in
|
||||||
The win7 skin adds a **fourth value to keep in sync**: `buildPreload` also
|
any new dialog or window.
|
||||||
bakes the `.win7` class, and index.html carries a matching `html.win7` arm
|
- **WebKit doesn't focus a clicked button — focus drops to `<body>`** (Windows
|
||||||
for all four theme states. Miss it and every win7 window flashes white.
|
parks it on the button, where Space clicks it again). Every list key died
|
||||||
- **The skin is a LAUNCH-TIME choice. Never apply one to a running window.**
|
until the list was clicked. `App` `(mousedown)` keeps focus on chrome-button
|
||||||
This shipped broken once: `SettingsService.applySkin` toggled the `.win7`
|
clicks and forwards `<body>` keys to the active list; keep both.
|
||||||
class the moment the setting changed, which was harmless when there was one
|
- **Match modifiers exactly.** The old panel switch caught every Tab, so
|
||||||
component tree and became a broken window as soon as there were two — 7.css
|
Ctrl+Tab flipped the active panel AND switched tabs; `isMod = metaKey ||
|
||||||
painted onto shadlike markup that has no `.window.glass`, no `.title-bar`, no
|
ctrlKey` made ⌃F (Cocoa's forward-one-character) open the editor's find.
|
||||||
`menu[role=tablist]` and no `<table>` ListView. Each skin's CSS is written
|
`matchesEvent` compares all four modifiers, platform-exact.
|
||||||
against its OWN tree's markup; the class must only ever be written by
|
- **AltGr is Ctrl+Alt on Windows/Linux.** A Ctrl+Alt+letter binding steals
|
||||||
`buildPreload`, for the tree that window actually booted.
|
"@", "€", "ł"… — never a default there, never fired from a text field, and
|
||||||
- **A reload does not rescue it.** The preload is a persistent
|
an event with `getModifierState('AltGraph')` matches nothing.
|
||||||
`WebKitUserScript` injected at DOCUMENT_START, so its string is frozen at
|
- **Type-to-filter takes every printable key.** A plain-letter binding (vim
|
||||||
webview construction — `webview.loadURL()` re-runs it with the OLD
|
j/k) must resolve BEFORE it — and then that letter no longer starts a query
|
||||||
`__APP_SKIN`. (`updatePreloadScriptToWebView` exists in the FFI but is
|
(Settings warns). While a query is being typed, printable keys (Space too)
|
||||||
reachable only from electrobun's internal `<electrobun-webview>` handler.)
|
always extend it.
|
||||||
- **`titleBarStyle`/`transparent` are construction-time only** — no setters
|
- **In a master-detail list, selection must not follow focus** when the detail
|
||||||
exist, so a live window can never be re-framed. win7 → modern is the
|
is an unsaved form: the connection manager's `edit()` replaces the draft
|
||||||
dangerous direction: the modern tree draws no title bar, so it would land
|
without asking, so arrowing through the list would silently discard edits.
|
||||||
in a frameless transparent window with no way to move, minimize or close.
|
Arrows move focus; Enter loads.
|
||||||
- Don't "fix" this by recreating the main window either: `exitOnLastWindowClosed`
|
- **A dialog's Esc must `stopPropagation`.** The connection manager closes on
|
||||||
is true, the main `rpc` is captured by six push closures, the tray captures
|
a window-level Escape; the dialog cleared its own signal first, so the
|
||||||
`win`, listeners cannot be removed (no `off()`) — and it would drop every
|
window handler saw "no dialog open" and closed the whole window.
|
||||||
session and tab.
|
- **An unclaimed key behind a dialog reaches the macOS menu** — ⌘T opened a
|
||||||
- Bun pins it: `initSessionSkin` freezes the skin before any window exists,
|
tab behind the rename prompt. The main window swallows window commands and
|
||||||
and `buildPreload`/`chromeOptions` read that, never the live settings. Skip
|
the menu bar's panel commands while a dialog or menu is up.
|
||||||
the pin and a window opened after a pending change boots the other skin.
|
- **WebView2 reloads the page on unclaimed F5 / Ctrl+R** (default browser
|
||||||
- Dev vs prod URL: `getMainViewUrl()` resolves the ng-serve dev server only
|
accelerators), dropping every session and the editor's unsaved text. Every
|
||||||
when `DEV_SERVER_URL` is set AND the updater channel is `dev` AND the
|
window root ends with `blockReloadKeys`; a blanket `stopPropagation` (the old
|
||||||
server answers a HEAD request; secondary windows must reuse the resolved
|
path bar) hides keys from it — don't add one.
|
||||||
URL, never hardcode `views://`.
|
- **Hold-to-repeat** fired ⌘W once per repeat — every tab closed. `claim()`
|
||||||
- Upstream Bun panic (`FFI_Callback_threadsafe_call` → `web_worker.spin`):
|
repeats only movement-like commands (`isRepeatable`).
|
||||||
rare segfault under long sessions; upstream electrobun×Bun issue, restart
|
- **Electrobun's native menu accelerator turns a combo with no modifier token
|
||||||
recovers, nothing app-side to fix.
|
into ⌘+key** ("f7" shows and fires ⌘F7), its `backspace` / `delete` tokens
|
||||||
|
are characters AppKit matches unreliably, and AppKit matches Shift+punctuation
|
||||||
|
by the shifted character ("}" for ⇧]). `toAccelerator` returns null for all of
|
||||||
|
those; the menu item then shows no key and the page still handles it.
|
||||||
|
- **Recording a key must take the keydown before every window handler** — and
|
||||||
|
must NOT take reserved ones: preventDefault on ⌘Q while recording would make
|
||||||
|
the app unquittable from the keyboard.
|
||||||
|
- **Focus after a state change needs `afterNextRender({ injector })`.** Under
|
||||||
|
zoneless CD `queueMicrotask` runs before the render that creates an `@if`
|
||||||
|
element (the editor's find box and textarea), so `.focus()` hit nothing.
|
||||||
|
|
||||||
## Domain logic
|
## Domain logic
|
||||||
|
|
||||||
@@ -378,6 +369,16 @@ touching the adjacent code.
|
|||||||
- Settings: the backend spread-merges (`{...DEFAULT_SETTINGS, ...data}`) so
|
- Settings: the backend spread-merges (`{...DEFAULT_SETTINGS, ...data}`) so
|
||||||
new keys auto-default, but the UI SettingsService reads field-by-field —
|
new keys auto-default, but the UI SettingsService reads field-by-field —
|
||||||
a new setting silently stays at default until wired in all five spots.
|
a new setting silently stays at default until wired in all five spots.
|
||||||
|
- **Saves are whole-object: a window must not save before its first
|
||||||
|
`getSettings` landed.** A ⌘+ pressed while an editor opened wrote
|
||||||
|
`DEFAULT_SETTINGS` (no keyboard remaps) over the user's file.
|
||||||
|
`SettingsService.persist()` is a no-op until `applyAll` ran once.
|
||||||
|
- **Every window's RPC must serve `saveSettings`.** The scale shortcuts
|
||||||
|
shipped in editor and manager windows while only the main RPC served it:
|
||||||
|
the request went unanswered, the change applied locally, and main's next
|
||||||
|
broadcast snapped it back. All three factories now share
|
||||||
|
`saveSettingsHandler`, and `settingsSaved()` in index.ts rebroadcasts to
|
||||||
|
every window but the sender — an echo would drag a held Cmd+ backwards.
|
||||||
- The editor draft mirror sends full content per tick (2 MB cap upstream);
|
- The editor draft mirror sends full content per tick (2 MB cap upstream);
|
||||||
don't shrink the 400 ms debounce.
|
don't shrink the 400 ms debounce.
|
||||||
- `panel.touched` gates localDir auto-navigation; `connectToken` guards
|
- `panel.touched` gates localDir auto-navigation; `connectToken` guards
|
||||||
@@ -416,7 +417,14 @@ touching the adjacent code.
|
|||||||
breaks `ng`.
|
breaks `ng`.
|
||||||
- ssh2's optional native deps must stay `external: ["cpu-features", "*.node"]`
|
- ssh2's optional native deps must stay `external: ["cpu-features", "*.node"]`
|
||||||
in `electrobun.config.ts` — bundling them breaks the build; ssh2 falls back
|
in `electrobun.config.ts` — bundling them breaks the build; ssh2 falls back
|
||||||
to pure JS at runtime.
|
to pure JS at runtime. (Inert without `mainProcess: "bun"` — see Electrobun.)
|
||||||
|
- **2.x installers dropped the `stable-` prefix; the updater feed kept it.**
|
||||||
|
`macos-arm64-miniSCP.dmg` / `win-x64-miniSCP-Setup.zip` /
|
||||||
|
`linux-x64-miniSCP-Setup.tar.gz` next to `stable-<os>-<arch>-update.json`,
|
||||||
|
`.tar.zst` and `.patch`. Anything that globs `artifacts/stable-*` misses the
|
||||||
|
installers — release.ts collects by `<os>-<arch>-`, deploy.sh publishes
|
||||||
|
`artifacts/*` verbatim (release.ts empties `artifacts/` first), and the site
|
||||||
|
links the new names.
|
||||||
- `bun run --cwd ui build` (flag order matters; `bun --cwd ui run build` does
|
- `bun run --cwd ui build` (flag order matters; `bun --cwd ui run build` does
|
||||||
not do what you want).
|
not do what you want).
|
||||||
- `version.gen.ts` is gitignored and regenerated by postinstall + every build
|
- `version.gen.ts` is gitignored and regenerated by postinstall + every build
|
||||||
|
|||||||
+47
-58
@@ -1,31 +1,35 @@
|
|||||||
# Platform notes — macOS / Windows / Linux behavior matrix
|
# Platform notes — macOS / Windows / Linux behavior matrix
|
||||||
|
|
||||||
macOS is the primary target; Windows 11+ x64 and Linux x64 are **beta** targets
|
macOS (Apple Silicon) is the primary target; Windows 11+ x64 and Linux x64 are
|
||||||
(win-arm64 and linux-arm64 deferred). Every platform seam carries a
|
**beta** targets (win-arm64 and linux-arm64 deferred). **Intel Macs were
|
||||||
`PORTABILITY:` comment at the branch site.
|
dropped** with the move to Electrobun 2.x, which publishes no macOS x64 core.
|
||||||
|
Every platform seam carries a `PORTABILITY:` comment at the branch site.
|
||||||
|
|
||||||
## Seam inventory (where platform branches live)
|
## Seam inventory (where platform branches live)
|
||||||
|
|
||||||
| Concern | File | mac | win | linux |
|
| Concern | File | mac | win | linux |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Window chrome (modern skin) | `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 |
|
||||||
| Window chrome (win7 skin) | `src/bun/windows.ts chromeOptions` | `hidden` (clears Titled → no traffic lights) | frameless | `gtk_window_set_decorated(false)` |
|
| Scrollbars | `ui/src/styles.css` (`::-webkit-scrollbar`) | custom square, always visible (styling them turns off overlay scrollbars) | custom square | custom square |
|
||||||
| Window **resize** | `ui/.../7like/window-resize/` | native frame resizes; win7 frameless uses the synthesized handles | as mac | as mac |
|
|
||||||
| 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.service.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 | `ui context-menu.service.ts` | Reveal in Finder / Move to Trash | Show in Explorer / Move to Recycle Bin | Show in File Manager |
|
| Shell wording | `ui context-menu.service.ts` | Reveal in Finder / Move to Trash | Show in Explorer / Move to Recycle Bin | Show in File Manager |
|
||||||
| Shortcut labels | `ui platform.ts MOD_LABEL/ALT_LABEL` | ⌘ / ⌥ | Ctrl+ / Alt+ | Ctrl+ / Alt+ |
|
| Shortcut labels | `src/shared/keymap.ts formatCombo` (+ `keys.names.*`) | ⌘⇧⌥⌃ glyphs: ⇧⌘N | Ctrl+Shift+N, localized key names (Strg+Entf) | as Windows |
|
||||||
|
| Command modifier | `keymap.ts` `Mod` | ⌘ only (⌃ stays Cocoa's text keys) | Ctrl | Ctrl |
|
||||||
|
| Key equivalents | `shortcuts.service.ts claim` | page first, unclaimed → app menu | page only (no app menu) | page only |
|
||||||
|
| Tab focus | `core/focus-cycle.ts cycleFocus` | WKWebView skips buttons/checkboxes without Keyboard navigation → walked by hand | native order (same walk) | native order (same walk) |
|
||||||
|
| Reload keys | `platform.ts blockReloadKeys` | — | WebView2 reloads on F5 / Ctrl+R → swallowed when unclaimed | — |
|
||||||
| Tray | `src/bun/tray.ts` | 22 pt template PNG | 16 px colored PNG | colored (needs AppIndicator, see below) |
|
| 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 (unsupported upstream) |
|
| App menu | `src/bun/menu.ts` | full menu incl. About; accelerators follow the keymap (remaps included), ⌘Q fixed | none (early return) — Settings opens from the tab-bar gear (Ctrl+,), About from its About section | none (unsupported upstream) |
|
||||||
| connections.json protection | `store/connections.ts` | chmod 0600 | `icacls` current-user ACL (0600 is meaningless) | 0600 |
|
| connections.json protection | `store/connections.ts` | chmod 0600 | `icacls` current-user ACL (0600 is meaningless) | 0600 |
|
||||||
| App data dir | `store/paths.ts` | ~/Library/Application Support | %APPDATA% | $XDG_CONFIG_HOME/.config |
|
| App data dir | `store/paths.ts` | ~/Library/Application Support | %APPDATA% | $XDG_CONFIG_HOME/.config |
|
||||||
| ssh-agent | `fs/sftp.ts` | $SSH_AUTH_SOCK | `\\.\pipe\openssh-ssh-agent` fallback | $SSH_AUTH_SOCK |
|
| ssh-agent | `fs/sftp.ts` | $SSH_AUTH_SOCK | `\\.\pipe\openssh-ssh-agent` fallback | $SSH_AUTH_SOCK |
|
||||||
| Paths in UI | `ui format.ts joinPath/isRootPath` | "/" | separator detected from the dir string; drive-root regex | "/" |
|
| Paths in UI | `ui format.ts joinPath/isRootPath` | "/" | separator detected from the dir string; drive-root regex | "/" |
|
||||||
|
|
||||||
The **context menu is HTML on every platform** (`ui/src/app/skins/*/context-menu.ts` +
|
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
|
`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
|
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.
|
the default app, copy path) cross to bun, via the `shellItemAction` RPC.
|
||||||
@@ -33,29 +37,6 @@ 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`).
|
||||||
|
|
||||||
### Frameless windows lose the WM's resize borders (win7 skin, every platform)
|
|
||||||
|
|
||||||
`titleBarStyle: "hidden"` is what lets 7.css draw the Aero caption — on Linux the
|
|
||||||
GTK wrapper honours it with `gtk_window_set_decorated(false)`. Undecorating also
|
|
||||||
removes the window manager's resize borders and corner grips, and electrobun
|
|
||||||
1.18.1 offers **no** native resize gesture to put back: the shipped
|
|
||||||
`libNativeWrapper` exports `startWindowMove`/`stopWindowMove` with no resize
|
|
||||||
counterpart, does not link `gtk_window_begin_resize_drag`, and `BrowserWindow`
|
|
||||||
has neither `setResizable` nor min-size constructor options. The window is still
|
|
||||||
*programmatically* resizable (`setFrame`/`getFrame` work, and
|
|
||||||
`gtk_window_set_resizable(false)` is never called), so the app **synthesizes the
|
|
||||||
input affordance**: a bottom-right grip in the webview drags the native frame
|
|
||||||
over RPC (`ui/src/app/skins/7like/window-resize/`). Only that one corner — an
|
|
||||||
invisible handle anywhere else overlays a control and steals its hover, which is
|
|
||||||
what a full edge set did to the caption buttons.
|
|
||||||
|
|
||||||
That is a **stopgap** — each drag tick is an RPC hop plus an FFI call, where a
|
|
||||||
real WM grab is compositor-side. `scripts/check-electrobun-resize.ts` watches the
|
|
||||||
installed electrobun for the missing primitives and prints the removal checklist
|
|
||||||
when they appear; it runs from `postinstall`, so an electrobun bump surfaces it
|
|
||||||
without anyone remembering to look. The modern skin keeps the native frame and
|
|
||||||
needs none of this.
|
|
||||||
|
|
||||||
## 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
|
||||||
@@ -105,7 +86,8 @@ ldd build/dev-linux-x64/*/bin/libNativeWrapper.so | grep 'not found'
|
|||||||
|
|
||||||
`libasar.so => not found` in that output is **expected and harmless** — it ships
|
`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
|
in the same `bin/` folder and resolves at load time; only an unresolved
|
||||||
`libayatana-appindicator3.so.1` is fatal.
|
`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
|
**Tray visibility** is a separate matter from linking: GNOME 3.26+ hides the
|
||||||
legacy tray, so the icon needs the
|
legacy tray, so the icon needs the
|
||||||
@@ -116,23 +98,26 @@ either way — the tray is a convenience, and every action it offers exists in-a
|
|||||||
## Linux known issues (upstream electrobun)
|
## Linux known issues (upstream electrobun)
|
||||||
|
|
||||||
- **No native menus of any kind.** Application menus and `showContextMenu` are
|
- **No native menus of any kind.** Application menus and `showContextMenu` are
|
||||||
both no-ops (electrobun's `dist/api/bun/proc/linux.md`; the app logs
|
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
|
"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,
|
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
|
so nothing is missing at the UI level — but do not add a feature that depends
|
||||||
on a native menu.
|
on a native menu.
|
||||||
- **NVIDIA renders black without the DMABUF workaround.** WebKitGTK can't
|
- **NVIDIA renders black without the DMABUF workaround.** WebKitGTK can't
|
||||||
allocate GBM buffers on the proprietary driver, so the window paints nothing
|
allocate GBM buffers on the proprietary driver, so the window paints nothing
|
||||||
while the log looks perfectly healthy. Every launch path sets
|
while the log looks perfectly healthy. The dev launches
|
||||||
`WEBKIT_DISABLE_DMABUF_RENDERER=1` when `/proc/driver/nvidia` exists —
|
(`scripts/linux-gpu.ts`) set `WEBKIT_DISABLE_DMABUF_RENDERER=1` when
|
||||||
`scripts/linux-gpu.ts` for dev, the `miniSCP.sh` wrapper in the tarball for
|
`/proc/driver/nvidia` exists; an installed build (the official Setup
|
||||||
releases. The app can't do it itself (Bun's `process.env` writes don't reach
|
installer) does not — launch it with that variable set (e.g. in the desktop
|
||||||
child processes); see [gotchas.md](gotchas.md#linux).
|
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
|
- **Benign startup noise**: a deprecation warning pointing at
|
||||||
`libayatana-appindicator-glib`. Not fatal.
|
`libayatana-appindicator-glib`. Not fatal.
|
||||||
- **Unsigned, unpackaged**: the release artifact is a portable tarball — no
|
- **Unsigned; Electrobun's own installer**: the release artifact is
|
||||||
AppImage, `.deb`, `.desktop` entry or icon installation. Unpack and run
|
`linux-x64-miniSCP-Setup.tar.gz` — a self-extracting `installer` + README
|
||||||
`miniSCP.sh` (which execs `bin/launcher`).
|
(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
|
## First-Linux-boot smoke test
|
||||||
|
|
||||||
@@ -154,21 +139,24 @@ either way — the tray is a convenience, and every action it offers exists in-a
|
|||||||
(temp download → save → auto-upload) with the inotify watcher.
|
(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.
|
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
|
7. `bun scripts/release.ts --target linux-x64` produces
|
||||||
`release/linux-x64/miniSCP-Build<N>-linux-x64.tar.gz`; unpack it elsewhere and
|
`release/linux-x64/miniSCP-Build<N>-linux-x64-Setup.tar.gz`; unpack it
|
||||||
run `miniSCP.sh`.
|
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
|
||||||
@@ -179,10 +167,11 @@ either way — the tray is a convenience, and every action it offers exists in-a
|
|||||||
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.
|
||||||
@@ -197,7 +186,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/dev.ts",
|
"dev": "bun scripts/gen-version.ts && bunx electrobun prepare && bun scripts/dev.ts",
|
||||||
"dev:ui": "bun run --cwd ui start",
|
"dev:ui": "bun run --cwd ui start",
|
||||||
"build:ui": "bun run --cwd ui build",
|
"build:ui": "bun run --cwd ui build",
|
||||||
"build": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun build",
|
"build": "bun scripts/gen-version.ts && bunx electrobun prepare && bun run build:ui && bunx electrobun build",
|
||||||
"start": "bun scripts/gen-version.ts && bun run build:ui && bun scripts/start.ts",
|
"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/check-electrobun-resize.ts",
|
"postinstall": "bun scripts/gen-version.ts && bunx electrobun prepare",
|
||||||
"release": "bun scripts/release.ts"
|
"release": "bun scripts/release.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electrobun": "1.18.1",
|
|
||||||
"ssh-config": "^5.2.0",
|
"ssh-config": "^5.2.0",
|
||||||
"ssh2": "^1.17.0"
|
"ssh2": "^1.17.0"
|
||||||
},
|
},
|
||||||
@@ -24,6 +23,7 @@
|
|||||||
"@resvg/resvg-js": "^2.6.2",
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/ssh2": "^1.15.5",
|
"@types/ssh2": "^1.15.5",
|
||||||
|
"electrobun": "2.0.2",
|
||||||
"png-to-ico": "^3.0.2",
|
"png-to-ico": "^3.0.2",
|
||||||
"typescript": "~5.9.3"
|
"typescript": "~5.9.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
// Watches for electrobun growing a real window-resize gesture, so the
|
|
||||||
// synthesized one can be deleted the moment it becomes dead weight.
|
|
||||||
//
|
|
||||||
// bun scripts/check-electrobun-resize.ts (also runs from `postinstall`)
|
|
||||||
//
|
|
||||||
// WHY THIS EXISTS
|
|
||||||
//
|
|
||||||
// The win7 skin is frameless, which on GTK means undecorated, which means the
|
|
||||||
// window manager supplies no resize borders. electrobun 1.18.1 exposes no
|
|
||||||
// native resize gesture at all, so the app synthesizes one: handles in the
|
|
||||||
// webview drag the frame via RPC. That is a genuine stopgap — jerkier than a
|
|
||||||
// compositor-side grab and always will be — and the thing most likely to
|
|
||||||
// happen to a stopgap is that it outlives its reason and nobody notices.
|
|
||||||
//
|
|
||||||
// So the trigger is automated rather than a comment somebody has to re-read: an
|
|
||||||
// electrobun bump runs `postinstall`, which runs this, which prints the removal
|
|
||||||
// checklist the moment upstream ships the primitive we are waiting for.
|
|
||||||
//
|
|
||||||
// It NEVER fails the install (always exit 0) and is silent when there is
|
|
||||||
// nothing to say — a check that cries wolf gets muted, and a muted check is
|
|
||||||
// worse than none.
|
|
||||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
/** The version the synthesized resize was written against and verified on. If
|
|
||||||
* the installed version differs, this script says so once — that is the cue to
|
|
||||||
* re-check upstream's changelog and then bump this constant. */
|
|
||||||
const VALIDATED_VERSION = "1.18.1";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Symbols that would mean "upstream can resize now". Verified ABSENT in 1.18.1:
|
|
||||||
* the shipped library exports `startWindowMove`/`stopWindowMove` with no resize
|
|
||||||
* counterpart, and imports `gtk_window_begin_move_drag` but not the resize
|
|
||||||
* twin. Any of these appearing is the signal.
|
|
||||||
*
|
|
||||||
* Searched as raw ASCII in the binary rather than via `nm`/`strings`: only the
|
|
||||||
* host platform's library is installed (`dist-linux-x64/…so`, `.dylib`, `.dll`
|
|
||||||
* elsewhere), and `nm` does not exist on Windows. Symbol names live in the
|
|
||||||
* binary as plain text either way.
|
|
||||||
*/
|
|
||||||
const NATIVE_NEEDLES = [
|
|
||||||
"gtk_window_begin_resize_drag", // the GTK primitive, if they ever link it
|
|
||||||
"gtk_window_set_resizable",
|
|
||||||
"startWindowResize", // the obvious sibling of startWindowMove
|
|
||||||
"setWindowResizable",
|
|
||||||
"beginWindowResize",
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Source-level signals in the shipped TS API. */
|
|
||||||
const SOURCE_NEEDLES: { file: string; needle: string; means: string }[] = [
|
|
||||||
{
|
|
||||||
file: "dist/api/bun/core/BrowserWindow.ts",
|
|
||||||
needle: "setResizable",
|
|
||||||
means: "BrowserWindow gained a resizability setter",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "dist/api/bun/core/BrowserWindow.ts",
|
|
||||||
needle: "minWidth",
|
|
||||||
means: "the window constructor gained min-size options (our JS clamp may be redundant)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "dist/api/bun/preload/dragRegions.ts",
|
|
||||||
needle: "resize",
|
|
||||||
means: "the drag-region preload now knows about resizing",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const CHECKLIST = `
|
|
||||||
┌────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ electrobun may now support window resizing natively. │
|
|
||||||
│ The synthesized resize is a STOPGAP and should be deleted. │
|
|
||||||
│ Full checklist: │
|
|
||||||
│ ui/src/app/skins/7like/window-resize/window-resize.ts │
|
|
||||||
└────────────────────────────────────────────────────────────────────────┘`;
|
|
||||||
|
|
||||||
function main(): void {
|
|
||||||
const pkgDir = path.join(import.meta.dir, "..", "node_modules", "electrobun");
|
|
||||||
if (!existsSync(pkgDir)) return; // not installed yet (fresh clone mid-install)
|
|
||||||
|
|
||||||
const hits: string[] = [];
|
|
||||||
|
|
||||||
// --- the shipped native library, whichever platform's is installed ---
|
|
||||||
for (const entry of readdirSync(pkgDir)) {
|
|
||||||
if (!entry.startsWith("dist-")) continue;
|
|
||||||
const dir = path.join(pkgDir, entry);
|
|
||||||
for (const file of readdirSync(dir)) {
|
|
||||||
if (!/^libNativeWrapper.*\.(so|dylib|dll)$/.test(file)) continue;
|
|
||||||
// latin1: we are searching for ASCII symbol names inside a binary, and
|
|
||||||
// utf-8 decoding would mangle bytes >0x7f (and can drop invalid
|
|
||||||
// sequences), which could hide a needle sitting next to one.
|
|
||||||
const text = readFileSync(path.join(dir, file), "latin1");
|
|
||||||
for (const needle of NATIVE_NEEDLES) {
|
|
||||||
if (text.includes(needle)) hits.push(`${entry}/${file} contains ${needle}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- the shipped TypeScript API ---
|
|
||||||
for (const { file, needle, means } of SOURCE_NEEDLES) {
|
|
||||||
const full = path.join(pkgDir, file);
|
|
||||||
if (!existsSync(full)) continue;
|
|
||||||
if (readFileSync(full, "utf8").includes(needle)) hits.push(`${file} mentions ${needle} — ${means}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const version = installedVersion(pkgDir);
|
|
||||||
|
|
||||||
if (hits.length) {
|
|
||||||
console.log(CHECKLIST);
|
|
||||||
console.log(` electrobun ${version ?? "?"} — what changed:`);
|
|
||||||
for (const hit of hits) console.log(` · ${hit}`);
|
|
||||||
console.log("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No resize support. Stay quiet unless the version moved out from under the
|
|
||||||
// hack, in which case one line is the reminder to look.
|
|
||||||
if (version && version !== VALIDATED_VERSION) {
|
|
||||||
console.log(
|
|
||||||
`[electrobun] ${VALIDATED_VERSION} → ${version}: no window-resize API found, so the ` +
|
|
||||||
`synthesized resize stays. Re-check, then bump VALIDATED_VERSION in ` +
|
|
||||||
`scripts/check-electrobun-resize.ts to silence this.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function installedVersion(pkgDir: string): string | null {
|
|
||||||
try {
|
|
||||||
return JSON.parse(readFileSync(path.join(pkgDir, "package.json"), "utf8")).version ?? null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A broken check must never break an install: swallow everything, exit 0.
|
|
||||||
try {
|
|
||||||
main();
|
|
||||||
} catch (error) {
|
|
||||||
// Deliberately quiet — this is advisory. Surface it only when run by hand.
|
|
||||||
if (process.env["DEBUG"]) console.error("[electrobun] resize check failed:", error);
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
// Programmatic HOVER test for the win7 title-bar close button.
|
|
||||||
//
|
|
||||||
// bun scripts/hover-probe.ts
|
|
||||||
//
|
|
||||||
// Why this exists: hovering the X paints 7.css's red glow ::before
|
|
||||||
// (box-shadow: 0 0 7px 3px #e68e75), and a stray red mark was reported on the
|
|
||||||
// window border's far edge. Hover cannot be captured by any tool on this
|
|
||||||
// machine — Firefox --screenshot has no pointer, `import -window` grabs
|
|
||||||
// whatever the real pointer is (not) doing — so this script makes hover a
|
|
||||||
// CLASS: it copies the built stylesheet with every `:hover` rewritten to
|
|
||||||
// `.simhover`, renders the app's exact frame markup with the X force-hovered,
|
|
||||||
// and then PIXEL-SCANS the regions where the artifact could live, printing a
|
|
||||||
// verdict table. Re-run after any change to the title-bar/hover styling; the
|
|
||||||
// artifact regions must stay at zero.
|
|
||||||
//
|
|
||||||
// Inherits shot.ts's constraints: dedicated Firefox profile (a running Firefox
|
|
||||||
// holds the default profile's lock), and the page must be written INTO the
|
|
||||||
// dist folder or strict_origin_policy silently unstyles it. Gecko vs WebKitGTK
|
|
||||||
// is fine here — box-shadow/border-radius compositing is engine-agnostic.
|
|
||||||
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
const ROOT = path.join(import.meta.dir, "..");
|
|
||||||
const DIST = path.join(ROOT, "ui", "dist", "ui", "browser");
|
|
||||||
const WORK = path.join(ROOT, ".shot");
|
|
||||||
|
|
||||||
function fail(message: string): never {
|
|
||||||
console.error(`[hover-probe] ERROR: ${message}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- stylesheet with :hover rewritten to .simhover ----
|
|
||||||
|
|
||||||
if (!existsSync(DIST)) fail(`no build output at ${DIST} — run: bun run build:ui`);
|
|
||||||
const styles = readdirSync(DIST).find((f) => f.startsWith("styles-") && f.endsWith(".css"));
|
|
||||||
if (!styles) fail(`no styles-*.css in ${DIST} — run: bun run build:ui`);
|
|
||||||
|
|
||||||
const css = await Bun.file(path.join(DIST, styles)).text();
|
|
||||||
// Global on purpose: EVERY hover rule becomes assertable markup. `:hover` has
|
|
||||||
// no partial-token hazard (no `:hoverish` exists in CSS).
|
|
||||||
await Bun.write(path.join(DIST, "_probe.css"), css.replaceAll(":hover", ".simhover"));
|
|
||||||
|
|
||||||
// ---- the probe page: the app's frame markup, X force-hovered ----
|
|
||||||
|
|
||||||
// Mirrors ui/src/app/skins/7like/app.html + title-bar.ts EXACTLY where it
|
|
||||||
// matters: .window.glass.w7-frame.active with .title-bar as a DIRECT child
|
|
||||||
// (7.css couples the glass with `.window.glass > .title-bar`), controls keyed
|
|
||||||
// by aria-label. Fixed-size windows instead of h-screen so two fit one shot.
|
|
||||||
const win = (id: string, hover: boolean) => `
|
|
||||||
<div id="${id}" class="window glass w7-frame active" style="width: 420px; height: 120px; position: relative;">
|
|
||||||
<div class="title-bar electrobun-webkit-app-region-drag">
|
|
||||||
<div class="title-bar-text">miniSCP</div>
|
|
||||||
<div class="title-bar-controls">
|
|
||||||
<button aria-label="Minimize"></button>
|
|
||||||
<button aria-label="Maximize"></button>
|
|
||||||
<button aria-label="Close"${hover ? ' class="simhover"' : ""}></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="window-body" style="height: 60px"></div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const page = `<!doctype html>
|
|
||||||
<html class="win7" lang="en">
|
|
||||||
<head><meta charset="utf-8"><link rel="stylesheet" href="_probe.css">
|
|
||||||
<style>
|
|
||||||
/* checkerboard ground so semi-transparent escapes are visible over ANY tone */
|
|
||||||
body { margin: 0; padding: 40px; background: #808080; }
|
|
||||||
.slot { margin-bottom: 40px; }
|
|
||||||
</style></head>
|
|
||||||
<body>
|
|
||||||
<div class="slot">${win("control", false)}</div>
|
|
||||||
<div class="slot">${win("hovered", true)}</div>
|
|
||||||
</body></html>`;
|
|
||||||
|
|
||||||
const pagePath = path.join(DIST, "_hover-probe.html");
|
|
||||||
await Bun.write(pagePath, page);
|
|
||||||
|
|
||||||
// ---- shoot ----
|
|
||||||
|
|
||||||
mkdirSync(WORK, { recursive: true });
|
|
||||||
const profile = path.join(WORK, "profile");
|
|
||||||
mkdirSync(profile, { recursive: true });
|
|
||||||
rmSync(path.join(profile, "parent.lock"), { force: true });
|
|
||||||
|
|
||||||
const out = path.join(WORK, "hover-probe.png");
|
|
||||||
const proc = Bun.spawnSync(
|
|
||||||
["firefox", "--headless", "--profile", profile, "--screenshot", out, "--window-size", "560,420", `file://${pagePath}`],
|
|
||||||
{ cwd: ROOT, stdout: "pipe", stderr: "pipe" },
|
|
||||||
);
|
|
||||||
if (!existsSync(out)) {
|
|
||||||
console.error(proc.stderr.toString().trim());
|
|
||||||
fail("firefox produced no image");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- pixel scan ----
|
|
||||||
|
|
||||||
// Page geometry (CSS px = image px; no DPR scaling in headless Firefox):
|
|
||||||
// body padding 40, window at x:40..460 (width 420), first window y:40..160,
|
|
||||||
// second y:200..320. The window's own 1px border is AT x=40 and x=459.
|
|
||||||
const WIN_X = 40;
|
|
||||||
const WIN_W = 420;
|
|
||||||
const CONTROL_Y = 40;
|
|
||||||
const HOVER_Y = 200;
|
|
||||||
const WIN_H = 120;
|
|
||||||
|
|
||||||
interface Region {
|
|
||||||
name: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
w: number;
|
|
||||||
h: number;
|
|
||||||
artifact: boolean; // true = red here is a DEFECT; false = red here is the intended glow
|
|
||||||
}
|
|
||||||
|
|
||||||
// The close button: last control, min-width 48px (3rem), controls end 6px
|
|
||||||
// (--w7-w-space padding) from the window's right edge — so the button spans
|
|
||||||
// roughly x = right-6-48 .. right-6. The glow reaches up to blur+spread = 10px
|
|
||||||
// beyond any edge of it.
|
|
||||||
const regions = (top: number): Region[] => [
|
|
||||||
// the window's right border column (2px wide: border + inset ring), full title-bar height
|
|
||||||
{ name: "right border (A)", x: WIN_X + WIN_W - 2, y: top + 2, w: 2, h: 30, artifact: true },
|
|
||||||
// OUTSIDE the window entirely, right of the border, title-bar height
|
|
||||||
{ name: "outside right (B)", x: WIN_X + WIN_W, y: top, w: 12, h: 34, artifact: true },
|
|
||||||
// OUTSIDE above the top edge / rounded corner
|
|
||||||
{ name: "outside top (B)", x: WIN_X + WIN_W - 80, y: top - 12, w: 92, h: 12, artifact: true },
|
|
||||||
// the gutter between the button and the border — glow HERE is the intended look
|
|
||||||
{ name: "gutter (glow ok)", x: WIN_X + WIN_W - 7, y: top + 2, w: 5, h: 30, artifact: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
async function redCount(img: string, r: Region): Promise<number> {
|
|
||||||
// txt: gives one "x,y: (r,g,b[,a])" line per pixel
|
|
||||||
const p = Bun.spawnSync(
|
|
||||||
["magick", img, "-crop", `${r.w}x${r.h}+${r.x}+${r.y}`, "+repage", "txt:"],
|
|
||||||
{ stdout: "pipe", stderr: "pipe" },
|
|
||||||
);
|
|
||||||
let count = 0;
|
|
||||||
for (const line of p.stdout.toString().split("\n").slice(1)) {
|
|
||||||
const m = /\((\d+),(\d+),(\d+)/.exec(line);
|
|
||||||
if (!m) continue;
|
|
||||||
const [red, green, blue] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
||||||
// "red" = the red channel dominating both others decisively. The Aero
|
|
||||||
// chrome is blue/white, so any genuine red here is the glow.
|
|
||||||
if (red > 120 && red > green + 30 && red > blue + 30) count++;
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("[hover-probe] region scan (red-dominant pixels):\n");
|
|
||||||
console.log(" region control hovered verdict");
|
|
||||||
let defects = 0;
|
|
||||||
for (const [i, region] of regions(CONTROL_Y).entries()) {
|
|
||||||
const hoverRegion = regions(HOVER_Y)[i];
|
|
||||||
const inControl = await redCount(out, region);
|
|
||||||
const inHover = await redCount(out, hoverRegion);
|
|
||||||
const introduced = inHover - inControl;
|
|
||||||
let verdict: string;
|
|
||||||
if (!region.artifact) verdict = introduced > 0 ? "glow present (intended)" : "no glow";
|
|
||||||
else if (introduced > 0) {
|
|
||||||
verdict = "DEFECT: red introduced by hover";
|
|
||||||
defects++;
|
|
||||||
} else verdict = "clean";
|
|
||||||
console.log(
|
|
||||||
` ${region.name.padEnd(20)}${String(inControl).padStart(7)}${String(inHover).padStart(9)} ${verdict}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
console.log(`\n[hover-probe] image: ${out}`);
|
|
||||||
if (defects > 0) {
|
|
||||||
console.log(`[hover-probe] ${defects} artifact region(s) show hover-introduced red`);
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
console.log("[hover-probe] clean");
|
|
||||||
@@ -7,9 +7,10 @@
|
|||||||
// It has to be in the environment BEFORE the app process starts: WebKit reads
|
// 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
|
// 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
|
// propagate to children (verified — a child sees the variable unset), so the
|
||||||
// app cannot set this for itself from inside src/bun/. Every launch path has to
|
// app cannot set this for itself from inside src/bun/. Only the dev launch
|
||||||
// inject it: the dev scripts here, and the wrapper script that release.ts ships
|
// paths (`bun run dev` / `bun run start`) inject it; an installed build (the
|
||||||
// beside the packaged launcher.
|
// 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
|
// Scoped to NVIDIA so Intel/AMD keep hardware compositing, and an explicit
|
||||||
// value from the user's environment always wins.
|
// value from the user's environment always wins.
|
||||||
|
|||||||
@@ -1,225 +0,0 @@
|
|||||||
// Programmatic POSITIONING + HIGHLIGHT test for the win7 context menu.
|
|
||||||
//
|
|
||||||
// bun scripts/menu-probe.ts
|
|
||||||
//
|
|
||||||
// Why this exists: 7.css declares `position: relative` on ul[role=menu] (it
|
|
||||||
// needs it for the gutter stripe and submenus), and the `skin` layer outranks
|
|
||||||
// `utilities` — so Tailwind's `.fixed` on the app's own menu lost, its
|
|
||||||
// [style.left/top.px] became FLOW offsets, and every right-click menu rendered
|
|
||||||
// below the viewport of the h-screen frame. Nothing about the idle window
|
|
||||||
// changes when that breaks, so the screenshot harness (scripts/shot.ts) cannot
|
|
||||||
// see it, and hover-probe.ts tests a different axis. This script renders the
|
|
||||||
// menu markup at declared coordinates and pixel-scans whether it actually
|
|
||||||
// landed there.
|
|
||||||
//
|
|
||||||
// It carries its own control: a second menu WITHOUT the `w7-ctx` marker class,
|
|
||||||
// i.e. the bug verbatim. That one must stay invisible — if it ever shows up,
|
|
||||||
// 7.css stopped forcing `relative` and this probe's premise is stale.
|
|
||||||
//
|
|
||||||
// It also checks the item HIGHLIGHT, using hover-probe.ts's trick (`:hover`
|
|
||||||
// rewritten to a `.simhover` class, since headless Firefox has no pointer):
|
|
||||||
// 7.css paints hovered items with a translucent gradient wash, and this app
|
|
||||||
// wants one flat colour. A gradient is detectable as vertical colour spread
|
|
||||||
// down a text-free strip of the item, so "is it flat?" is a measurement, not
|
|
||||||
// an opinion. What canNOT be measured here is the 1s afterglow fade — a
|
|
||||||
// temporal effect — but its cause is structural (`button::before` still has
|
|
||||||
// `content`), and this probe would show the glow's colour if the pseudo ever
|
|
||||||
// came back.
|
|
||||||
//
|
|
||||||
// Inherits shot.ts's constraints: dedicated Firefox profile (a running Firefox
|
|
||||||
// holds the default profile's lock), and the page must be written INTO the
|
|
||||||
// dist folder or strict_origin_policy silently unstyles it.
|
|
||||||
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
const ROOT = path.join(import.meta.dir, "..");
|
|
||||||
const DIST = path.join(ROOT, "ui", "dist", "ui", "browser");
|
|
||||||
const WORK = path.join(ROOT, ".shot");
|
|
||||||
|
|
||||||
function fail(message: string): never {
|
|
||||||
console.error(`[menu-probe] ERROR: ${message}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existsSync(DIST)) fail(`no build output at ${DIST} — run: bun run build:ui`);
|
|
||||||
const styles = readdirSync(DIST).find((f) => f.startsWith("styles-") && f.endsWith(".css"));
|
|
||||||
if (!styles) fail(`no styles-*.css in ${DIST} — run: bun run build:ui`);
|
|
||||||
|
|
||||||
// Hover as a CLASS: same rewrite hover-probe.ts uses, for the same reason —
|
|
||||||
// `--screenshot` has no pointer. `:hover` has no partial-token hazard.
|
|
||||||
const css = await Bun.file(path.join(DIST, styles)).text();
|
|
||||||
const PROBE_CSS = "_menu-probe.css";
|
|
||||||
await Bun.write(path.join(DIST, PROBE_CSS), css.replaceAll(":hover", ".simhover"));
|
|
||||||
|
|
||||||
// ---- the probe page ----
|
|
||||||
|
|
||||||
// Geometry (CSS px = image px; headless Firefox applies no DPR scaling).
|
|
||||||
const MENU_X = 200;
|
|
||||||
const MENU_Y = 100;
|
|
||||||
const CONTROL_X = 560;
|
|
||||||
const MENU_W = 200;
|
|
||||||
|
|
||||||
// Mirrors ui/src/app/skins/7like/context-menu.ts EXACTLY where it matters:
|
|
||||||
// ul[role=menu] > li[role=menuitem] > button, the `w7-ctx fixed` class pair,
|
|
||||||
// and left/top as inline pixel styles. `w7-ctx` is what the skinfix rule in
|
|
||||||
// 7like.css keys on; the control deliberately omits it.
|
|
||||||
const menu = (x: number, marker: boolean) => `
|
|
||||||
<ul role="menu" tabindex="-1"
|
|
||||||
class="${marker ? "w7-ctx " : ""}fixed z-50 outline-none"
|
|
||||||
style="left: ${x}px; top: ${MENU_Y}px; width: ${MENU_W}px">
|
|
||||||
<li role="menuitem"><button type="button"${marker ? ' class="simhover"' : ""}>Send to Other Panel</button></li>
|
|
||||||
<li role="menuitem" class="has-divider"><button type="button">Rename</button></li>
|
|
||||||
<li role="menuitem"><button type="button">Copy Path</button></li>
|
|
||||||
<li role="menuitem" aria-disabled="true"><button type="button">Delete</button></li>
|
|
||||||
</ul>`;
|
|
||||||
|
|
||||||
// The frame reproduces the app's: .window.w7-frame is a 100vh flex column and
|
|
||||||
// <app-context-menu> is one of its LAST children, so a menu that falls back to
|
|
||||||
// static flow lands past the bottom edge — which is the whole bug.
|
|
||||||
// The body gets an explicit white background (inline, so no layer can touch
|
|
||||||
// it) purely so "is anything drawn here?" is an unambiguous question.
|
|
||||||
const page = `<!doctype html>
|
|
||||||
<html class="win7" lang="en">
|
|
||||||
<head><meta charset="utf-8"><link rel="stylesheet" href="${PROBE_CSS}">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="window glass w7-frame active" style="display: flex; flex-direction: column; height: 100vh">
|
|
||||||
<div class="title-bar">
|
|
||||||
<div class="title-bar-text">miniSCP</div>
|
|
||||||
<div class="title-bar-controls"><button aria-label="Close"></button></div>
|
|
||||||
</div>
|
|
||||||
<div class="window-body" style="flex: 1; min-height: 0; background: #fff"></div>
|
|
||||||
${menu(MENU_X, true)}
|
|
||||||
${menu(CONTROL_X, false)}
|
|
||||||
</div>
|
|
||||||
</body></html>`;
|
|
||||||
|
|
||||||
const pagePath = path.join(DIST, "_menu-probe.html");
|
|
||||||
await Bun.write(pagePath, page);
|
|
||||||
|
|
||||||
// ---- shoot ----
|
|
||||||
|
|
||||||
mkdirSync(WORK, { recursive: true });
|
|
||||||
const profile = path.join(WORK, "profile");
|
|
||||||
mkdirSync(profile, { recursive: true });
|
|
||||||
rmSync(path.join(profile, "parent.lock"), { force: true });
|
|
||||||
|
|
||||||
const out = path.join(WORK, "menu-probe.png");
|
|
||||||
const proc = Bun.spawnSync(
|
|
||||||
["firefox", "--headless", "--profile", profile, "--screenshot", out, "--window-size", "800,400", `file://${pagePath}`],
|
|
||||||
{ cwd: ROOT, stdout: "pipe", stderr: "pipe" },
|
|
||||||
);
|
|
||||||
if (!existsSync(out)) {
|
|
||||||
console.error(proc.stderr.toString().trim());
|
|
||||||
fail("firefox produced no image");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- pixel scan ----
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count pixels that are visibly NOT the white window body: the menu's border
|
|
||||||
* (#0006 over white ≈ #999), its shadow and its black item text all qualify.
|
|
||||||
* A menu that rendered where it was told fills this with hundreds of them; an
|
|
||||||
* empty white region scores 0.
|
|
||||||
*/
|
|
||||||
async function inkCount(img: string, x: number, y: number, w: number, h: number): Promise<number> {
|
|
||||||
const p = Bun.spawnSync(["magick", img, "-crop", `${w}x${h}+${x}+${y}`, "+repage", "txt:"], {
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
});
|
|
||||||
let count = 0;
|
|
||||||
for (const line of p.stdout.toString().split("\n").slice(1)) {
|
|
||||||
const m = /\((\d+),(\d+),(\d+)/.exec(line);
|
|
||||||
if (!m) continue;
|
|
||||||
const [r, g, b] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
||||||
if (Math.max(r, g, b) < 200) count++;
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Colour spread down a text-free strip of the first (force-hovered) item: the
|
|
||||||
* ul's own 2px padding + the item's 1px border put the item's background at
|
|
||||||
* x >= menu+3, and its 32px left padding keeps label text out to menu+32. So a
|
|
||||||
* column at menu+5..menu+26 is pure fill — except for the gutter stripe, which
|
|
||||||
* 7.css puts at left: 28px. A flat colour reads 0 spread; a gradient does not.
|
|
||||||
*/
|
|
||||||
async function fillSpread(
|
|
||||||
img: string,
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
w: number,
|
|
||||||
h: number,
|
|
||||||
): Promise<{ spread: number; mean: [number, number, number] }> {
|
|
||||||
const p = Bun.spawnSync(["magick", img, "-crop", `${w}x${h}+${x}+${y}`, "+repage", "txt:"], {
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
});
|
|
||||||
const lo: number[] = [255, 255, 255];
|
|
||||||
const hi: number[] = [0, 0, 0];
|
|
||||||
const sum = [0, 0, 0];
|
|
||||||
let n = 0;
|
|
||||||
for (const line of p.stdout.toString().split("\n").slice(1)) {
|
|
||||||
const m = /\((\d+),(\d+),(\d+)/.exec(line);
|
|
||||||
if (!m) continue;
|
|
||||||
for (let c = 0; c < 3; c++) {
|
|
||||||
const v = Number(m[c + 1]);
|
|
||||||
lo[c] = Math.min(lo[c], v);
|
|
||||||
hi[c] = Math.max(hi[c], v);
|
|
||||||
sum[c] += v;
|
|
||||||
}
|
|
||||||
n++;
|
|
||||||
}
|
|
||||||
if (!n) return { spread: -1, mean: [0, 0, 0] };
|
|
||||||
return {
|
|
||||||
spread: Math.max(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]),
|
|
||||||
mean: [Math.round(sum[0] / n), Math.round(sum[1] / n), Math.round(sum[2] / n)],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inset by 2px so the scan cannot straddle the region's own edge.
|
|
||||||
const BOX = { w: MENU_W - 4, h: 60 };
|
|
||||||
const placed = await inkCount(out, MENU_X + 2, MENU_Y + 2, BOX.w, BOX.h);
|
|
||||||
const control = await inkCount(out, CONTROL_X + 2, MENU_Y + 2, BOX.w, BOX.h);
|
|
||||||
|
|
||||||
console.log("[menu-probe] ink at the declared coordinates:\n");
|
|
||||||
console.log(" menu px verdict");
|
|
||||||
console.log(
|
|
||||||
` w7-ctx (position: fixed)${String(placed).padStart(6)} ${placed > 0 ? "placed" : "MISSING: menu did not render where it was told"}`,
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
` control (no marker) ${String(control).padStart(6)} ${control > 0 ? "NOTE: 7.css no longer forces relative — premise stale" : "off-viewport, as expected"}`,
|
|
||||||
);
|
|
||||||
// The force-hovered first item: fill strip, and the same strip on the item
|
|
||||||
// below it as a not-hovered reference (which must stay the menu surface).
|
|
||||||
const FILL = { x: MENU_X + 5, w: 21, h: 14 };
|
|
||||||
const hovered = await fillSpread(out, FILL.x, MENU_Y + 7, FILL.w, FILL.h);
|
|
||||||
const resting = await fillSpread(out, FILL.x, MENU_Y + 34, FILL.w, FILL.h);
|
|
||||||
const FLAT_MAX = 6; // antialiasing tolerance; 7.css's wash spreads far wider
|
|
||||||
|
|
||||||
console.log("\n[menu-probe] item highlight (force-hovered vs resting):\n");
|
|
||||||
console.log(" item mean rgb spread verdict");
|
|
||||||
console.log(
|
|
||||||
` hovered (.simhover) ${hovered.mean.join(",").padEnd(15)} ${String(hovered.spread).padStart(6)} ${
|
|
||||||
hovered.spread <= FLAT_MAX ? "flat" : "GRADIENT: highlight is not a flat colour"
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
console.log(` resting ${resting.mean.join(",").padEnd(15)} ${String(resting.spread).padStart(6)} reference`);
|
|
||||||
|
|
||||||
console.log(`\n[menu-probe] image: ${out}`);
|
|
||||||
|
|
||||||
let failed = false;
|
|
||||||
if (placed === 0) {
|
|
||||||
console.log("[menu-probe] the context menu is positioned in FLOW again — see 7like.css .w7-ctx");
|
|
||||||
failed = true;
|
|
||||||
}
|
|
||||||
if (hovered.spread > FLAT_MAX) {
|
|
||||||
console.log("[menu-probe] the item highlight regained a gradient — see the menu block in 7like.css");
|
|
||||||
failed = true;
|
|
||||||
}
|
|
||||||
if (hovered.mean.join(",") === resting.mean.join(",")) {
|
|
||||||
console.log("[menu-probe] hovered and resting items are identical — the highlight is not painting at all");
|
|
||||||
failed = true;
|
|
||||||
}
|
|
||||||
if (failed) process.exit(2);
|
|
||||||
console.log("[menu-probe] clean");
|
|
||||||
+38
-89
@@ -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|linux-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, writeFileSync } from "node:fs";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
const ROOT = path.join(import.meta.dir, "..");
|
const ROOT = path.join(import.meta.dir, "..");
|
||||||
const TARGETS = ["mac-arm64", "mac-x64", "win-x64", "linux-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";
|
||||||
if (process.platform === "linux") return "linux-x64";
|
if (process.platform === "linux") return "linux-x64";
|
||||||
return process.arch === "arm64" ? "mac-arm64" : "mac-x64";
|
return "mac-arm64";
|
||||||
}
|
}
|
||||||
|
|
||||||
function fail(message: string): never {
|
function fail(message: string): never {
|
||||||
@@ -38,8 +36,10 @@ 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.");
|
||||||
@@ -47,14 +47,14 @@ if (requested === "win-x64" && process.platform !== "win32") {
|
|||||||
if (requested === "linux-x64" && process.platform !== "linux") {
|
if (requested === "linux-x64" && process.platform !== "linux") {
|
||||||
fail("linux-x64 must be built on a Linux machine/runner — electrobun cannot cross-compile.");
|
fail("linux-x64 must be built on a Linux machine/runner — electrobun cannot cross-compile.");
|
||||||
}
|
}
|
||||||
if (requested.startsWith("mac") && process.platform !== "darwin") {
|
if (requested === "mac-arm64" && (process.platform !== "darwin" || process.arch !== "arm64")) {
|
||||||
fail(`${requested} must be built on macOS.`);
|
fail("mac-arm64 must be built on an Apple Silicon Mac (Electrobun 2.x has no macOS x64 build).");
|
||||||
}
|
}
|
||||||
const viaRosetta = requested === "mac-x64" && process.arch === "arm64";
|
|
||||||
|
|
||||||
// ---- prepare: version, icons, UI bundle ----
|
// ---- prepare: version, devkit, icons, UI bundle ----
|
||||||
|
|
||||||
run(["bun", "scripts/gen-version.ts"]);
|
run(["bun", "scripts/gen-version.ts"]);
|
||||||
|
run(["bunx", "electrobun", "prepare"]);
|
||||||
run(["bun", "scripts/gen-icons.ts"]);
|
run(["bun", "scripts/gen-icons.ts"]);
|
||||||
run(["bun", "run", "--cwd", "ui", "build"]);
|
run(["bun", "run", "--cwd", "ui", "build"]);
|
||||||
|
|
||||||
@@ -62,98 +62,47 @@ const { BUILD } = await import("../src/shared/version.gen");
|
|||||||
const { BRANDING } = await import("../src/shared/branding");
|
const { BRANDING } = await import("../src/shared/branding");
|
||||||
const buildName = `Build${BUILD.number}`;
|
const buildName = `Build${BUILD.number}`;
|
||||||
|
|
||||||
// ---- electrobun build (native, or x64 CLI under Rosetta) ----
|
// ---- electrobun build ----
|
||||||
|
|
||||||
async function x64Cli(): Promise<string> {
|
// Start from an empty artifacts/: site/deploy.sh publishes it verbatim (the
|
||||||
const pkg = await Bun.file(path.join(ROOT, "node_modules", "electrobun", "package.json")).json();
|
// updater contract), so an earlier build's files must not ride along.
|
||||||
const dir = path.join(os.homedir(), ".cache", "miniscp", `electrobun-cli-x64-${pkg.version}`);
|
const artifactsDir = path.join(ROOT, "artifacts");
|
||||||
const bin = path.join(dir, "electrobun");
|
rmSync(artifactsDir, { recursive: true, force: true });
|
||||||
if (existsSync(bin)) return bin;
|
run(["bunx", "electrobun", "build", "--env=stable"]);
|
||||||
console.log(`[release] downloading electrobun x64 CLI ${pkg.version} (one-time)…`);
|
|
||||||
mkdirSync(dir, { recursive: true });
|
|
||||||
const url = `https://github.com/blackboardsh/electrobun/releases/download/v${pkg.version}/electrobun-cli-darwin-x64.tar.gz`;
|
|
||||||
const tarball = path.join(dir, "cli.tar.gz");
|
|
||||||
run(["curl", "-fsSL", "-o", tarball, url]);
|
|
||||||
run(["tar", "-xzf", tarball, "-C", dir]);
|
|
||||||
if (!existsSync(bin)) fail(`x64 CLI tarball did not contain "electrobun" (${dir})`);
|
|
||||||
run(["chmod", "+x", bin]);
|
|
||||||
return bin;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (viaRosetta) {
|
|
||||||
// x64 Mach-O runs under Rosetta transparently; it sees process.arch === x64
|
|
||||||
const rosetta = Bun.spawnSync(["arch", "-x86_64", "true"]);
|
|
||||||
if (!rosetta.success) {
|
|
||||||
fail("Rosetta 2 is not installed — run: softwareupdate --install-rosetta --agree-to-license");
|
|
||||||
}
|
|
||||||
run([await x64Cli(), "build", "--env=stable"]);
|
|
||||||
} else {
|
|
||||||
run(["bunx", "electrobun", "build", "--env=stable"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- collect artifacts ----
|
// ---- collect artifacts ----
|
||||||
|
|
||||||
const [osName, archName] =
|
const [osName, archName] =
|
||||||
requested === "win-x64"
|
requested === "win-x64" ? ["win", "x64"] : requested === "linux-x64" ? ["linux", "x64"] : ["macos", "arm64"];
|
||||||
? ["win", "x64"]
|
// 2.x installers carry no channel prefix (macos-arm64-miniSCP.dmg); only the
|
||||||
: requested === "linux-x64"
|
// update feed (stable-<os>-<arch>-*) keeps it, for v1-compatible clients.
|
||||||
? ["linux", "x64"]
|
const prefix = `${osName}-${archName}-`;
|
||||||
: ["macos", requested.slice(4)];
|
|
||||||
const prefix = `stable-${osName}-${archName}-`;
|
|
||||||
const artifactsDir = path.join(ROOT, "artifacts");
|
|
||||||
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 / Linux: package the assembled app folder directly. On Windows that
|
// Windows: also zip the assembled app folder — the portable build (unzip
|
||||||
// complements the Setup.exe installer (portable = unzip anywhere and run); on
|
// anywhere and run) next to the Setup installer. Best-effort: the folder
|
||||||
// Linux it is the ONLY artifact, since electrobun emits just the .tar.zst
|
// layout is confirmed by the first real 2.x Windows build.
|
||||||
// updater blob there — no installer, AppImage or .deb. Best-effort: the folder
|
if (requested === "win-x64") {
|
||||||
// layout is confirmed by the first real build on each OS.
|
const appDir = path.join(ROOT, "build", "stable-win-x64", BRANDING.appName);
|
||||||
if (requested === "win-x64" || requested === "linux-x64") {
|
|
||||||
const appDir = path.join(ROOT, "build", `stable-${osName}-${archName}`, BRANDING.appName);
|
|
||||||
if (existsSync(appDir)) {
|
if (existsSync(appDir)) {
|
||||||
if (requested === "win-x64") {
|
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}'`]);
|
out.push(path.basename(portable));
|
||||||
out.push(path.basename(portable));
|
|
||||||
} else {
|
|
||||||
// Ship a wrapper beside the launcher: the packaged app never goes
|
|
||||||
// through scripts/start.ts, and the NVIDIA/DMABUF workaround has to be
|
|
||||||
// in the environment BEFORE the process starts (see scripts/linux-gpu.ts).
|
|
||||||
const wrapper = path.join(appDir, `${BRANDING.appName}.sh`);
|
|
||||||
writeFileSync(
|
|
||||||
wrapper,
|
|
||||||
[
|
|
||||||
"#!/bin/sh",
|
|
||||||
"# WebKitGTK's DMABUF renderer cannot allocate GBM buffers on the NVIDIA",
|
|
||||||
"# proprietary driver — the window renders black. Disable it there only.",
|
|
||||||
'if [ -z "$WEBKIT_DISABLE_DMABUF_RENDERER" ] && [ -e /proc/driver/nvidia ]; then',
|
|
||||||
" export WEBKIT_DISABLE_DMABUF_RENDERER=1",
|
|
||||||
"fi",
|
|
||||||
'exec "$(dirname "$(readlink -f "$0")")/bin/launcher" "$@"',
|
|
||||||
"",
|
|
||||||
].join("\n"),
|
|
||||||
{ mode: 0o755 },
|
|
||||||
);
|
|
||||||
const tarball = path.join(outDir, `${BRANDING.appName}-${buildName}-linux-x64.tar.gz`);
|
|
||||||
// -C the parent so the archive unpacks into one named folder
|
|
||||||
run(["tar", "-czf", tarball, "-C", path.dirname(appDir), path.basename(appDir)]);
|
|
||||||
out.push(path.basename(tarball));
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
console.warn(`[release] WARN: portable skipped — app folder not found at ${appDir}`);
|
console.warn(`[release] WARN: portable skipped — app folder not found at ${appDir}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
// Programmatic test for the synthesized window resize's frame arithmetic.
|
|
||||||
//
|
|
||||||
// bun scripts/resize-math-probe.ts
|
|
||||||
//
|
|
||||||
// Why this exists: the win7 skin resizes itself by dragging handles and moving
|
|
||||||
// the native frame (see ui/src/app/skins/7like/window-resize/window-resize.ts —
|
|
||||||
// a STOPGAP with a removal checklist). Pointer drags cannot be synthesized
|
|
||||||
// headlessly on this machine, so the interaction itself needs a human; but the
|
|
||||||
// part most likely to be subtly wrong is pure arithmetic — eight edges, four of
|
|
||||||
// which move the window's ORIGIN as well as its size, plus a minimum-size clamp
|
|
||||||
// that must not let the anchored edge drift. That is all in `resize-math.ts`,
|
|
||||||
// which is deliberately dependency-free so this script can drive it directly.
|
|
||||||
//
|
|
||||||
// The failure this guards against is not "the window does not resize" (obvious
|
|
||||||
// on sight) but "dragging the west edge inward past the minimum walks the whole
|
|
||||||
// window across the screen" — correct-looking until you hit the limit.
|
|
||||||
//
|
|
||||||
// This covers MORE than the UI currently mounts, deliberately: only the
|
|
||||||
// bottom-right grip is wired up (an invisible handle over a caption button
|
|
||||||
// steals its hover — see the component), but the math stays general so that
|
|
||||||
// restoring a side later is one line against already-proven arithmetic.
|
|
||||||
import {
|
|
||||||
MIN_HEIGHT,
|
|
||||||
MIN_WIDTH,
|
|
||||||
RESIZE_EDGES,
|
|
||||||
type Frame,
|
|
||||||
type ResizeEdge,
|
|
||||||
resizeFrame,
|
|
||||||
} from "../ui/src/app/skins/7like/window-resize/resize-math";
|
|
||||||
|
|
||||||
const START: Frame = { x: 100, y: 200, width: 1000, height: 800 };
|
|
||||||
|
|
||||||
interface Case {
|
|
||||||
name: string;
|
|
||||||
edge: ResizeEdge;
|
|
||||||
dx: number;
|
|
||||||
dy: number;
|
|
||||||
want: Frame;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every expectation is written out in full rather than computed, so a bug in
|
|
||||||
// resizeFrame cannot be mirrored into the expectation by sharing its algebra.
|
|
||||||
const CASES: Case[] = [
|
|
||||||
// --- edges: each moves only what it owns ---
|
|
||||||
{ name: "east grows width only", edge: "e", dx: 50, dy: 999, want: { x: 100, y: 200, width: 1050, height: 800 } },
|
|
||||||
{ name: "south grows height only", edge: "s", dx: 999, dy: 40, want: { x: 100, y: 200, width: 1000, height: 840 } },
|
|
||||||
{ name: "west moves x AND width", edge: "w", dx: -30, dy: 0, want: { x: 70, y: 200, width: 1030, height: 800 } },
|
|
||||||
{ name: "north moves y AND height", edge: "n", dx: 0, dy: -25, want: { x: 100, y: 175, width: 1000, height: 825 } },
|
|
||||||
// --- corners: two axes at once ---
|
|
||||||
{ name: "south-east", edge: "se", dx: 10, dy: 20, want: { x: 100, y: 200, width: 1010, height: 820 } },
|
|
||||||
{ name: "north-west", edge: "nw", dx: -10, dy: -20, want: { x: 90, y: 180, width: 1010, height: 820 } },
|
|
||||||
{ name: "north-east", edge: "ne", dx: 10, dy: -20, want: { x: 100, y: 180, width: 1010, height: 820 } },
|
|
||||||
{ name: "south-west", edge: "sw", dx: -10, dy: 20, want: { x: 90, y: 200, width: 1010, height: 820 } },
|
|
||||||
// --- the anchored edge must not drift once clamped ---
|
|
||||||
// west dragged far east: width parks at MIN, and x parks where the EAST edge
|
|
||||||
// (100 + 1000 = 1100) minus MIN puts it. If x kept tracking dx, the window
|
|
||||||
// would slide away while appearing frozen.
|
|
||||||
{
|
|
||||||
name: "west clamps, east edge holds",
|
|
||||||
edge: "w",
|
|
||||||
dx: 5000,
|
|
||||||
dy: 0,
|
|
||||||
want: { x: 1100 - MIN_WIDTH, y: 200, width: MIN_WIDTH, height: 800 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "north clamps, south edge holds",
|
|
||||||
edge: "n",
|
|
||||||
dx: 0,
|
|
||||||
dy: 5000,
|
|
||||||
want: { x: 100, y: 1000 - MIN_HEIGHT, width: 1000, height: MIN_HEIGHT },
|
|
||||||
},
|
|
||||||
// east/south clamp the size but never touch the origin
|
|
||||||
{ name: "east clamps, origin holds", edge: "e", dx: -5000, dy: 0, want: { x: 100, y: 200, width: MIN_WIDTH, height: 800 } },
|
|
||||||
{ name: "south clamps, origin holds", edge: "s", dx: 0, dy: -5000, want: { x: 100, y: 200, width: 1000, height: MIN_HEIGHT } },
|
|
||||||
// a corner clamping on BOTH axes at once
|
|
||||||
{
|
|
||||||
name: "north-west clamps both",
|
|
||||||
edge: "nw",
|
|
||||||
dx: 5000,
|
|
||||||
dy: 5000,
|
|
||||||
want: { x: 1100 - MIN_WIDTH, y: 1000 - MIN_HEIGHT, width: MIN_WIDTH, height: MIN_HEIGHT },
|
|
||||||
},
|
|
||||||
// a zero drag must be a no-op, or a click on a handle would nudge the window
|
|
||||||
{ name: "zero drag is identity", edge: "se", dx: 0, dy: 0, want: START },
|
|
||||||
];
|
|
||||||
|
|
||||||
const fmt = (f: Frame) => `${f.x},${f.y} ${f.width}x${f.height}`;
|
|
||||||
|
|
||||||
console.log("[resize-math] frame arithmetic:\n");
|
|
||||||
console.log(" case edge got verdict");
|
|
||||||
let failed = 0;
|
|
||||||
for (const c of CASES) {
|
|
||||||
const got = resizeFrame(c.edge, START, c.dx, c.dy);
|
|
||||||
const ok = fmt(got) === fmt(c.want);
|
|
||||||
if (!ok) failed++;
|
|
||||||
console.log(
|
|
||||||
` ${c.name.padEnd(32)} ${c.edge.padEnd(5)} ${fmt(got).padEnd(22)} ${ok ? "ok" : `FAIL: want ${fmt(c.want)}`}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Structural check: every edge must be exercised above, so adding a ninth
|
|
||||||
// handle without a case cannot slip through.
|
|
||||||
const covered = new Set(CASES.map((c) => c.edge));
|
|
||||||
const missing = RESIZE_EDGES.filter((e) => !covered.has(e));
|
|
||||||
if (missing.length) {
|
|
||||||
console.log(`\n[resize-math] no case covers: ${missing.join(", ")}`);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The clamp must never produce a frame smaller than the minimum, whatever the
|
|
||||||
// drag — a cheap sweep over every edge and a range of deltas.
|
|
||||||
for (const edge of RESIZE_EDGES) {
|
|
||||||
for (const d of [-9999, -1000, -1, 0, 1, 1000, 9999]) {
|
|
||||||
const got = resizeFrame(edge, START, d, d);
|
|
||||||
if (got.width < MIN_WIDTH || got.height < MIN_HEIGHT) {
|
|
||||||
console.log(`\n[resize-math] ${edge} at delta ${d} produced ${fmt(got)} — under the minimum`);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failed) {
|
|
||||||
console.log(`\n[resize-math] ${failed} failure(s)`);
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
console.log("\n[resize-math] clean");
|
|
||||||
-106
@@ -1,106 +0,0 @@
|
|||||||
// Headless screenshot of the skin harness, so the Win7 look can be reviewed
|
|
||||||
// visually during development rather than inferred from a clean build log.
|
|
||||||
//
|
|
||||||
// bun scripts/shot.ts [--dark] [--width 1200] [--height 1400] [--out path.png]
|
|
||||||
// bun scripts/shot.ts --url http://localhost:4200 (any page, not just the harness)
|
|
||||||
//
|
|
||||||
// Firefox is used because it ships on this machine and has a built-in
|
|
||||||
// --screenshot mode; no Chromium/puppeteer download is needed. Two constraints
|
|
||||||
// it imposes:
|
|
||||||
// - a DEDICATED PROFILE is mandatory. A running Firefox holds a lock on the
|
|
||||||
// default profile and the headless run aborts with "Firefox is already
|
|
||||||
// running, but is not responding".
|
|
||||||
// - it renders with Gecko, NOT the WebKitGTK engine the app actually uses.
|
|
||||||
// Good enough for skin fidelity (layout, colour, gradients, borders);
|
|
||||||
// useless for engine-specific behaviour or native window transparency.
|
|
||||||
// Real confirmation is still `bun run start`.
|
|
||||||
//
|
|
||||||
// The harness links the REAL hashed stylesheet from ui/dist (produced by the
|
|
||||||
// normal `bun run build:ui`), so there is no second build pipeline to keep in
|
|
||||||
// sync.
|
|
||||||
//
|
|
||||||
// The composed page is written INTO that same dist folder, and this is not
|
|
||||||
// incidental: Firefox's security.fileuri.strict_origin_policy stops a file://
|
|
||||||
// document from loading a stylesheet that lives in a different directory. Host
|
|
||||||
// the page elsewhere and it renders completely unstyled — which looks exactly
|
|
||||||
// like "the skin is broken" rather than "the CSS never loaded". Keeping them
|
|
||||||
// co-located also lets media/ fonts resolve.
|
|
||||||
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
const ROOT = path.join(import.meta.dir, "..");
|
|
||||||
const DIST = path.join(ROOT, "ui", "dist", "ui", "browser");
|
|
||||||
const HARNESS = path.join(ROOT, "ui", "dev", "harness.html");
|
|
||||||
const WORK = path.join(ROOT, ".shot");
|
|
||||||
|
|
||||||
function arg(name: string): string | undefined {
|
|
||||||
const i = process.argv.indexOf(`--${name}`);
|
|
||||||
return i >= 0 ? process.argv[i + 1] : undefined;
|
|
||||||
}
|
|
||||||
const flag = (name: string) => process.argv.includes(`--${name}`);
|
|
||||||
|
|
||||||
function fail(message: string): never {
|
|
||||||
console.error(`[shot] ERROR: ${message}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- resolve the built stylesheet ----
|
|
||||||
|
|
||||||
if (!existsSync(DIST)) fail(`no build output at ${DIST} — run: bun run build:ui`);
|
|
||||||
const styles = readdirSync(DIST).find((f) => f.startsWith("styles-") && f.endsWith(".css"));
|
|
||||||
if (!styles) fail(`no styles-*.css in ${DIST} — run: bun run build:ui`);
|
|
||||||
|
|
||||||
// ---- compose the page ----
|
|
||||||
|
|
||||||
mkdirSync(WORK, { recursive: true });
|
|
||||||
const profile = path.join(WORK, "profile");
|
|
||||||
mkdirSync(profile, { recursive: true });
|
|
||||||
|
|
||||||
const url = arg("url");
|
|
||||||
let target: string;
|
|
||||||
|
|
||||||
if (url) {
|
|
||||||
target = url;
|
|
||||||
} else {
|
|
||||||
const dark = flag("dark");
|
|
||||||
let html = await Bun.file(HARNESS).text();
|
|
||||||
// replaceAll, not replace: the harness documents its own {{STYLES}} token in
|
|
||||||
// a header comment that precedes the <link>, so a first-match replace
|
|
||||||
// substitutes the comment and silently leaves the stylesheet unlinked.
|
|
||||||
html = html.replaceAll("{{STYLES}}", styles); // relative: page sits next to it
|
|
||||||
// The preload bakes these attributes in the real app; do it by hand here.
|
|
||||||
if (dark) html = html.replace('<html class="win7"', '<html class="win7" data-theme="dark"');
|
|
||||||
const page = path.join(DIST, dark ? "_harness.dark.html" : "_harness.light.html");
|
|
||||||
await Bun.write(page, html);
|
|
||||||
target = `file://${page}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- shoot ----
|
|
||||||
|
|
||||||
const out = path.resolve(arg("out") ?? path.join(WORK, flag("dark") ? "dark.png" : "light.png"));
|
|
||||||
const width = arg("width") ?? "1200";
|
|
||||||
const height = arg("height") ?? "1500";
|
|
||||||
|
|
||||||
// A stale lock from a killed run makes every later shot fail confusingly.
|
|
||||||
rmSync(path.join(profile, "parent.lock"), { force: true });
|
|
||||||
|
|
||||||
const proc = Bun.spawnSync(
|
|
||||||
[
|
|
||||||
"firefox",
|
|
||||||
"--headless",
|
|
||||||
"--profile",
|
|
||||||
profile,
|
|
||||||
"--screenshot",
|
|
||||||
out,
|
|
||||||
"--window-size",
|
|
||||||
`${width},${height}`,
|
|
||||||
target,
|
|
||||||
],
|
|
||||||
{ cwd: ROOT, stdout: "pipe", stderr: "pipe" },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!existsSync(out)) {
|
|
||||||
console.error(proc.stderr.toString().trim());
|
|
||||||
fail("firefox produced no image (see stderr above)");
|
|
||||||
}
|
|
||||||
console.log(`[shot] ${out}`);
|
|
||||||
+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
|
||||||
|
|||||||
Binary file not shown.
+79
-42
@@ -1,4 +1,4 @@
|
|||||||
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";
|
||||||
@@ -10,18 +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 {
|
import { buildPreload, chromeOptions, showWhenReady } from "./windows";
|
||||||
buildPreload,
|
|
||||||
chromeOptions,
|
|
||||||
initSessionSkin,
|
|
||||||
showWhenReady,
|
|
||||||
trackMaximizeState,
|
|
||||||
} from "./windows";
|
|
||||||
|
|
||||||
const PROD_URL = "views://app/index.html";
|
const PROD_URL = "views://app/index.html";
|
||||||
|
|
||||||
@@ -58,21 +53,11 @@ const events = {
|
|||||||
openEditor: (_ctx: EditorOpenContext) => {},
|
openEditor: (_ctx: EditorOpenContext) => {},
|
||||||
settingsChanged: (_settings: AppSettings) => {},
|
settingsChanged: (_settings: AppSettings) => {},
|
||||||
openConnectionManager: () => {},
|
openConnectionManager: () => {},
|
||||||
|
openSettings: () => {},
|
||||||
showAbout: () => {},
|
showAbout: () => {},
|
||||||
|
applyUpdate: async () => {},
|
||||||
};
|
};
|
||||||
// Late-bound for the same reason events is: `win` below takes this rpc in its
|
const rpc = createAppRPC({ registry, engine, store, settings, edits, watcher, events });
|
||||||
// constructor, so it cannot exist yet. Only the win7 title bar reads it.
|
|
||||||
let mainWin: BrowserWindow | undefined;
|
|
||||||
const rpc = createAppRPC({
|
|
||||||
registry,
|
|
||||||
engine,
|
|
||||||
store,
|
|
||||||
settings,
|
|
||||||
edits,
|
|
||||||
watcher,
|
|
||||||
mainWindow: () => mainWin,
|
|
||||||
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();
|
||||||
@@ -82,11 +67,6 @@ const url = await getMainViewUrl();
|
|||||||
// fresh via the saveSettings flow below.
|
// fresh via the saveSettings flow below.
|
||||||
let currentSettings = await settings.load();
|
let currentSettings = await settings.load();
|
||||||
setBackendLocale(currentSettings.locale); // native menus/errors follow the UI language
|
setBackendLocale(currentSettings.locale); // native menus/errors follow the UI language
|
||||||
// Freeze the skin for this process BEFORE any window is constructed. It picks
|
|
||||||
// a component tree and a native frame, neither of which can change on a live
|
|
||||||
// window, so the persisted value may only take effect at the next start — see
|
|
||||||
// initSessionSkin.
|
|
||||||
initSessionSkin(currentSettings.skin);
|
|
||||||
|
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
title: BRANDING.appName,
|
title: BRANDING.appName,
|
||||||
@@ -97,8 +77,7 @@ const win = new BrowserWindow({
|
|||||||
x: 120,
|
x: 120,
|
||||||
y: 120,
|
y: 120,
|
||||||
},
|
},
|
||||||
// Frame decision lives in chromeOptions — see windows.ts. Constructor-only,
|
// frame decision lives in chromeOptions (windows.ts); constructor-only
|
||||||
// and read from the pinned session skin, not from currentSettings.
|
|
||||||
...chromeOptions(),
|
...chromeOptions(),
|
||||||
// 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),
|
||||||
@@ -107,8 +86,6 @@ const win = new BrowserWindow({
|
|||||||
hidden: true,
|
hidden: true,
|
||||||
});
|
});
|
||||||
showWhenReady(win);
|
showWhenReady(win);
|
||||||
mainWin = win;
|
|
||||||
trackMaximizeState(win, (maximized) => rpc.send.windowState({ maximized }));
|
|
||||||
|
|
||||||
const editors = new EditorWindows({
|
const editors = new EditorWindows({
|
||||||
registry,
|
registry,
|
||||||
@@ -118,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,
|
||||||
@@ -130,21 +108,48 @@ const manager = new ManagerWindow({
|
|||||||
connectionsChanged: () => rpc.send.connectionsChanged({}),
|
connectionsChanged: () => rpc.send.connectionsChanged({}),
|
||||||
menuAction: (info) => rpc.send.menuAction(info),
|
menuAction: (info) => rpc.send.menuAction(info),
|
||||||
},
|
},
|
||||||
rebuildMenu: () => void applyApplicationMenu(store),
|
rebuildMenu: () => void applyApplicationMenu(store, () => currentSettings.keybindings),
|
||||||
|
onSettingsSaved: (s) => settingsSaved(s, "manager"),
|
||||||
|
});
|
||||||
|
const settingsWin = new SettingsWindow({
|
||||||
|
settings,
|
||||||
|
url,
|
||||||
|
currentSettings: () => currentSettings,
|
||||||
|
mainFrame: () => win.getFrame(),
|
||||||
|
onSettingsSaved: (s) => settingsSaved(s, "settings"),
|
||||||
|
showAbout: () => showAbout(),
|
||||||
});
|
});
|
||||||
|
|
||||||
events.openEditor = (ctx) => editors.open(ctx);
|
events.openEditor = (ctx) => editors.open(ctx);
|
||||||
events.settingsChanged = (s) => {
|
/**
|
||||||
|
* Every settings save lands here, whichever window made it (editor and manager
|
||||||
|
* windows save too — the scale shortcuts — and the Settings window is nothing
|
||||||
|
* but saves): side effects run once, then every OTHER window follows. The
|
||||||
|
* sender is skipped — it already shows the new values, and an echo arriving
|
||||||
|
* behind a newer local change (a held Cmd+) would drag it backwards.
|
||||||
|
*/
|
||||||
|
function settingsSaved(
|
||||||
|
s: AppSettings,
|
||||||
|
sender: "main" | "manager" | "settings" | { editor: string },
|
||||||
|
): void {
|
||||||
|
engine.setConcurrency(effectiveConcurrency(s));
|
||||||
const localeChanged = s.locale !== currentSettings.locale;
|
const localeChanged = s.locale !== currentSettings.locale;
|
||||||
|
const keysChanged = JSON.stringify(s.keybindings) !== JSON.stringify(currentSettings.keybindings);
|
||||||
currentSettings = s; // future windows' preloads bake the fresh theme
|
currentSettings = s; // future windows' preloads bake the fresh theme
|
||||||
if (localeChanged) {
|
if (localeChanged) {
|
||||||
setBackendLocale(s.locale);
|
setBackendLocale(s.locale);
|
||||||
void applyApplicationMenu(store); // relabel the native menu in the new language
|
settingsWin.retitle();
|
||||||
}
|
}
|
||||||
editors.broadcastSettings(s);
|
// relabel the native menu in the new language / give it the remapped keys
|
||||||
manager.broadcastSettings(s);
|
if (localeChanged || keysChanged) void applyApplicationMenu(store, () => currentSettings.keybindings);
|
||||||
};
|
if (sender !== "main") rpc.send.settingsChanged({ settings: s });
|
||||||
|
if (sender !== "manager") manager.broadcastSettings(s);
|
||||||
|
if (sender !== "settings") settingsWin.broadcastSettings(s);
|
||||||
|
editors.broadcastSettings(s, typeof sender === "object" ? sender.editor : undefined);
|
||||||
|
}
|
||||||
|
events.settingsChanged = (s) => settingsSaved(s, "main");
|
||||||
events.openConnectionManager = () => manager.open();
|
events.openConnectionManager = () => manager.open();
|
||||||
|
events.openSettings = () => settingsWin.open();
|
||||||
|
|
||||||
function showAbout(): void {
|
function showAbout(): void {
|
||||||
const { build } = BRANDING;
|
const { build } = BRANDING;
|
||||||
@@ -163,6 +168,7 @@ 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();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -187,6 +193,7 @@ engine.onUpdate = (snapshot) => {
|
|||||||
if (
|
if (
|
||||||
!windowFocused &&
|
!windowFocused &&
|
||||||
!editors.anyFocused() &&
|
!editors.anyFocused() &&
|
||||||
|
!settingsWin.isFocused() &&
|
||||||
(snapshot.state === "completed" || snapshot.state === "failed")
|
(snapshot.state === "completed" || snapshot.state === "failed")
|
||||||
) {
|
) {
|
||||||
Utils.showNotification({
|
Utils.showNotification({
|
||||||
@@ -207,11 +214,24 @@ engine.onUpdate = (snapshot) => {
|
|||||||
|
|
||||||
// don't quit out from under running transfers or unsaved editors
|
// don't quit out from under running transfers or unsaved editors
|
||||||
let quitConfirmed = false;
|
let quitConfirmed = false;
|
||||||
|
// Updater.applyUpdate() asks this guard first (2.x), so remember the quit is
|
||||||
|
// update-initiated: "Quit Anyway" must re-run the apply, or a plain quit would
|
||||||
|
// drop the downloaded update.
|
||||||
|
let applyingUpdate = false;
|
||||||
|
events.applyUpdate = async () => {
|
||||||
|
applyingUpdate = true;
|
||||||
|
try {
|
||||||
|
await Updater.applyUpdate();
|
||||||
|
} finally {
|
||||||
|
applyingUpdate = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
Electrobun.events.on("before-quit", (event) => {
|
Electrobun.events.on("before-quit", (event) => {
|
||||||
const transfers = engine.activeJobCount();
|
const transfers = engine.activeJobCount();
|
||||||
const dirtyEditors = editors.dirtyCount();
|
const dirtyEditors = editors.dirtyCount();
|
||||||
if (quitConfirmed || (transfers === 0 && dirtyEditors === 0)) return;
|
if (quitConfirmed || (transfers === 0 && dirtyEditors === 0)) return;
|
||||||
(event as { response: { allow: boolean } }).response = { allow: false };
|
(event as { response: { allow: boolean } }).response = { allow: false };
|
||||||
|
const updating = applyingUpdate; // read now: it clears once the vetoed apply returns
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const parts = [
|
const parts = [
|
||||||
transfers > 0 && `${transfers} transfer${transfers === 1 ? " is" : "s are"} still running`,
|
transfers > 0 && `${transfers} transfer${transfers === 1 ? " is" : "s are"} still running`,
|
||||||
@@ -229,7 +249,10 @@ Electrobun.events.on("before-quit", (event) => {
|
|||||||
});
|
});
|
||||||
if (response === 0) {
|
if (response === 0) {
|
||||||
quitConfirmed = true;
|
quitConfirmed = true;
|
||||||
Utils.quit();
|
// the trailing quit is a no-op once the apply is quitting, and still
|
||||||
|
// quits if it bails (no prepared download)
|
||||||
|
if (updating) void Updater.applyUpdate().finally(() => Utils.quit());
|
||||||
|
else Utils.quit();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
@@ -237,17 +260,24 @@ events.sessionClosed = (info) => {
|
|||||||
rpc.send.sessionClosed(info);
|
rpc.send.sessionClosed(info);
|
||||||
editors.notifySessionClosed(info);
|
editors.notifySessionClosed(info);
|
||||||
};
|
};
|
||||||
events.connectionsChanged = () => void applyApplicationMenu(store);
|
events.connectionsChanged = () => void applyApplicationMenu(store, () => currentSettings.keybindings);
|
||||||
engine.setConcurrency(effectiveConcurrency(currentSettings));
|
engine.setConcurrency(effectiveConcurrency(currentSettings));
|
||||||
|
|
||||||
void applyApplicationMenu(store);
|
void applyApplicationMenu(store, () => currentSettings.keybindings);
|
||||||
ApplicationMenu.on("application-menu-clicked", (event) => {
|
ApplicationMenu.on("application-menu-clicked", (event) => {
|
||||||
const action = (event as { data?: { action?: string } }).data?.action;
|
const action = (event as { data?: { action?: string } }).data?.action;
|
||||||
if (!action) return;
|
if (!action) return;
|
||||||
// ⌘W while an editor window is focused closes that editor (with its dirty
|
// Their pages claim close-window (⌘W) themselves — macOS hands key
|
||||||
// prompt), not a main-window tab. Other menu actions deliberately still
|
// equivalents to the page first — so a close-tab that still arrives while
|
||||||
// target the main window.
|
// the Settings window or an editor is focused is a ⌘W the page let through
|
||||||
|
// (or the menu item clicked): close THAT window (an editor with its dirty
|
||||||
|
// prompt), never a main-window tab behind it. Other menu actions
|
||||||
|
// deliberately still target the main window.
|
||||||
if (action === "close-tab") {
|
if (action === "close-tab") {
|
||||||
|
if (settingsWin.isFocused()) {
|
||||||
|
settingsWin.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const focusedEditor = editors.focusedKey();
|
const focusedEditor = editors.focusedKey();
|
||||||
if (focusedEditor) {
|
if (focusedEditor) {
|
||||||
editors.requestClose(focusedEditor);
|
editors.requestClose(focusedEditor);
|
||||||
@@ -258,6 +288,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;
|
||||||
@@ -270,6 +304,9 @@ ApplicationMenu.on("application-menu-clicked", (event) => {
|
|||||||
}
|
}
|
||||||
// the manager is modal to the main window — don't drive main behind it
|
// the manager is modal to the main window — don't drive main behind it
|
||||||
if (manager.isOpen()) return;
|
if (manager.isOpen()) return;
|
||||||
|
// everything below acts on the main window: from a focused editor or the
|
||||||
|
// Settings window (⌘T, ⌘N…), bring it forward so the effect is visible
|
||||||
|
if (settingsWin.isFocused() || editors.focusedKey()) win.activate();
|
||||||
rpc.send.menuAction({ action });
|
rpc.send.menuAction({ action });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 +72,7 @@ export class ManagerWindow {
|
|||||||
this.close();
|
this.close();
|
||||||
},
|
},
|
||||||
onClose: () => this.close(),
|
onClose: () => this.close(),
|
||||||
// late-bound: the window takes this rpc in its constructor
|
onSettingsSaved: (next) => this.deps.onSettingsSaved(next),
|
||||||
window: () => this.win ?? undefined,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+35
-11
@@ -1,5 +1,6 @@
|
|||||||
import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/bun";
|
import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/main";
|
||||||
import { BRANDING } from "../shared/branding";
|
import { BRANDING } from "../shared/branding";
|
||||||
|
import { effectiveKeymap, type ShortcutId, toAccelerator } from "../shared/keymap";
|
||||||
import type { SavedConnection } from "../shared/types";
|
import type { SavedConnection } from "../shared/types";
|
||||||
import { t } from "./i18n";
|
import { t } from "./i18n";
|
||||||
import type { ConnectionStore } from "./store/connections";
|
import type { ConnectionStore } from "./store/connections";
|
||||||
@@ -7,13 +8,30 @@ import type { ConnectionStore } from "./store/connections";
|
|||||||
/**
|
/**
|
||||||
* macOS application menu (electrobun menus are macOS-only). Standard roles plus
|
* macOS application menu (electrobun menus are macOS-only). Standard roles plus
|
||||||
* app actions; custom actions are forwarded to the webview as menuAction
|
* app actions; custom actions are forwarded to the webview as menuAction
|
||||||
* messages. Rebuilt whenever saved connections change so the Connect submenu
|
* messages. Rebuilt whenever saved connections, the language or the keyboard
|
||||||
* stays current.
|
* remaps change.
|
||||||
|
*
|
||||||
|
* Accelerators come from the shared keymap, so the menu bar shows — and
|
||||||
|
* fires — the user's keys. The page sees ⌘-keys first and claims the ones it
|
||||||
|
* handles; the menu only runs what falls through (see shortcuts.service.ts).
|
||||||
|
* ⌘Q stays fixed: quitting must always work.
|
||||||
*/
|
*/
|
||||||
export async function applyApplicationMenu(store: ConnectionStore): Promise<void> {
|
export async function applyApplicationMenu(
|
||||||
|
store: ConnectionStore,
|
||||||
|
keybindings: () => Record<string, string[]>,
|
||||||
|
): Promise<void> {
|
||||||
if (process.platform !== "darwin") return;
|
if (process.platform !== "darwin") return;
|
||||||
|
|
||||||
const { stacks, connections } = await store.load();
|
const { stacks, connections } = await store.load();
|
||||||
|
// read after the await: a remap saved meanwhile must not be lost
|
||||||
|
const keymap = effectiveKeymap(keybindings(), true);
|
||||||
|
const accelerator = (id: ShortcutId): string | undefined => {
|
||||||
|
for (const combo of keymap.find((b) => b.id === id)?.combos ?? []) {
|
||||||
|
const native = toAccelerator(combo);
|
||||||
|
if (native) return native;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
const item = (c: SavedConnection): ApplicationMenuItemConfig => ({
|
const item = (c: SavedConnection): ApplicationMenuItemConfig => ({
|
||||||
label: c.name,
|
label: c.name,
|
||||||
action: `connect:${c.id}`,
|
action: `connect:${c.id}`,
|
||||||
@@ -28,7 +46,11 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
|
|||||||
...looseItems,
|
...looseItems,
|
||||||
...stackMenus,
|
...stackMenus,
|
||||||
...(looseItems.length || stackMenus.length ? [{ type: "separator" } as const] : []),
|
...(looseItems.length || stackMenus.length ? [{ type: "separator" } as const] : []),
|
||||||
{ label: t("menu.connectionManager"), action: "manage-connections" },
|
{
|
||||||
|
label: t("menu.connectionManager"),
|
||||||
|
action: "manage-connections",
|
||||||
|
accelerator: accelerator("connection-manager"),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
ApplicationMenu.setApplicationMenu([
|
ApplicationMenu.setApplicationMenu([
|
||||||
@@ -36,6 +58,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: accelerator("open-settings") },
|
||||||
|
{ 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" },
|
||||||
@@ -51,13 +75,13 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
|
|||||||
{
|
{
|
||||||
label: t("menu.file"),
|
label: t("menu.file"),
|
||||||
submenu: [
|
submenu: [
|
||||||
{ label: t("menu.newTab"), action: "new-tab", accelerator: "t" },
|
{ label: t("menu.newTab"), action: "new-tab", accelerator: accelerator("new-tab") },
|
||||||
{ label: t("menu.closeTab"), action: "close-tab", accelerator: "w" },
|
{ label: t("menu.closeTab"), action: "close-tab", accelerator: accelerator("close-tab") },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ label: t("menu.newFolder"), action: "new-folder" },
|
{ label: t("menu.newFolder"), action: "new-folder", accelerator: accelerator("new-folder") },
|
||||||
{ label: t("menu.newFile"), action: "new-file" },
|
{ label: t("menu.newFile"), action: "new-file", accelerator: accelerator("new-file") },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ label: t("menu.editFile"), action: "edit-file", accelerator: "e" },
|
{ label: t("menu.editFile"), action: "edit-file", accelerator: accelerator("edit") },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ label: t("menu.connect"), submenu: connectSubmenu },
|
{ label: t("menu.connect"), submenu: connectSubmenu },
|
||||||
],
|
],
|
||||||
@@ -79,7 +103,7 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
|
|||||||
{
|
{
|
||||||
label: t("menu.view"),
|
label: t("menu.view"),
|
||||||
submenu: [
|
submenu: [
|
||||||
{ label: t("menu.refresh"), action: "refresh-active", accelerator: "r" },
|
{ label: t("menu.refresh"), action: "refresh-active", accelerator: accelerator("refresh") },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "toggleFullScreen" },
|
{ role: "toggleFullScreen" },
|
||||||
],
|
],
|
||||||
|
|||||||
+91
-126
@@ -1,6 +1,6 @@
|
|||||||
import { BrowserView, type BrowserWindow, 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 { 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,12 +24,6 @@ export interface AppDeps {
|
|||||||
settings: SettingsStore;
|
settings: SettingsStore;
|
||||||
edits: EditManager;
|
edits: EditManager;
|
||||||
watcher: LocalDirWatcher;
|
watcher: LocalDirWatcher;
|
||||||
/**
|
|
||||||
* The main BrowserWindow, for the win7 skin's custom title-bar controls.
|
|
||||||
* Lazy for the same reason events is late-bound: the window takes this rpc
|
|
||||||
* instance in its constructor, so it does not exist yet at createAppRPC time.
|
|
||||||
*/
|
|
||||||
mainWindow: () => BrowserWindow | undefined;
|
|
||||||
/** Assigned after the rpc object exists (it needs rpc.send). */
|
/** Assigned after the rpc object exists (it needs rpc.send). */
|
||||||
events: {
|
events: {
|
||||||
sessionClosed: (info: { sessionId: string; reason: string }) => void;
|
sessionClosed: (info: { sessionId: string; reason: string }) => void;
|
||||||
@@ -37,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>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,78 +46,6 @@ async function wrap<T extends object>(fn: () => Promise<T> | T): Promise<Result<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Window-control requests for the win7 skin's custom title bar. Shared by all
|
|
||||||
* three window kinds; each passes a getter for ITS OWN window.
|
|
||||||
*
|
|
||||||
* The getter is lazy because the main window's rpc instance is constructed
|
|
||||||
* before the BrowserWindow exists (the window takes the rpc in its
|
|
||||||
* constructor) — same late-binding reason as AppDeps.events. Under the modern
|
|
||||||
* skin nothing calls these; they are harmless no-ops if the window is gone.
|
|
||||||
*/
|
|
||||||
export function windowControlHandlers(
|
|
||||||
getWindow: () => BrowserWindow | undefined,
|
|
||||||
/**
|
|
||||||
* What the title bar's X does. Defaults to a raw `win.close()`. Editor
|
|
||||||
* windows MUST pass their confirmed-close hook instead — the native close
|
|
||||||
* is not cancellable, so a raw close there would skip the dirty prompt
|
|
||||||
* (see gotchas.md "Window `close` is not cancellable").
|
|
||||||
*/
|
|
||||||
onClose: (win: BrowserWindow | undefined) => void = (win) => win?.close(),
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
windowMinimize: () =>
|
|
||||||
wrap(() => {
|
|
||||||
getWindow()?.minimize();
|
|
||||||
return {};
|
|
||||||
}),
|
|
||||||
|
|
||||||
windowToggleMaximize: () =>
|
|
||||||
wrap(() => {
|
|
||||||
const win = getWindow();
|
|
||||||
if (!win) return { maximized: false };
|
|
||||||
// isMaximized is a synchronous FFI read, so this stays one round trip
|
|
||||||
const next = !win.isMaximized();
|
|
||||||
if (next) win.maximize();
|
|
||||||
else win.unmaximize();
|
|
||||||
return { maximized: next };
|
|
||||||
}),
|
|
||||||
|
|
||||||
windowClose: () =>
|
|
||||||
wrap(() => {
|
|
||||||
onClose(getWindow());
|
|
||||||
return {};
|
|
||||||
}),
|
|
||||||
|
|
||||||
windowIsMaximized: () => wrap(() => ({ maximized: getWindow()?.isMaximized() ?? false })),
|
|
||||||
|
|
||||||
// ---- STOPGAP: synthesized resize ----
|
|
||||||
// A frameless GTK window has no resize borders, and electrobun exposes no
|
|
||||||
// native resize gesture (it has startWindowMove and nothing else — the
|
|
||||||
// shipped libNativeWrapper does not even link gtk_window_begin_resize_drag).
|
|
||||||
// So the webview drags its own handles and moves the frame itself. Both are
|
|
||||||
// synchronous FFI reads/writes, like isMaximized above, so each drag tick
|
|
||||||
// stays one round trip. DELETE WITH THE REST OF THE HACK — the checklist is
|
|
||||||
// in ui/src/app/skins/7like/window-resize/window-resize.ts.
|
|
||||||
windowGetFrame: () =>
|
|
||||||
wrap(() => getWindow()?.getFrame() ?? { x: 0, y: 0, width: 0, height: 0 }),
|
|
||||||
|
|
||||||
// Params are annotated from the schema rather than inferred: this factory
|
|
||||||
// returns a bare object literal, so it gets none of the contextual typing
|
|
||||||
// the inline handler sets inside defineRPC<AppRPC> enjoy.
|
|
||||||
windowSetFrame: ({
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
}: AppRPC["bun"]["requests"]["windowSetFrame"]["params"]) =>
|
|
||||||
wrap(() => {
|
|
||||||
getWindow()?.setFrame(x, y, width, height);
|
|
||||||
return {};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Connection-store requests, shared by the main and manager window rpcs. */
|
/** Connection-store requests, shared by the main and manager window rpcs. */
|
||||||
function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void) {
|
function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void) {
|
||||||
return {
|
return {
|
||||||
@@ -177,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");
|
||||||
@@ -192,6 +133,28 @@ 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,
|
||||||
@@ -199,7 +162,6 @@ export function createAppRPC({
|
|||||||
settings,
|
settings,
|
||||||
edits,
|
edits,
|
||||||
watcher,
|
watcher,
|
||||||
mainWindow,
|
|
||||||
events,
|
events,
|
||||||
}: AppDeps) {
|
}: AppDeps) {
|
||||||
return BrowserView.defineRPC<AppRPC>({
|
return BrowserView.defineRPC<AppRPC>({
|
||||||
@@ -297,22 +259,13 @@ export function createAppRPC({
|
|||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// ---- custom window chrome (win7 skin) ----
|
|
||||||
...windowControlHandlers(mainWindow),
|
|
||||||
|
|
||||||
// ---- connection store ----
|
// ---- connection store ----
|
||||||
...connectionStoreHandlers(store, () => events.connectionsChanged()),
|
...connectionStoreHandlers(store, () => events.connectionsChanged()),
|
||||||
|
|
||||||
// ---- 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 }) =>
|
||||||
@@ -329,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,
|
||||||
|
|
||||||
@@ -390,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();
|
||||||
@@ -413,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 {};
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -429,8 +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, for the win7 title-bar controls (late-bound like the rest). */
|
/** This window saved settings (e.g. the scale shortcuts) — rebroadcast them. */
|
||||||
window: () => BrowserWindow | undefined;
|
onSettingsSaved: (next: AppSettings) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -452,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 () => {
|
||||||
@@ -466,12 +408,6 @@ export function createEditorRPC(
|
|||||||
hooks.onCloseConfirmed();
|
hooks.onCloseConfirmed();
|
||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// win7 skin: this window is frameless, so the webview draws the
|
|
||||||
// chrome. The X routes through onCloseConfirmed (suppressPrompt +
|
|
||||||
// close), exactly like editorClose — the editor UI runs its dirty
|
|
||||||
// prompt BEFORE calling this, same as the ⌘W path.
|
|
||||||
...windowControlHandlers(hooks.window, () => hooks.onCloseConfirmed()),
|
|
||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
editorState: (state) => hooks.onDraft(state),
|
editorState: (state) => hooks.onDraft(state),
|
||||||
@@ -487,8 +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, for the win7 title-bar controls (late-bound like the rest). */
|
/** This window saved settings (e.g. the scale shortcuts) — rebroadcast them. */
|
||||||
window: () => BrowserWindow | undefined;
|
onSettingsSaved: (next: AppSettings) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** RPC for the single connection-manager window (per-window instance). */
|
/** RPC for the single connection-manager window (per-window instance). */
|
||||||
@@ -503,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 () => {
|
||||||
@@ -521,10 +458,38 @@ export function createManagerRPC(
|
|||||||
hooks.onClose();
|
hooks.onClose();
|
||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
|
},
|
||||||
// win7 skin: frameless window, webview-drawn chrome. The X reuses
|
},
|
||||||
// the managerClose path so the modal bookkeeping still runs.
|
});
|
||||||
...windowControlHandlers(hooks.window, () => hooks.onClose()),
|
}
|
||||||
|
|
||||||
|
export interface SettingsRpcHooks {
|
||||||
|
/** Every edit here is a settings save — rebroadcast it to the other windows. */
|
||||||
|
onSettingsSaved: (next: AppSettings) => void;
|
||||||
|
showAbout: () => void;
|
||||||
|
/** The window's own close-window key. */
|
||||||
|
close: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RPC for the single Settings window (per-window instance). */
|
||||||
|
export function createSettingsRPC(deps: { settings: SettingsStore }, hooks: SettingsRpcHooks) {
|
||||||
|
return BrowserView.defineRPC<AppRPC>({
|
||||||
|
maxRequestTime: MAX_REQUEST_TIME_MS,
|
||||||
|
handlers: {
|
||||||
|
requests: {
|
||||||
|
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
|
||||||
|
saveSettings: saveSettingsHandler(deps.settings, hooks.onSettingsSaved),
|
||||||
|
pickEditorApp: pickEditorAppHandler,
|
||||||
|
showAbout: () =>
|
||||||
|
wrap(async () => {
|
||||||
|
hooks.showAbout();
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
settingsClose: () =>
|
||||||
|
wrap(async () => {
|
||||||
|
hooks.close();
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { BrowserWindow } from "electrobun/main";
|
||||||
|
import type { AppSettings } from "../shared/types";
|
||||||
|
import { t } from "./i18n";
|
||||||
|
import { createSettingsRPC } from "./rpc-handlers";
|
||||||
|
import type { SettingsStore } from "./store/settings";
|
||||||
|
import { openSecondaryWindow } from "./windows";
|
||||||
|
|
||||||
|
const SETTINGS_WIDTH = 680;
|
||||||
|
const SETTINGS_HEIGHT = 480;
|
||||||
|
|
||||||
|
export interface SettingsWindowDeps {
|
||||||
|
settings: SettingsStore;
|
||||||
|
/** The once-resolved dev/prod view url. */
|
||||||
|
url: string;
|
||||||
|
/** Live settings snapshot — baked into the window's preload (theme flash). */
|
||||||
|
currentSettings: () => AppSettings;
|
||||||
|
mainFrame: () => { x: number; y: number; width: number; height: number };
|
||||||
|
/** This window saved settings — rebroadcast them to every other window. */
|
||||||
|
onSettingsSaved: (settings: AppSettings) => void;
|
||||||
|
showAbout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single Settings window. Unlike the connection manager it is NOT modal:
|
||||||
|
* every edit is an ordinary settings save, so the other windows follow live
|
||||||
|
* while it stays open. Its page claims its own close-window key (settingsClose).
|
||||||
|
* A ⌘W it doesn't claim (remapped away) falls through to the app menu's Close
|
||||||
|
* Tab, which index.ts routes here while this window is focused.
|
||||||
|
*/
|
||||||
|
export class SettingsWindow {
|
||||||
|
private win: BrowserWindow | null = null;
|
||||||
|
private rpc: ReturnType<typeof createSettingsRPC> | null = null;
|
||||||
|
private focused = false;
|
||||||
|
|
||||||
|
constructor(private deps: SettingsWindowDeps) {}
|
||||||
|
|
||||||
|
isFocused(): boolean {
|
||||||
|
return this.focused;
|
||||||
|
}
|
||||||
|
|
||||||
|
open(): void {
|
||||||
|
if (this.win) {
|
||||||
|
this.win.activate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rpc = createSettingsRPC(
|
||||||
|
{ settings: this.deps.settings },
|
||||||
|
{
|
||||||
|
onSettingsSaved: (next) => this.deps.onSettingsSaved(next),
|
||||||
|
showAbout: () => this.deps.showAbout(),
|
||||||
|
close: () => this.close(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const main = this.deps.mainFrame();
|
||||||
|
this.win = openSecondaryWindow({
|
||||||
|
title: t("options.title"),
|
||||||
|
url: this.deps.url,
|
||||||
|
mode: "settings",
|
||||||
|
settings: this.deps.currentSettings(),
|
||||||
|
frame: {
|
||||||
|
x: Math.max(0, main.x + (main.width - SETTINGS_WIDTH) / 2),
|
||||||
|
y: Math.max(0, main.y + (main.height - SETTINGS_HEIGHT) / 2),
|
||||||
|
width: SETTINGS_WIDTH,
|
||||||
|
height: SETTINGS_HEIGHT,
|
||||||
|
},
|
||||||
|
rpc: this.rpc,
|
||||||
|
});
|
||||||
|
this.win.on("focus", () => (this.focused = true));
|
||||||
|
this.win.on("blur", () => (this.focused = false));
|
||||||
|
this.win.on("close", () => {
|
||||||
|
this.win = null;
|
||||||
|
this.rpc = null;
|
||||||
|
this.focused = false;
|
||||||
|
});
|
||||||
|
console.log("[settings] window opened");
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.win?.close(); // the close event does the cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The native title follows a language switch (the header row is the UI's own). */
|
||||||
|
retitle(): void {
|
||||||
|
this.win?.setTitle(t("options.title"));
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastSettings(settings: AppSettings): void {
|
||||||
|
this.rpc?.send.settingsChanged({ settings });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { sanitizeKeybindings } from "../../shared/keymap";
|
||||||
import { clampUiScale, DEFAULT_SETTINGS, type AppSettings } from "../../shared/types";
|
import { clampUiScale, DEFAULT_SETTINGS, type AppSettings } from "../../shared/types";
|
||||||
import { appDataDir } from "./paths";
|
import { appDataDir } from "./paths";
|
||||||
|
|
||||||
@@ -24,6 +25,9 @@ export class SettingsStore {
|
|||||||
// into every window's root font-size, so a hand-edited settings.json
|
// 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
|
// (uiScale: 100, "2", NaN) must not be able to render the UI unusable
|
||||||
settings.uiScale = clampUiScale(Number(settings.uiScale));
|
settings.uiScale = clampUiScale(Number(settings.uiScale));
|
||||||
|
// same reasoning: every window resolves keys through this map
|
||||||
|
settings.keybindings = sanitizeKeybindings(data.keybindings);
|
||||||
|
settings.openFilesOnEnter = data.openFilesOnEnter === true;
|
||||||
return settings;
|
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
|
||||||
|
|||||||
+18
-93
@@ -1,31 +1,9 @@
|
|||||||
import { BrowserWindow } from "electrobun/bun";
|
import { BrowserWindow } from "electrobun/main";
|
||||||
import { DEFAULT_SETTINGS, type AppSettings } from "../shared/types";
|
import type { AppSettings } from "../shared/types";
|
||||||
|
|
||||||
/**
|
|
||||||
* The skin THIS PROCESS booted with, frozen at startup by `initSessionSkin`.
|
|
||||||
*
|
|
||||||
* Every window must be built from this, never from the live settings snapshot.
|
|
||||||
* The skin picks a whole component tree (bootstrapped from the preload-baked
|
|
||||||
* __APP_SKIN) and the native frame (constructor-only), so it cannot change
|
|
||||||
* without a restart — but the persisted setting CAN change mid-session. Reading
|
|
||||||
* the live value would boot a newly opened editor or manager into the pending
|
|
||||||
* skin while the main window still runs the old one: two windows, two trees,
|
|
||||||
* two frames.
|
|
||||||
*
|
|
||||||
* Pinning it in one place makes that impossible rather than making each call
|
|
||||||
* site remember. Everything else in the preload keeps using the live snapshot,
|
|
||||||
* because theme/tone/accent/text-size genuinely do apply live.
|
|
||||||
*/
|
|
||||||
let sessionSkin: AppSettings["skin"] = DEFAULT_SETTINGS.skin;
|
|
||||||
|
|
||||||
/** Call once, at startup, before any window is constructed. */
|
|
||||||
export function initSessionSkin(skin: AppSettings["skin"]): void {
|
|
||||||
sessionSkin = skin;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
@@ -35,30 +13,18 @@ export function initSessionSkin(skin: AppSettings["skin"]): void {
|
|||||||
* 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 {
|
||||||
// sessionSkin, NOT settings.skin — a pending skin change must not leak into
|
|
||||||
// a window opened before the restart. See initSessionSkin.
|
|
||||||
let script =
|
let script =
|
||||||
`window.__APP_PLATFORM=${JSON.stringify(process.platform)};` +
|
`window.__APP_PLATFORM=${JSON.stringify(process.platform)};` +
|
||||||
`window.__APP_MODE=${JSON.stringify(mode)};` +
|
`window.__APP_MODE=${JSON.stringify(mode)};`;
|
||||||
`window.__APP_SKIN=${JSON.stringify(sessionSkin)};`;
|
|
||||||
if (ctx !== undefined) script += `window.__APP_CTX=${JSON.stringify(ctx)};`;
|
if (ctx !== undefined) script += `window.__APP_CTX=${JSON.stringify(ctx)};`;
|
||||||
|
|
||||||
// A class, not a data-* attribute: 7.scoped.css is precompiled to require a
|
|
||||||
// literal `.win7` ancestor, so the name is not ours to choose. This is the
|
|
||||||
// ONLY place it is ever written — the UI must never toggle it live, because
|
|
||||||
// each skin's CSS is written against its own tree's markup.
|
|
||||||
if (sessionSkin === "win7") {
|
|
||||||
script += `document.documentElement.classList.add("win7");`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
// interface scale: mirrors SettingsService.applyUiScale so the FIRST paint
|
||||||
// is already scaled — without this every window opens at 100% and snaps.
|
// 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.
|
// Written as an inline style, not a data attribute: the scale is continuous.
|
||||||
@@ -72,34 +38,21 @@ export function buildPreload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Native chrome options for a window. The ONE place the frame decision is made —
|
* Native chrome for every window — the ONE place the frame is decided; the main
|
||||||
* both the main window (src/bun/index.ts) and openSecondaryWindow spread this.
|
* 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.
|
||||||
*
|
*
|
||||||
* Both values are BrowserWindow CONSTRUCTOR options — there is no API to
|
* electrobun 2.x adds trafficLightOffset to macOS's default button position
|
||||||
* re-decorate a live window — which is half of why the skin is a launch-time
|
* (9/9 in the 32pt macOS 26 titlebar) and grows the titlebar to fit — 1.18.1
|
||||||
* choice. Takes no argument on purpose: it reads the pinned sessionSkin, so no
|
* clamped the shift inside the stock titlebar instead. {12, 4} puts the 14pt
|
||||||
* call site can accidentally frame a window from a pending setting.
|
* buttons at left 21 / top 13: vertically centred in the 40px h-10 header.
|
||||||
*/
|
*/
|
||||||
export function chromeOptions() {
|
export function chromeOptions() {
|
||||||
const skin = sessionSkin;
|
|
||||||
// win7 draws its own Aero title bar (drag region + our minimize/maximize/
|
|
||||||
// close), so the window is frameless on EVERY platform. `titleBarStyle:
|
|
||||||
// "hidden"` clears `Titled` from the Cocoa style mask, which is also what
|
|
||||||
// removes the macOS traffic lights — there is no separate
|
|
||||||
// hide-traffic-lights API. Linux honours it too: the GTK wrapper calls
|
|
||||||
// gtk_window_set_decorated(false).
|
|
||||||
//
|
|
||||||
// `transparent` is what lets the Aero frame be a real frame: .window's 6px
|
|
||||||
// radius is drawn by the webview OUTSIDE the window's own rectangle, so on
|
|
||||||
// an opaque surface the corners are simply clipped square.
|
|
||||||
// PORTABILITY: on Linux this needs a compositor granting an RGBA visual.
|
|
||||||
if (skin === "win7") return { titleBarStyle: "hidden" as const, transparent: true };
|
|
||||||
|
|
||||||
// modern: the native frame stays. On macOS it fuses with the app's own
|
|
||||||
// header row (hiddenInset), and the traffic lights overlay it — which is
|
|
||||||
// what IS_MAC_CHROME reserves pl-24 for in the shadlike tree.
|
|
||||||
return process.platform === "darwin"
|
return process.platform === "darwin"
|
||||||
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 4 } }
|
||||||
: {};
|
: {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,36 +76,11 @@ export function showWhenReady(win: BrowserWindow): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Push maximize-state changes to a window's webview so the win7 title bar can
|
|
||||||
* swap its maximize/restore glyph.
|
|
||||||
*
|
|
||||||
* electrobun emits NO maximize/unmaximize event (only close/resize/move/focus/
|
|
||||||
* blur/keyDown/keyUp), so the state is derived from `resize` — which is also
|
|
||||||
* what makes OS-initiated changes work: double-click zoom, Win+Up, Aero Snap
|
|
||||||
* all resize the window. `isMaximized()` is a synchronous FFI read, so polling
|
|
||||||
* it per resize is cheap; the last-value guard keeps the wire quiet during a
|
|
||||||
* drag-resize. The emitter has no off(), so this closure must not capture
|
|
||||||
* anything that needs to be garbage collected (see WindowRegistry.track).
|
|
||||||
*/
|
|
||||||
export function trackMaximizeState(
|
|
||||||
win: BrowserWindow,
|
|
||||||
push: (maximized: boolean) => void,
|
|
||||||
): void {
|
|
||||||
let last = win.isMaximized();
|
|
||||||
win.on("resize", () => {
|
|
||||||
const now = win.isMaximized();
|
|
||||||
if (now === last) return;
|
|
||||||
last = now;
|
|
||||||
push(now);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SecondaryWindowOptions {
|
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;
|
||||||
@@ -162,10 +90,7 @@ 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. The frame decision is delegated to
|
|
||||||
* chromeOptions (the single frame decision).
|
|
||||||
*/
|
|
||||||
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,
|
||||||
|
|||||||
+151
-35
@@ -21,38 +21,29 @@ export const de = {
|
|||||||
tabBar: {
|
tabBar: {
|
||||||
connections: "Verbindungen",
|
connections: "Verbindungen",
|
||||||
manageConnections: "Verbindungen verwalten",
|
manageConnections: "Verbindungen verwalten",
|
||||||
newTab: "Neuer Tab ({mod}T)",
|
newTab: "Neuer Tab",
|
||||||
closeTab: "Tab schließen ({mod}W)",
|
closeTab: "Tab schließen",
|
||||||
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",
|
||||||
|
keyboard: "Tastatur",
|
||||||
|
about: "Über",
|
||||||
|
},
|
||||||
|
columns: "Sichtbare Spalten",
|
||||||
language: "Sprache",
|
language: "Sprache",
|
||||||
system: "System",
|
system: "System",
|
||||||
skin: {
|
|
||||||
label: "Optik",
|
|
||||||
modern: "Modern",
|
|
||||||
win7: "Windows 7",
|
|
||||||
restartHint: "Wird beim nächsten Start von miniSCP übernommen",
|
|
||||||
},
|
|
||||||
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" },
|
|
||||||
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", smaller: "Kleiner", larger: "Größer", reset: "Auf 100% zurücksetzen" },
|
interface: { label: "Oberfläche", smaller: "Kleiner", larger: "Größer", reset: "Auf 100% zurücksetzen" },
|
||||||
speedUnit: { label: "Geschwindigkeitseinheit" },
|
speedUnit: { label: "Geschwindigkeitseinheit" },
|
||||||
transfers: { label: "Übertragungen", single: "Einzeln", multi: "Mehrere" },
|
transfers: { label: "Übertragungen", single: "Einzeln", multi: "Mehrere" },
|
||||||
@@ -61,6 +52,7 @@ export const de = {
|
|||||||
systemDefault: "Systemstandard",
|
systemDefault: "Systemstandard",
|
||||||
resetDefault: "Auf Standard zurücksetzen",
|
resetDefault: "Auf Standard zurücksetzen",
|
||||||
finishedTransfers: { label: "Abgeschlossene Übertragungen", autoClear: "Auto-löschen", keep: "Behalten" },
|
finishedTransfers: { label: "Abgeschlossene Übertragungen", autoClear: "Auto-löschen", keep: "Behalten" },
|
||||||
|
openFiles: { label: "Eingabe / Doppelklick auf eine Datei", nothing: "Nichts tun", open: "Öffnen", hint: "Lokale Dateien öffnen sich in ihrer Standard-App; Ordner öffnen sich immer." },
|
||||||
},
|
},
|
||||||
kinds: {
|
kinds: {
|
||||||
folder: "Ordner",
|
folder: "Ordner",
|
||||||
@@ -85,17 +77,16 @@ export const de = {
|
|||||||
panel: {
|
panel: {
|
||||||
choosePanel: "Wählen, was dieses Panel anzeigt",
|
choosePanel: "Wählen, was dieses Panel anzeigt",
|
||||||
copy: "Senden",
|
copy: "Senden",
|
||||||
copyToRight: "An rechtes Panel senden (F5)",
|
copyToRight: "An rechtes Panel senden",
|
||||||
copyToLeft: "An linkes Panel senden (F5)",
|
copyToLeft: "An linkes Panel senden",
|
||||||
edit: "Bearbeiten",
|
edit: "Bearbeiten",
|
||||||
editFile: "Datei bearbeiten (F4)",
|
editFile: "Datei bearbeiten",
|
||||||
rename: "Umbenennen (F2)",
|
rename: "Umbenennen",
|
||||||
newFolder: "Neuer Ordner (F7)",
|
newFolder: "Neuer Ordner",
|
||||||
newFile: "Neue Datei",
|
newFile: "Neue Datei",
|
||||||
permissions: "Berechtigungen",
|
permissions: "Berechtigungen",
|
||||||
delete: "Löschen",
|
delete: "Löschen",
|
||||||
deleteHint: "Löschen (F8 / Entf)",
|
parentDir: "Übergeordneter Ordner",
|
||||||
parentDir: "Übergeordneter Ordner (Rücktaste)",
|
|
||||||
refresh: "Aktualisieren",
|
refresh: "Aktualisieren",
|
||||||
clearFilter: "Filter löschen (Esc)",
|
clearFilter: "Filter löschen (Esc)",
|
||||||
sshConfig: "~/.ssh/config",
|
sshConfig: "~/.ssh/config",
|
||||||
@@ -154,16 +145,16 @@ export const de = {
|
|||||||
noFileTitle: "Keine Datei zum Bearbeiten",
|
noFileTitle: "Keine Datei zum Bearbeiten",
|
||||||
noFileBody: "Dieses Fenster wurde ohne Editor-Kontext geöffnet. Öffne Dateien aus der App.",
|
noFileBody: "Dieses Fenster wurde ohne Editor-Kontext geöffnet. Öffne Dateien aus der App.",
|
||||||
wrap: "Umbruch",
|
wrap: "Umbruch",
|
||||||
wrapHint: "Lange Zeilen umbrechen — blendet Zeilennummern aus ({alt}Z)",
|
wrapHint: "Lange Zeilen umbrechen — blendet Zeilennummern aus",
|
||||||
findHint: "Suchen ({mod}F)",
|
findHint: "Suchen",
|
||||||
findPlaceholder: "In Datei suchen",
|
findPlaceholder: "In Datei suchen",
|
||||||
prevMatch: "Vorheriger Treffer (⇧↵)",
|
prevMatch: "Vorheriger Treffer",
|
||||||
nextMatch: "Nächster Treffer (↵)",
|
nextMatch: "Nächster Treffer",
|
||||||
closeHint: "Schließen (Esc)",
|
closeHint: "Schließen (Esc)",
|
||||||
disconnected: "Verbindung zu {name} getrennt — Speichern schlägt fehl, aber dein Text bleibt in diesem Fenster erhalten.",
|
disconnected: "Verbindung zu {name} getrennt — Speichern schlägt fehl, aber dein Text bleibt in diesem Fenster erhalten.",
|
||||||
cantOpen: "{name} kann nicht geöffnet werden",
|
cantOpen: "{name} kann nicht geöffnet werden",
|
||||||
closeWindow: "Fenster schließen",
|
closeWindow: "Fenster schließen",
|
||||||
shortcuts: "{mod}S speichern · {mod}F suchen · {mod}W schließen",
|
verbs: { save: "speichern", find: "suchen", close: "schließen" },
|
||||||
lines: { one: "{count} Zeile", other: "{count} Zeilen" },
|
lines: { one: "{count} Zeile", other: "{count} Zeilen" },
|
||||||
close: "Schließen",
|
close: "Schließen",
|
||||||
save: "Speichern",
|
save: "Speichern",
|
||||||
@@ -219,6 +210,7 @@ export const de = {
|
|||||||
},
|
},
|
||||||
toasts: {
|
toasts: {
|
||||||
movedToTrash: '„{name}" in den Papierkorb verschoben',
|
movedToTrash: '„{name}" in den Papierkorb verschoben',
|
||||||
|
movedManyToTrash: { one: "{count} Objekt in den Papierkorb verschoben", other: "{count} Objekte in den Papierkorb verschoben" },
|
||||||
editingExternal: "Bearbeite {name} — Speichern lädt automatisch hoch",
|
editingExternal: "Bearbeite {name} — Speichern lädt automatisch hoch",
|
||||||
nothingToCopy: "Nichts zu kopieren (symbolische Links werden übersprungen)",
|
nothingToCopy: "Nichts zu kopieren (symbolische Links werden übersprungen)",
|
||||||
nothingToCopyAllSkipped: "Nichts zu kopieren — alle Konflikte wurden übersprungen",
|
nothingToCopyAllSkipped: "Nichts zu kopieren — alle Konflikte wurden übersprungen",
|
||||||
@@ -255,6 +247,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",
|
||||||
@@ -301,6 +294,129 @@ export const de = {
|
|||||||
tabDisconnect: "Trennen",
|
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",
|
||||||
},
|
},
|
||||||
|
keys: {
|
||||||
|
"cursor-up": "Nach oben",
|
||||||
|
"cursor-down": "Nach unten",
|
||||||
|
"page-up": "Seite nach oben",
|
||||||
|
"page-down": "Seite nach unten",
|
||||||
|
"cursor-first": "Erster Eintrag",
|
||||||
|
"cursor-last": "Letzter Eintrag",
|
||||||
|
"toggle-select": "Auswählen / abwählen",
|
||||||
|
"select-all": "Alles auswählen",
|
||||||
|
open: "Öffnen",
|
||||||
|
parent: "Übergeordneter Ordner",
|
||||||
|
"switch-panel": "Panel wechseln",
|
||||||
|
"row-menu": "Menü des Eintrags zeigen",
|
||||||
|
send: "An anderes Panel senden",
|
||||||
|
edit: "Bearbeiten",
|
||||||
|
rename: "Umbenennen",
|
||||||
|
"new-folder": "Neuer Ordner",
|
||||||
|
"new-file": "Neue Datei",
|
||||||
|
chmod: "Berechtigungen",
|
||||||
|
delete: "Löschen",
|
||||||
|
trash: "In den Papierkorb",
|
||||||
|
"copy-path": "Pfad kopieren",
|
||||||
|
reveal: "Im Dateimanager zeigen",
|
||||||
|
"open-default": "Mit Standard-App öffnen",
|
||||||
|
refresh: "Aktualisieren",
|
||||||
|
"focus-path": "Zum Pfad",
|
||||||
|
"source-picker": "Panelinhalt wählen",
|
||||||
|
"sort-name": "Nach Name sortieren",
|
||||||
|
"sort-size": "Nach Größe sortieren",
|
||||||
|
"sort-mtime": "Nach Änderungsdatum sortieren",
|
||||||
|
"sort-kind": "Nach Art sortieren",
|
||||||
|
"sort-rights": "Nach Rechten sortieren",
|
||||||
|
"sort-owner": "Nach Eigentümer sortieren",
|
||||||
|
"sort-group": "Nach Gruppe sortieren",
|
||||||
|
"new-tab": "Neuer Tab",
|
||||||
|
"close-tab": "Tab schließen",
|
||||||
|
"close-other-tabs": "Andere Tabs schließen",
|
||||||
|
"close-tabs-right": "Tabs rechts schließen",
|
||||||
|
"next-tab": "Nächster Tab",
|
||||||
|
"prev-tab": "Vorheriger Tab",
|
||||||
|
"go-to-tab": "Zu Tab {n}",
|
||||||
|
"go-to-last-tab": "Zum letzten Tab",
|
||||||
|
"move-tab-left": "Tab nach links",
|
||||||
|
"move-tab-right": "Tab nach rechts",
|
||||||
|
"disconnect-tab": "Tab trennen",
|
||||||
|
"open-settings": "Einstellungen",
|
||||||
|
"connection-manager": "Verbindungsverwaltung",
|
||||||
|
"toggle-transfers": "Übertragungen ein-/ausblenden",
|
||||||
|
"install-update": "Update installieren",
|
||||||
|
"scale-up": "Oberfläche größer",
|
||||||
|
"scale-down": "Oberfläche kleiner",
|
||||||
|
"scale-reset": "Oberfläche auf 100 %",
|
||||||
|
"close-window": "Fenster schließen",
|
||||||
|
save: "Speichern",
|
||||||
|
find: "Suchen",
|
||||||
|
"find-next": "Weitersuchen",
|
||||||
|
"find-prev": "Rückwärts suchen",
|
||||||
|
"toggle-wrap": "Zeilen umbrechen",
|
||||||
|
"conn-new": "Neue Verbindung",
|
||||||
|
"conn-new-stack": "Neue Gruppe",
|
||||||
|
"conn-save": "Verbindung speichern",
|
||||||
|
"conn-connect": "Verbinden / Gruppe öffnen",
|
||||||
|
"conn-delete": "Verbindung oder Gruppe löschen",
|
||||||
|
"conn-rename-stack": "Gruppe umbenennen",
|
||||||
|
"conn-import": "Aus WinSCP importieren",
|
||||||
|
groups: {
|
||||||
|
panels: "Dateiliste",
|
||||||
|
files: "Dateien",
|
||||||
|
sort: "Sortierung",
|
||||||
|
tabs: "Tabs",
|
||||||
|
window: "Fenster",
|
||||||
|
editor: "Editor",
|
||||||
|
connections: "Verbindungsverwaltung",
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
filter: "Tastenkürzel suchen",
|
||||||
|
intro: "Auf + klicken und die Tasten drücken, um ein Kürzel hinzuzufügen. Das erste zeigen Menüs und Tooltips an.",
|
||||||
|
add: "Kürzel hinzufügen",
|
||||||
|
remove: "{key} entfernen",
|
||||||
|
reset: "Auf Standard zurücksetzen",
|
||||||
|
resetAll: "Alle zurücksetzen",
|
||||||
|
resetAllConfirm: "Alle Kürzel zurücksetzen",
|
||||||
|
press: "Tasten drücken… ({esc} bricht ab)",
|
||||||
|
unbound: "Kein Kürzel",
|
||||||
|
reserved: "{key} ist vom System reserviert",
|
||||||
|
needsModifier: "Im Editor würde {key} nur tippen – eine Sondertaste hinzufügen oder eine F-Taste nehmen",
|
||||||
|
usedBy: "{key} wird schon von „{action}“ benutzt",
|
||||||
|
replace: "Ersetzen",
|
||||||
|
conflict: "Gleiche Taste wie {action}",
|
||||||
|
plainLetter: "{key} in der Dateiliste tippen filtert sie nicht mehr",
|
||||||
|
noResults: "Kein Kürzel passt",
|
||||||
|
fixed: "Feste Tasten",
|
||||||
|
},
|
||||||
|
fixed: {
|
||||||
|
escape: "{esc} schließt Menüs, Dialoge und die Suchleiste",
|
||||||
|
escList: "{esc} in der Dateiliste leert den Filter, bricht dann eine Verbindung ab und hebt dann die Auswahl auf",
|
||||||
|
dialogs: "{enter} und {esc} bestätigen oder verwerfen einen Dialog; {tab} und {shiftTab} wechseln zwischen seinen Elementen",
|
||||||
|
menus: "↑ ↓ {home} {end} und {enter} funktionieren in jedem Menü",
|
||||||
|
filter: "Tippen in der Dateiliste filtert sie; {backspace} löscht das letzte Zeichen des Filters",
|
||||||
|
shift: "Umschalt mit einer Bewegungstaste wählt die überfahrenen Zeilen aus",
|
||||||
|
find: "{enter} und {shiftEnter} springen durch die Treffer der Suche",
|
||||||
|
manager: "↑ ↓ ← → und {enter} bewegen sich durch die Verbindungsliste",
|
||||||
|
sidebar: "↑ ↓ wechseln hier zwischen den Bereichen",
|
||||||
|
},
|
||||||
|
names: {
|
||||||
|
ctrl: "Strg",
|
||||||
|
alt: "Alt",
|
||||||
|
shift: "Umschalt",
|
||||||
|
enter: "Eingabe",
|
||||||
|
backspace: "Rücktaste",
|
||||||
|
del: "Entf",
|
||||||
|
esc: "Esc",
|
||||||
|
space: "Leertaste",
|
||||||
|
tab: "Tab",
|
||||||
|
pgup: "Bild↑",
|
||||||
|
pgdn: "Bild↓",
|
||||||
|
home: "Pos1",
|
||||||
|
end: "Ende",
|
||||||
|
ins: "Einfg",
|
||||||
|
menu: "Menü",
|
||||||
|
num: "Num",
|
||||||
|
},
|
||||||
|
},
|
||||||
errors: {
|
errors: {
|
||||||
sshAgentUnavailable: "ssh-agent nicht verfügbar (SSH_AUTH_SOCK ist nicht gesetzt)",
|
sshAgentUnavailable: "ssh-agent nicht verfügbar (SSH_AUTH_SOCK ist nicht gesetzt)",
|
||||||
identityFileNotFound: "Identitätsdatei nicht gefunden: {path}",
|
identityFileNotFound: "Identitätsdatei nicht gefunden: {path}",
|
||||||
|
|||||||
+156
-35
@@ -25,38 +25,29 @@ export const en = {
|
|||||||
tabBar: {
|
tabBar: {
|
||||||
connections: "Connections",
|
connections: "Connections",
|
||||||
manageConnections: "Manage connections",
|
manageConnections: "Manage connections",
|
||||||
newTab: "New tab ({mod}T)",
|
newTab: "New tab",
|
||||||
closeTab: "Close tab ({mod}W)",
|
closeTab: "Close tab",
|
||||||
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",
|
||||||
|
keyboard: "Keyboard",
|
||||||
|
about: "About",
|
||||||
|
},
|
||||||
|
columns: "Visible columns",
|
||||||
language: "Language",
|
language: "Language",
|
||||||
system: "System",
|
system: "System",
|
||||||
skin: {
|
|
||||||
label: "Skin",
|
|
||||||
modern: "Modern",
|
|
||||||
win7: "Windows 7",
|
|
||||||
restartHint: "Applies next time you start miniSCP",
|
|
||||||
},
|
|
||||||
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" },
|
|
||||||
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", smaller: "Smaller", larger: "Larger", reset: "Reset to 100%" },
|
interface: { label: "Interface", smaller: "Smaller", larger: "Larger", reset: "Reset to 100%" },
|
||||||
speedUnit: { label: "Speed unit" },
|
speedUnit: { label: "Speed unit" },
|
||||||
transfers: { label: "Transfers", single: "Single", multi: "Multi" },
|
transfers: { label: "Transfers", single: "Single", multi: "Multi" },
|
||||||
@@ -65,6 +56,7 @@ export const en = {
|
|||||||
systemDefault: "System default",
|
systemDefault: "System default",
|
||||||
resetDefault: "Reset to system default",
|
resetDefault: "Reset to system default",
|
||||||
finishedTransfers: { label: "Finished transfers", autoClear: "Auto-clear", keep: "Keep" },
|
finishedTransfers: { label: "Finished transfers", autoClear: "Auto-clear", keep: "Keep" },
|
||||||
|
openFiles: { label: "Enter / double-click on a file", nothing: "Do nothing", open: "Open", hint: "Local files open in their default app; folders always open." },
|
||||||
},
|
},
|
||||||
kinds: {
|
kinds: {
|
||||||
folder: "Folder",
|
folder: "Folder",
|
||||||
@@ -89,17 +81,16 @@ export const en = {
|
|||||||
panel: {
|
panel: {
|
||||||
choosePanel: "Choose what this panel shows",
|
choosePanel: "Choose what this panel shows",
|
||||||
copy: "Send",
|
copy: "Send",
|
||||||
copyToRight: "Send to right panel (F5)",
|
copyToRight: "Send to right panel",
|
||||||
copyToLeft: "Send to left panel (F5)",
|
copyToLeft: "Send to left panel",
|
||||||
edit: "Edit",
|
edit: "Edit",
|
||||||
editFile: "Edit file (F4)",
|
editFile: "Edit file",
|
||||||
rename: "Rename (F2)",
|
rename: "Rename",
|
||||||
newFolder: "New folder (F7)",
|
newFolder: "New folder",
|
||||||
newFile: "New file",
|
newFile: "New file",
|
||||||
permissions: "Permissions",
|
permissions: "Permissions",
|
||||||
delete: "Delete",
|
delete: "Delete",
|
||||||
deleteHint: "Delete (F8 / Del)",
|
parentDir: "Parent directory",
|
||||||
parentDir: "Parent directory (Backspace)",
|
|
||||||
refresh: "Refresh",
|
refresh: "Refresh",
|
||||||
clearFilter: "Clear filter (Esc)",
|
clearFilter: "Clear filter (Esc)",
|
||||||
sshConfig: "~/.ssh/config",
|
sshConfig: "~/.ssh/config",
|
||||||
@@ -158,16 +149,17 @@ export const en = {
|
|||||||
noFileTitle: "No file to edit",
|
noFileTitle: "No file to edit",
|
||||||
noFileBody: "This window was opened without an editor context. Launch files from the app.",
|
noFileBody: "This window was opened without an editor context. Launch files from the app.",
|
||||||
wrap: "Wrap",
|
wrap: "Wrap",
|
||||||
wrapHint: "Wrap long lines — hides line numbers ({alt}Z)",
|
wrapHint: "Wrap long lines — hides line numbers",
|
||||||
findHint: "Find ({mod}F)",
|
findHint: "Find",
|
||||||
findPlaceholder: "Find in file",
|
findPlaceholder: "Find in file",
|
||||||
prevMatch: "Previous match (⇧↵)",
|
prevMatch: "Previous match",
|
||||||
nextMatch: "Next match (↵)",
|
nextMatch: "Next match",
|
||||||
closeHint: "Close (Esc)",
|
closeHint: "Close (Esc)",
|
||||||
disconnected: "Disconnected from {name} — saving will fail, but your text is preserved in this window.",
|
disconnected: "Disconnected from {name} — saving will fail, but your text is preserved in this window.",
|
||||||
cantOpen: "Can't open {name}",
|
cantOpen: "Can't open {name}",
|
||||||
closeWindow: "Close Window",
|
closeWindow: "Close Window",
|
||||||
shortcuts: "{mod}S save · {mod}F find · {mod}W close",
|
// footer hint: "⌘S save · ⌘F find · ⌘W close", keys from the keymap
|
||||||
|
verbs: { save: "save", find: "find", close: "close" },
|
||||||
lines: { one: "{count} line", other: "{count} lines" },
|
lines: { one: "{count} line", other: "{count} lines" },
|
||||||
close: "Close",
|
close: "Close",
|
||||||
save: "Save",
|
save: "Save",
|
||||||
@@ -223,6 +215,7 @@ export const en = {
|
|||||||
},
|
},
|
||||||
toasts: {
|
toasts: {
|
||||||
movedToTrash: 'Moved "{name}" to Trash',
|
movedToTrash: 'Moved "{name}" to Trash',
|
||||||
|
movedManyToTrash: { one: "Moved {count} item to Trash", other: "Moved {count} items to Trash" },
|
||||||
editingExternal: "Editing {name} — saves upload automatically",
|
editingExternal: "Editing {name} — saves upload automatically",
|
||||||
nothingToCopy: "Nothing to copy (symlinks are skipped)",
|
nothingToCopy: "Nothing to copy (symlinks are skipped)",
|
||||||
nothingToCopyAllSkipped: "Nothing to copy — all conflicting items were skipped",
|
nothingToCopyAllSkipped: "Nothing to copy — all conflicting items were skipped",
|
||||||
@@ -259,6 +252,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",
|
||||||
@@ -305,6 +299,133 @@ export const en = {
|
|||||||
tabDisconnect: "Disconnect",
|
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",
|
||||||
},
|
},
|
||||||
|
// Keyboard commands — one label per src/shared/keymap.ts BINDINGS id
|
||||||
|
keys: {
|
||||||
|
"cursor-up": "Move up",
|
||||||
|
"cursor-down": "Move down",
|
||||||
|
"page-up": "Page up",
|
||||||
|
"page-down": "Page down",
|
||||||
|
"cursor-first": "First item",
|
||||||
|
"cursor-last": "Last item",
|
||||||
|
"toggle-select": "Select / deselect",
|
||||||
|
"select-all": "Select all",
|
||||||
|
open: "Open",
|
||||||
|
parent: "Parent folder",
|
||||||
|
"switch-panel": "Switch panel",
|
||||||
|
"row-menu": "Show the item's menu",
|
||||||
|
send: "Send to the other panel",
|
||||||
|
edit: "Edit",
|
||||||
|
rename: "Rename",
|
||||||
|
"new-folder": "New folder",
|
||||||
|
"new-file": "New file",
|
||||||
|
chmod: "Permissions",
|
||||||
|
delete: "Delete",
|
||||||
|
trash: "Move to Trash",
|
||||||
|
"copy-path": "Copy path",
|
||||||
|
reveal: "Reveal in file manager",
|
||||||
|
"open-default": "Open in default app",
|
||||||
|
refresh: "Refresh",
|
||||||
|
"focus-path": "Go to path",
|
||||||
|
"source-picker": "Choose what the panel shows",
|
||||||
|
"sort-name": "Sort by name",
|
||||||
|
"sort-size": "Sort by size",
|
||||||
|
"sort-mtime": "Sort by modified",
|
||||||
|
"sort-kind": "Sort by kind",
|
||||||
|
"sort-rights": "Sort by rights",
|
||||||
|
"sort-owner": "Sort by owner",
|
||||||
|
"sort-group": "Sort by group",
|
||||||
|
"new-tab": "New tab",
|
||||||
|
"close-tab": "Close tab",
|
||||||
|
"close-other-tabs": "Close other tabs",
|
||||||
|
"close-tabs-right": "Close tabs to the right",
|
||||||
|
"next-tab": "Next tab",
|
||||||
|
"prev-tab": "Previous tab",
|
||||||
|
"go-to-tab": "Go to tab {n}",
|
||||||
|
"go-to-last-tab": "Go to last tab",
|
||||||
|
"move-tab-left": "Move tab left",
|
||||||
|
"move-tab-right": "Move tab right",
|
||||||
|
"disconnect-tab": "Disconnect tab",
|
||||||
|
"open-settings": "Settings",
|
||||||
|
"connection-manager": "Connection manager",
|
||||||
|
"toggle-transfers": "Show / hide transfers",
|
||||||
|
"install-update": "Install update",
|
||||||
|
"scale-up": "Bigger interface",
|
||||||
|
"scale-down": "Smaller interface",
|
||||||
|
"scale-reset": "Interface at 100%",
|
||||||
|
"close-window": "Close window",
|
||||||
|
save: "Save",
|
||||||
|
find: "Find",
|
||||||
|
"find-next": "Find next",
|
||||||
|
"find-prev": "Find previous",
|
||||||
|
"toggle-wrap": "Wrap lines",
|
||||||
|
"conn-new": "New connection",
|
||||||
|
"conn-new-stack": "New stack",
|
||||||
|
"conn-save": "Save connection",
|
||||||
|
"conn-connect": "Connect / open stack",
|
||||||
|
"conn-delete": "Delete connection or stack",
|
||||||
|
"conn-rename-stack": "Rename stack",
|
||||||
|
"conn-import": "Import from WinSCP",
|
||||||
|
groups: {
|
||||||
|
panels: "File list",
|
||||||
|
files: "Files",
|
||||||
|
sort: "Sorting",
|
||||||
|
tabs: "Tabs",
|
||||||
|
window: "Window",
|
||||||
|
editor: "Editor",
|
||||||
|
connections: "Connection manager",
|
||||||
|
},
|
||||||
|
// Settings → Keyboard
|
||||||
|
ui: {
|
||||||
|
filter: "Search shortcuts",
|
||||||
|
intro: "Press + and type the keys to add a shortcut. The first one is the hint menus and tooltips show.",
|
||||||
|
add: "Add a shortcut",
|
||||||
|
remove: "Remove {key}",
|
||||||
|
reset: "Reset to default",
|
||||||
|
resetAll: "Reset all",
|
||||||
|
resetAllConfirm: "Reset every shortcut",
|
||||||
|
press: "Press keys… ({esc} cancels)",
|
||||||
|
unbound: "No shortcut",
|
||||||
|
reserved: "{key} is reserved by the system",
|
||||||
|
needsModifier: "In the editor {key} would only type — add a modifier or use an F-key",
|
||||||
|
usedBy: "{key} is already used by “{action}”",
|
||||||
|
replace: "Replace",
|
||||||
|
conflict: "Same key as {action}",
|
||||||
|
plainLetter: "Typing {key} in the file list no longer filters it",
|
||||||
|
noResults: "No shortcut matches",
|
||||||
|
fixed: "Fixed keys",
|
||||||
|
},
|
||||||
|
// fixed keys, listed read-only there (key params come from the platform)
|
||||||
|
fixed: {
|
||||||
|
escape: "{esc} closes menus, dialogs and the find bar",
|
||||||
|
escList: "{esc} in the file list clears the filter, then cancels a connect, then clears the selection",
|
||||||
|
dialogs: "{enter} and {esc} confirm or cancel a dialog; {tab} and {shiftTab} move between its controls",
|
||||||
|
menus: "↑ ↓ {home} {end} and {enter} work in every menu",
|
||||||
|
filter: "Typing in the file list filters it; {backspace} trims the filter",
|
||||||
|
shift: "Shift with a movement key selects the rows the cursor passes",
|
||||||
|
find: "{enter} and {shiftEnter} step through the matches in the find box",
|
||||||
|
manager: "↑ ↓ ← → and {enter} move through the connection list",
|
||||||
|
sidebar: "↑ ↓ move between these sections",
|
||||||
|
},
|
||||||
|
// key names for "Ctrl+Shift+Del"-style hints (not used on macOS, which shows ⌘⇧⌫)
|
||||||
|
names: {
|
||||||
|
ctrl: "Ctrl",
|
||||||
|
alt: "Alt",
|
||||||
|
shift: "Shift",
|
||||||
|
enter: "Enter",
|
||||||
|
backspace: "Backspace",
|
||||||
|
del: "Del",
|
||||||
|
esc: "Esc",
|
||||||
|
space: "Space",
|
||||||
|
tab: "Tab",
|
||||||
|
pgup: "PgUp",
|
||||||
|
pgdn: "PgDn",
|
||||||
|
home: "Home",
|
||||||
|
end: "End",
|
||||||
|
ins: "Ins",
|
||||||
|
menu: "Menu",
|
||||||
|
num: "Num",
|
||||||
|
},
|
||||||
|
},
|
||||||
errors: {
|
errors: {
|
||||||
sshAgentUnavailable: "ssh-agent not available (SSH_AUTH_SOCK is not set)",
|
sshAgentUnavailable: "ssh-agent not available (SSH_AUTH_SOCK is not set)",
|
||||||
identityFileNotFound: "identity file not found: {path}",
|
identityFileNotFound: "identity file not found: {path}",
|
||||||
|
|||||||
+151
-35
@@ -21,38 +21,29 @@ export const es = {
|
|||||||
tabBar: {
|
tabBar: {
|
||||||
connections: "Conexiones",
|
connections: "Conexiones",
|
||||||
manageConnections: "Gestionar conexiones",
|
manageConnections: "Gestionar conexiones",
|
||||||
newTab: "Nueva pestaña ({mod}T)",
|
newTab: "Nueva pestaña",
|
||||||
closeTab: "Cerrar pestaña ({mod}W)",
|
closeTab: "Cerrar pestaña",
|
||||||
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",
|
||||||
|
keyboard: "Teclado",
|
||||||
|
about: "Acerca de",
|
||||||
|
},
|
||||||
|
columns: "Columnas visibles",
|
||||||
language: "Idioma",
|
language: "Idioma",
|
||||||
system: "Sistema",
|
system: "Sistema",
|
||||||
skin: {
|
|
||||||
label: "Aspecto",
|
|
||||||
modern: "Moderno",
|
|
||||||
win7: "Windows 7",
|
|
||||||
restartHint: "Se aplica la próxima vez que abras miniSCP",
|
|
||||||
},
|
|
||||||
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" },
|
|
||||||
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", smaller: "Más pequeña", larger: "Más grande", reset: "Restablecer al 100%" },
|
interface: { label: "Interfaz", smaller: "Más pequeña", larger: "Más grande", reset: "Restablecer al 100%" },
|
||||||
speedUnit: { label: "Unidad de velocidad" },
|
speedUnit: { label: "Unidad de velocidad" },
|
||||||
transfers: { label: "Transferencias", single: "Una", multi: "Varias" },
|
transfers: { label: "Transferencias", single: "Una", multi: "Varias" },
|
||||||
@@ -61,6 +52,7 @@ export const es = {
|
|||||||
systemDefault: "Predeterminado del sistema",
|
systemDefault: "Predeterminado del sistema",
|
||||||
resetDefault: "Restablecer al predeterminado",
|
resetDefault: "Restablecer al predeterminado",
|
||||||
finishedTransfers: { label: "Transferencias completadas", autoClear: "Auto-limpiar", keep: "Mantener" },
|
finishedTransfers: { label: "Transferencias completadas", autoClear: "Auto-limpiar", keep: "Mantener" },
|
||||||
|
openFiles: { label: "Intro / doble clic en un archivo", nothing: "No hacer nada", open: "Abrir", hint: "Los archivos locales se abren con su app predeterminada; las carpetas siempre se abren." },
|
||||||
},
|
},
|
||||||
kinds: {
|
kinds: {
|
||||||
folder: "Carpeta",
|
folder: "Carpeta",
|
||||||
@@ -85,17 +77,16 @@ export const es = {
|
|||||||
panel: {
|
panel: {
|
||||||
choosePanel: "Elige qué muestra este panel",
|
choosePanel: "Elige qué muestra este panel",
|
||||||
copy: "Enviar",
|
copy: "Enviar",
|
||||||
copyToRight: "Enviar al panel derecho (F5)",
|
copyToRight: "Enviar al panel derecho",
|
||||||
copyToLeft: "Enviar al panel izquierdo (F5)",
|
copyToLeft: "Enviar al panel izquierdo",
|
||||||
edit: "Editar",
|
edit: "Editar",
|
||||||
editFile: "Editar archivo (F4)",
|
editFile: "Editar archivo",
|
||||||
rename: "Renombrar (F2)",
|
rename: "Renombrar",
|
||||||
newFolder: "Nueva carpeta (F7)",
|
newFolder: "Nueva carpeta",
|
||||||
newFile: "Nuevo archivo",
|
newFile: "Nuevo archivo",
|
||||||
permissions: "Permisos",
|
permissions: "Permisos",
|
||||||
delete: "Eliminar",
|
delete: "Eliminar",
|
||||||
deleteHint: "Eliminar (F8 / Supr)",
|
parentDir: "Carpeta superior",
|
||||||
parentDir: "Carpeta superior (Retroceso)",
|
|
||||||
refresh: "Actualizar",
|
refresh: "Actualizar",
|
||||||
clearFilter: "Borrar filtro (Esc)",
|
clearFilter: "Borrar filtro (Esc)",
|
||||||
sshConfig: "~/.ssh/config",
|
sshConfig: "~/.ssh/config",
|
||||||
@@ -154,16 +145,16 @@ export const es = {
|
|||||||
noFileTitle: "Ningún archivo que editar",
|
noFileTitle: "Ningún archivo que editar",
|
||||||
noFileBody: "Esta ventana se abrió sin contexto de editor. Abre archivos desde la aplicación.",
|
noFileBody: "Esta ventana se abrió sin contexto de editor. Abre archivos desde la aplicación.",
|
||||||
wrap: "Ajustar",
|
wrap: "Ajustar",
|
||||||
wrapHint: "Ajustar líneas largas — oculta los números de línea ({alt}Z)",
|
wrapHint: "Ajustar líneas largas — oculta los números de línea",
|
||||||
findHint: "Buscar ({mod}F)",
|
findHint: "Buscar",
|
||||||
findPlaceholder: "Buscar en el archivo",
|
findPlaceholder: "Buscar en el archivo",
|
||||||
prevMatch: "Coincidencia anterior (⇧↵)",
|
prevMatch: "Coincidencia anterior",
|
||||||
nextMatch: "Coincidencia siguiente (↵)",
|
nextMatch: "Coincidencia siguiente",
|
||||||
closeHint: "Cerrar (Esc)",
|
closeHint: "Cerrar (Esc)",
|
||||||
disconnected: "Desconectado de {name} — no se podrá guardar, pero tu texto se conserva en esta ventana.",
|
disconnected: "Desconectado de {name} — no se podrá guardar, pero tu texto se conserva en esta ventana.",
|
||||||
cantOpen: "No se puede abrir {name}",
|
cantOpen: "No se puede abrir {name}",
|
||||||
closeWindow: "Cerrar ventana",
|
closeWindow: "Cerrar ventana",
|
||||||
shortcuts: "{mod}S guardar · {mod}F buscar · {mod}W cerrar",
|
verbs: { save: "guardar", find: "buscar", close: "cerrar" },
|
||||||
lines: { one: "{count} línea", other: "{count} líneas" },
|
lines: { one: "{count} línea", other: "{count} líneas" },
|
||||||
close: "Cerrar",
|
close: "Cerrar",
|
||||||
save: "Guardar",
|
save: "Guardar",
|
||||||
@@ -219,6 +210,7 @@ export const es = {
|
|||||||
},
|
},
|
||||||
toasts: {
|
toasts: {
|
||||||
movedToTrash: 'Se movió "{name}" a la papelera',
|
movedToTrash: 'Se movió "{name}" a la papelera',
|
||||||
|
movedManyToTrash: { one: "Se movió {count} elemento a la papelera", other: "Se movieron {count} elementos a la papelera" },
|
||||||
editingExternal: "Editando {name} — los cambios se suben automáticamente",
|
editingExternal: "Editando {name} — los cambios se suben automáticamente",
|
||||||
nothingToCopy: "Nada que copiar (los enlaces simbólicos se omiten)",
|
nothingToCopy: "Nada que copiar (los enlaces simbólicos se omiten)",
|
||||||
nothingToCopyAllSkipped: "Nada que copiar — se omitieron todos los elementos en conflicto",
|
nothingToCopyAllSkipped: "Nada que copiar — se omitieron todos los elementos en conflicto",
|
||||||
@@ -255,6 +247,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",
|
||||||
@@ -301,6 +294,129 @@ export const es = {
|
|||||||
tabDisconnect: "Desconectar",
|
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í",
|
||||||
},
|
},
|
||||||
|
keys: {
|
||||||
|
"cursor-up": "Subir",
|
||||||
|
"cursor-down": "Bajar",
|
||||||
|
"page-up": "Página arriba",
|
||||||
|
"page-down": "Página abajo",
|
||||||
|
"cursor-first": "Primer elemento",
|
||||||
|
"cursor-last": "Último elemento",
|
||||||
|
"toggle-select": "Seleccionar / deseleccionar",
|
||||||
|
"select-all": "Seleccionar todo",
|
||||||
|
open: "Abrir",
|
||||||
|
parent: "Carpeta superior",
|
||||||
|
"switch-panel": "Cambiar de panel",
|
||||||
|
"row-menu": "Mostrar el menú del elemento",
|
||||||
|
send: "Enviar al otro panel",
|
||||||
|
edit: "Editar",
|
||||||
|
rename: "Renombrar",
|
||||||
|
"new-folder": "Nueva carpeta",
|
||||||
|
"new-file": "Nuevo archivo",
|
||||||
|
chmod: "Permisos",
|
||||||
|
delete: "Eliminar",
|
||||||
|
trash: "Mover a la papelera",
|
||||||
|
"copy-path": "Copiar ruta",
|
||||||
|
reveal: "Mostrar en el gestor de archivos",
|
||||||
|
"open-default": "Abrir con la app predeterminada",
|
||||||
|
refresh: "Actualizar",
|
||||||
|
"focus-path": "Ir a la ruta",
|
||||||
|
"source-picker": "Elegir qué muestra el panel",
|
||||||
|
"sort-name": "Ordenar por nombre",
|
||||||
|
"sort-size": "Ordenar por tamaño",
|
||||||
|
"sort-mtime": "Ordenar por fecha de modificación",
|
||||||
|
"sort-kind": "Ordenar por tipo",
|
||||||
|
"sort-rights": "Ordenar por permisos",
|
||||||
|
"sort-owner": "Ordenar por propietario",
|
||||||
|
"sort-group": "Ordenar por grupo",
|
||||||
|
"new-tab": "Nueva pestaña",
|
||||||
|
"close-tab": "Cerrar pestaña",
|
||||||
|
"close-other-tabs": "Cerrar las demás pestañas",
|
||||||
|
"close-tabs-right": "Cerrar las pestañas de la derecha",
|
||||||
|
"next-tab": "Pestaña siguiente",
|
||||||
|
"prev-tab": "Pestaña anterior",
|
||||||
|
"go-to-tab": "Ir a la pestaña {n}",
|
||||||
|
"go-to-last-tab": "Ir a la última pestaña",
|
||||||
|
"move-tab-left": "Mover la pestaña a la izquierda",
|
||||||
|
"move-tab-right": "Mover la pestaña a la derecha",
|
||||||
|
"disconnect-tab": "Desconectar la pestaña",
|
||||||
|
"open-settings": "Ajustes",
|
||||||
|
"connection-manager": "Gestor de conexiones",
|
||||||
|
"toggle-transfers": "Mostrar / ocultar transferencias",
|
||||||
|
"install-update": "Instalar la actualización",
|
||||||
|
"scale-up": "Interfaz más grande",
|
||||||
|
"scale-down": "Interfaz más pequeña",
|
||||||
|
"scale-reset": "Interfaz al 100 %",
|
||||||
|
"close-window": "Cerrar la ventana",
|
||||||
|
save: "Guardar",
|
||||||
|
find: "Buscar",
|
||||||
|
"find-next": "Buscar siguiente",
|
||||||
|
"find-prev": "Buscar anterior",
|
||||||
|
"toggle-wrap": "Ajustar líneas",
|
||||||
|
"conn-new": "Nueva conexión",
|
||||||
|
"conn-new-stack": "Nuevo grupo",
|
||||||
|
"conn-save": "Guardar la conexión",
|
||||||
|
"conn-connect": "Conectar / abrir grupo",
|
||||||
|
"conn-delete": "Eliminar conexión o grupo",
|
||||||
|
"conn-rename-stack": "Renombrar grupo",
|
||||||
|
"conn-import": "Importar de WinSCP",
|
||||||
|
groups: {
|
||||||
|
panels: "Lista de archivos",
|
||||||
|
files: "Archivos",
|
||||||
|
sort: "Orden",
|
||||||
|
tabs: "Pestañas",
|
||||||
|
window: "Ventana",
|
||||||
|
editor: "Editor",
|
||||||
|
connections: "Gestor de conexiones",
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
filter: "Buscar atajos",
|
||||||
|
intro: "Pulsa + y teclea las teclas para añadir un atajo. El primero es el que muestran los menús y las pistas.",
|
||||||
|
add: "Añadir un atajo",
|
||||||
|
remove: "Quitar {key}",
|
||||||
|
reset: "Restablecer el predeterminado",
|
||||||
|
resetAll: "Restablecer todo",
|
||||||
|
resetAllConfirm: "Restablecer todos los atajos",
|
||||||
|
press: "Pulsa las teclas… ({esc} cancela)",
|
||||||
|
unbound: "Sin atajo",
|
||||||
|
reserved: "{key} está reservado por el sistema",
|
||||||
|
needsModifier: "En el editor {key} solo escribiría: añade un modificador o usa una tecla F",
|
||||||
|
usedBy: "{key} ya lo usa «{action}»",
|
||||||
|
replace: "Reemplazar",
|
||||||
|
conflict: "Misma tecla que {action}",
|
||||||
|
plainLetter: "Teclear {key} en la lista de archivos ya no la filtra",
|
||||||
|
noResults: "Ningún atajo coincide",
|
||||||
|
fixed: "Teclas fijas",
|
||||||
|
},
|
||||||
|
fixed: {
|
||||||
|
escape: "{esc} cierra menús, diálogos y la barra de búsqueda",
|
||||||
|
escList: "{esc} en la lista de archivos borra el filtro, luego cancela una conexión y luego quita la selección",
|
||||||
|
dialogs: "{enter} y {esc} confirman o cancelan un diálogo; {tab} y {shiftTab} recorren sus controles",
|
||||||
|
menus: "↑ ↓ {home} {end} y {enter} funcionan en todos los menús",
|
||||||
|
filter: "Teclear en la lista de archivos la filtra; {backspace} borra el último carácter del filtro",
|
||||||
|
shift: "Mayús con una tecla de movimiento selecciona las filas por las que pasa el cursor",
|
||||||
|
find: "{enter} y {shiftEnter} recorren las coincidencias de la búsqueda",
|
||||||
|
manager: "↑ ↓ ← → y {enter} recorren la lista de conexiones",
|
||||||
|
sidebar: "↑ ↓ cambian de sección aquí",
|
||||||
|
},
|
||||||
|
names: {
|
||||||
|
ctrl: "Ctrl",
|
||||||
|
alt: "Alt",
|
||||||
|
shift: "Mayús",
|
||||||
|
enter: "Intro",
|
||||||
|
backspace: "Retroceso",
|
||||||
|
del: "Supr",
|
||||||
|
esc: "Esc",
|
||||||
|
space: "Espacio",
|
||||||
|
tab: "Tab",
|
||||||
|
pgup: "RePág",
|
||||||
|
pgdn: "AvPág",
|
||||||
|
home: "Inicio",
|
||||||
|
end: "Fin",
|
||||||
|
ins: "Insert",
|
||||||
|
menu: "Menú",
|
||||||
|
num: "Num",
|
||||||
|
},
|
||||||
|
},
|
||||||
errors: {
|
errors: {
|
||||||
sshAgentUnavailable: "ssh-agent no disponible (SSH_AUTH_SOCK no está definido)",
|
sshAgentUnavailable: "ssh-agent no disponible (SSH_AUTH_SOCK no está definido)",
|
||||||
identityFileNotFound: "archivo de identidad no encontrado: {path}",
|
identityFileNotFound: "archivo de identidad no encontrado: {path}",
|
||||||
|
|||||||
+151
-35
@@ -23,38 +23,29 @@ export const eu = {
|
|||||||
tabBar: {
|
tabBar: {
|
||||||
connections: "Konexioak",
|
connections: "Konexioak",
|
||||||
manageConnections: "Kudeatu konexioak",
|
manageConnections: "Kudeatu konexioak",
|
||||||
newTab: "Fitxa berria ({mod}T)",
|
newTab: "Fitxa berria",
|
||||||
closeTab: "Itxi fitxa ({mod}W)",
|
closeTab: "Itxi fitxa",
|
||||||
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",
|
||||||
|
keyboard: "Teklatua",
|
||||||
|
about: "Honi buruz",
|
||||||
|
},
|
||||||
|
columns: "Zutabe ikusgaiak",
|
||||||
language: "Hizkuntza",
|
language: "Hizkuntza",
|
||||||
system: "Sistema",
|
system: "Sistema",
|
||||||
skin: {
|
|
||||||
label: "Itxura",
|
|
||||||
modern: "Modernoa",
|
|
||||||
win7: "Windows 7",
|
|
||||||
restartHint: "Hurrengoan miniSCP abiaraztean aplikatuko da",
|
|
||||||
},
|
|
||||||
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" },
|
|
||||||
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", smaller: "Txikiagoa", larger: "Handiagoa", reset: "Berrezarri %100era" },
|
interface: { label: "Interfazea", smaller: "Txikiagoa", larger: "Handiagoa", reset: "Berrezarri %100era" },
|
||||||
speedUnit: { label: "Abiadura-unitatea" },
|
speedUnit: { label: "Abiadura-unitatea" },
|
||||||
transfers: { label: "Transferentziak", single: "Banaka", multi: "Anitz" },
|
transfers: { label: "Transferentziak", single: "Banaka", multi: "Anitz" },
|
||||||
@@ -63,6 +54,7 @@ export const eu = {
|
|||||||
systemDefault: "Sistemaren lehenetsia",
|
systemDefault: "Sistemaren lehenetsia",
|
||||||
resetDefault: "Berrezarri lehenetsira",
|
resetDefault: "Berrezarri lehenetsira",
|
||||||
finishedTransfers: { label: "Amaitutako transferentziak", autoClear: "Garbitu", keep: "Mantendu" },
|
finishedTransfers: { label: "Amaitutako transferentziak", autoClear: "Garbitu", keep: "Mantendu" },
|
||||||
|
openFiles: { label: "Sartu / klik bikoitza fitxategi batean", nothing: "Ezer ez", open: "Ireki", hint: "Fitxategi lokalak beren aplikazio lehenetsiarekin irekitzen dira; karpetak beti irekitzen dira." },
|
||||||
},
|
},
|
||||||
kinds: {
|
kinds: {
|
||||||
folder: "Karpeta",
|
folder: "Karpeta",
|
||||||
@@ -87,17 +79,16 @@ export const eu = {
|
|||||||
panel: {
|
panel: {
|
||||||
choosePanel: "Aukeratu panel honek zer erakusten duen",
|
choosePanel: "Aukeratu panel honek zer erakusten duen",
|
||||||
copy: "Bidali",
|
copy: "Bidali",
|
||||||
copyToRight: "Bidali eskuineko panelera (F5)",
|
copyToRight: "Bidali eskuineko panelera",
|
||||||
copyToLeft: "Bidali ezkerreko panelera (F5)",
|
copyToLeft: "Bidali ezkerreko panelera",
|
||||||
edit: "Editatu",
|
edit: "Editatu",
|
||||||
editFile: "Editatu fitxategia (F4)",
|
editFile: "Editatu fitxategia",
|
||||||
rename: "Berrizendatu (F2)",
|
rename: "Berrizendatu",
|
||||||
newFolder: "Karpeta berria (F7)",
|
newFolder: "Karpeta berria",
|
||||||
newFile: "Fitxategi berria",
|
newFile: "Fitxategi berria",
|
||||||
permissions: "Baimenak",
|
permissions: "Baimenak",
|
||||||
delete: "Ezabatu",
|
delete: "Ezabatu",
|
||||||
deleteHint: "Ezabatu (F8 / Del)",
|
parentDir: "Karpeta gurasoa",
|
||||||
parentDir: "Karpeta gurasoa (Atzera-tekla)",
|
|
||||||
refresh: "Freskatu",
|
refresh: "Freskatu",
|
||||||
clearFilter: "Garbitu iragazkia (Esc)",
|
clearFilter: "Garbitu iragazkia (Esc)",
|
||||||
sshConfig: "~/.ssh/config",
|
sshConfig: "~/.ssh/config",
|
||||||
@@ -156,16 +147,16 @@ export const eu = {
|
|||||||
noFileTitle: "Ez dago editatzeko fitxategirik",
|
noFileTitle: "Ez dago editatzeko fitxategirik",
|
||||||
noFileBody: "Leiho hau editore-testuingururik gabe ireki da. Ireki fitxategiak aplikaziotik.",
|
noFileBody: "Leiho hau editore-testuingururik gabe ireki da. Ireki fitxategiak aplikaziotik.",
|
||||||
wrap: "Doitu",
|
wrap: "Doitu",
|
||||||
wrapHint: "Doitu lerro luzeak — lerro-zenbakiak ezkutatzen ditu ({alt}Z)",
|
wrapHint: "Doitu lerro luzeak — lerro-zenbakiak ezkutatzen ditu",
|
||||||
findHint: "Bilatu ({mod}F)",
|
findHint: "Bilatu",
|
||||||
findPlaceholder: "Bilatu fitxategian",
|
findPlaceholder: "Bilatu fitxategian",
|
||||||
prevMatch: "Aurreko bat-etortzea (⇧↵)",
|
prevMatch: "Aurreko bat-etortzea",
|
||||||
nextMatch: "Hurrengo bat-etortzea (↵)",
|
nextMatch: "Hurrengo bat-etortzea",
|
||||||
closeHint: "Itxi (Esc)",
|
closeHint: "Itxi (Esc)",
|
||||||
disconnected: "{name}(e)tik deskonektatuta — gordetzeak huts egingo du, baina zure testua leiho honetan gordeta dago.",
|
disconnected: "{name}(e)tik deskonektatuta — gordetzeak huts egingo du, baina zure testua leiho honetan gordeta dago.",
|
||||||
cantOpen: "Ezin da {name} ireki",
|
cantOpen: "Ezin da {name} ireki",
|
||||||
closeWindow: "Itxi leihoa",
|
closeWindow: "Itxi leihoa",
|
||||||
shortcuts: "{mod}S gorde · {mod}F bilatu · {mod}W itxi",
|
verbs: { save: "gorde", find: "bilatu", close: "itxi" },
|
||||||
lines: { one: "lerro {count}", other: "{count} lerro" },
|
lines: { one: "lerro {count}", other: "{count} lerro" },
|
||||||
close: "Itxi",
|
close: "Itxi",
|
||||||
save: "Gorde",
|
save: "Gorde",
|
||||||
@@ -221,6 +212,7 @@ export const eu = {
|
|||||||
},
|
},
|
||||||
toasts: {
|
toasts: {
|
||||||
movedToTrash: '"{name}" zakarrontzira eraman da',
|
movedToTrash: '"{name}" zakarrontzira eraman da',
|
||||||
|
movedManyToTrash: { one: "{count} elementu zakarrontzira eraman da", other: "{count} elementu zakarrontzira eraman dira" },
|
||||||
editingExternal: "{name} editatzen — gordetzean automatikoki igotzen da",
|
editingExternal: "{name} editatzen — gordetzean automatikoki igotzen da",
|
||||||
nothingToCopy: "Ez dago kopiatzeko ezer (esteka sinbolikoak saltatu egiten dira)",
|
nothingToCopy: "Ez dago kopiatzeko ezer (esteka sinbolikoak saltatu egiten dira)",
|
||||||
nothingToCopyAllSkipped: "Ez dago kopiatzeko ezer — gatazkan zeuden elementu guztiak saltatu dira",
|
nothingToCopyAllSkipped: "Ez dago kopiatzeko ezer — gatazkan zeuden elementu guztiak saltatu dira",
|
||||||
@@ -257,6 +249,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",
|
||||||
@@ -303,6 +296,129 @@ export const eu = {
|
|||||||
tabDisconnect: "Deskonektatu",
|
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",
|
||||||
},
|
},
|
||||||
|
keys: {
|
||||||
|
"cursor-up": "Gora",
|
||||||
|
"cursor-down": "Behera",
|
||||||
|
"page-up": "Orrialde bat gora",
|
||||||
|
"page-down": "Orrialde bat behera",
|
||||||
|
"cursor-first": "Lehen elementua",
|
||||||
|
"cursor-last": "Azken elementua",
|
||||||
|
"toggle-select": "Hautatu / desautatu",
|
||||||
|
"select-all": "Hautatu dena",
|
||||||
|
open: "Ireki",
|
||||||
|
parent: "Karpeta gurasoa",
|
||||||
|
"switch-panel": "Aldatu panela",
|
||||||
|
"row-menu": "Erakutsi elementuaren menua",
|
||||||
|
send: "Bidali beste panelera",
|
||||||
|
edit: "Editatu",
|
||||||
|
rename: "Berrizendatu",
|
||||||
|
"new-folder": "Karpeta berria",
|
||||||
|
"new-file": "Fitxategi berria",
|
||||||
|
chmod: "Baimenak",
|
||||||
|
delete: "Ezabatu",
|
||||||
|
trash: "Eraman zakarrontzira",
|
||||||
|
"copy-path": "Kopiatu bidea",
|
||||||
|
reveal: "Erakutsi fitxategi-kudeatzailean",
|
||||||
|
"open-default": "Ireki aplikazio lehenetsiarekin",
|
||||||
|
refresh: "Freskatu",
|
||||||
|
"focus-path": "Joan bidera",
|
||||||
|
"source-picker": "Aukeratu panelak zer erakusten duen",
|
||||||
|
"sort-name": "Ordenatu izenaren arabera",
|
||||||
|
"sort-size": "Ordenatu tamainaren arabera",
|
||||||
|
"sort-mtime": "Ordenatu aldaketa-dataren arabera",
|
||||||
|
"sort-kind": "Ordenatu motaren arabera",
|
||||||
|
"sort-rights": "Ordenatu baimenen arabera",
|
||||||
|
"sort-owner": "Ordenatu jabearen arabera",
|
||||||
|
"sort-group": "Ordenatu taldearen arabera",
|
||||||
|
"new-tab": "Fitxa berria",
|
||||||
|
"close-tab": "Itxi fitxa",
|
||||||
|
"close-other-tabs": "Itxi beste fitxak",
|
||||||
|
"close-tabs-right": "Itxi eskuineko fitxak",
|
||||||
|
"next-tab": "Hurrengo fitxa",
|
||||||
|
"prev-tab": "Aurreko fitxa",
|
||||||
|
"go-to-tab": "Joan {n}. fitxara",
|
||||||
|
"go-to-last-tab": "Joan azken fitxara",
|
||||||
|
"move-tab-left": "Eraman fitxa ezkerrera",
|
||||||
|
"move-tab-right": "Eraman fitxa eskuinera",
|
||||||
|
"disconnect-tab": "Deskonektatu fitxa",
|
||||||
|
"open-settings": "Ezarpenak",
|
||||||
|
"connection-manager": "Konexio-kudeatzailea",
|
||||||
|
"toggle-transfers": "Erakutsi / ezkutatu transferentziak",
|
||||||
|
"install-update": "Instalatu eguneraketa",
|
||||||
|
"scale-up": "Interfaze handiagoa",
|
||||||
|
"scale-down": "Interfaze txikiagoa",
|
||||||
|
"scale-reset": "Interfazea % 100ean",
|
||||||
|
"close-window": "Itxi leihoa",
|
||||||
|
save: "Gorde",
|
||||||
|
find: "Bilatu",
|
||||||
|
"find-next": "Bilatu hurrengoa",
|
||||||
|
"find-prev": "Bilatu aurrekoa",
|
||||||
|
"toggle-wrap": "Doitu lerroak",
|
||||||
|
"conn-new": "Konexio berria",
|
||||||
|
"conn-new-stack": "Talde berria",
|
||||||
|
"conn-save": "Gorde konexioa",
|
||||||
|
"conn-connect": "Konektatu / ireki taldea",
|
||||||
|
"conn-delete": "Ezabatu konexioa edo taldea",
|
||||||
|
"conn-rename-stack": "Berrizendatu taldea",
|
||||||
|
"conn-import": "Inportatu WinSCPtik",
|
||||||
|
groups: {
|
||||||
|
panels: "Fitxategi-zerrenda",
|
||||||
|
files: "Fitxategiak",
|
||||||
|
sort: "Ordena",
|
||||||
|
tabs: "Fitxak",
|
||||||
|
window: "Leihoa",
|
||||||
|
editor: "Editorea",
|
||||||
|
connections: "Konexio-kudeatzailea",
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
filter: "Bilatu lasterbideak",
|
||||||
|
intro: "Sakatu + eta idatzi teklak lasterbide bat gehitzeko. Lehenengoa da menuek eta argibideek erakusten dutena.",
|
||||||
|
add: "Gehitu lasterbide bat",
|
||||||
|
remove: "Kendu {key}",
|
||||||
|
reset: "Berrezarri lehenetsia",
|
||||||
|
resetAll: "Berrezarri dena",
|
||||||
|
resetAllConfirm: "Berrezarri lasterbide guztiak",
|
||||||
|
press: "Sakatu teklak… ({esc} utzi)",
|
||||||
|
unbound: "Lasterbiderik ez",
|
||||||
|
reserved: "{key} sistemak erreserbatuta dauka",
|
||||||
|
needsModifier: "Editorean {key}(e)k idatzi besterik ez luke egingo: gehitu aldatzaile bat edo erabili F tekla bat",
|
||||||
|
usedBy: "{key} «{action}»(e)k erabiltzen du dagoeneko",
|
||||||
|
replace: "Ordeztu",
|
||||||
|
conflict: "{action}(r)en tekla bera",
|
||||||
|
plainLetter: "Fitxategi-zerrendan {key} idazteak ez du jada iragazten",
|
||||||
|
noResults: "Ez dago bat datorren lasterbiderik",
|
||||||
|
fixed: "Tekla finkoak",
|
||||||
|
},
|
||||||
|
fixed: {
|
||||||
|
escape: "{esc}(e)k menuak, elkarrizketa-koadroak eta bilaketa-barra ixten ditu",
|
||||||
|
escList: "Fitxategi-zerrendan {esc}(e)k iragazkia garbitzen du, gero konexioa bertan behera uzten du eta gero hautapena kentzen du",
|
||||||
|
dialogs: "{enter} eta {esc} elkarrizketa-koadroa berresten edo uzten dute; {tab} eta {shiftTab} haren kontroletan mugitzen dira",
|
||||||
|
menus: "↑ ↓ {home} {end} eta {enter} menu guztietan dabiltza",
|
||||||
|
filter: "Fitxategi-zerrendan idazteak iragazi egiten du; {backspace}(e)k iragazkiaren azken karakterea ezabatzen du",
|
||||||
|
shift: "Maius mugimendu-tekla batekin kurtsoreak igarotako errenkadak hautatzen ditu",
|
||||||
|
find: "{enter} eta {shiftEnter} bilaketako bat-etortzeetan mugitzen dira",
|
||||||
|
manager: "↑ ↓ ← → eta {enter} konexio-zerrendan mugitzen dira",
|
||||||
|
sidebar: "↑ ↓ atal batetik bestera mugitzen dira hemen",
|
||||||
|
},
|
||||||
|
names: {
|
||||||
|
ctrl: "Ctrl",
|
||||||
|
alt: "Alt",
|
||||||
|
shift: "Maius",
|
||||||
|
enter: "Intro",
|
||||||
|
backspace: "Atzera",
|
||||||
|
del: "Supr",
|
||||||
|
esc: "Esc",
|
||||||
|
space: "Zuriunea",
|
||||||
|
tab: "Tab",
|
||||||
|
pgup: "RePág",
|
||||||
|
pgdn: "AvPág",
|
||||||
|
home: "Inicio",
|
||||||
|
end: "Fin",
|
||||||
|
ins: "Insert",
|
||||||
|
menu: "Menua",
|
||||||
|
num: "Num",
|
||||||
|
},
|
||||||
|
},
|
||||||
errors: {
|
errors: {
|
||||||
sshAgentUnavailable: "ssh-agent ez dago erabilgarri (SSH_AUTH_SOCK ez dago ezarrita)",
|
sshAgentUnavailable: "ssh-agent ez dago erabilgarri (SSH_AUTH_SOCK ez dago ezarrita)",
|
||||||
identityFileNotFound: "identitate-fitxategia ez da aurkitu: {path}",
|
identityFileNotFound: "identitate-fitxategia ez da aurkitu: {path}",
|
||||||
|
|||||||
+151
-35
@@ -21,38 +21,29 @@ export const fr = {
|
|||||||
tabBar: {
|
tabBar: {
|
||||||
connections: "Connexions",
|
connections: "Connexions",
|
||||||
manageConnections: "Gérer les connexions",
|
manageConnections: "Gérer les connexions",
|
||||||
newTab: "Nouvel onglet ({mod}T)",
|
newTab: "Nouvel onglet",
|
||||||
closeTab: "Fermer l'onglet ({mod}W)",
|
closeTab: "Fermer l'onglet",
|
||||||
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",
|
||||||
|
keyboard: "Clavier",
|
||||||
|
about: "À propos",
|
||||||
|
},
|
||||||
|
columns: "Colonnes visibles",
|
||||||
language: "Langue",
|
language: "Langue",
|
||||||
system: "Système",
|
system: "Système",
|
||||||
skin: {
|
|
||||||
label: "Apparence",
|
|
||||||
modern: "Moderne",
|
|
||||||
win7: "Windows 7",
|
|
||||||
restartHint: "Prend effet au prochain démarrage de miniSCP",
|
|
||||||
},
|
|
||||||
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" },
|
|
||||||
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", smaller: "Plus petite", larger: "Plus grande", reset: "Réinitialiser à 100%" },
|
interface: { label: "Interface", smaller: "Plus petite", larger: "Plus grande", reset: "Réinitialiser à 100%" },
|
||||||
speedUnit: { label: "Unité de vitesse" },
|
speedUnit: { label: "Unité de vitesse" },
|
||||||
transfers: { label: "Transferts", single: "Un", multi: "Plusieurs" },
|
transfers: { label: "Transferts", single: "Un", multi: "Plusieurs" },
|
||||||
@@ -61,6 +52,7 @@ export const fr = {
|
|||||||
systemDefault: "Valeur par défaut du système",
|
systemDefault: "Valeur par défaut du système",
|
||||||
resetDefault: "Réinitialiser par défaut",
|
resetDefault: "Réinitialiser par défaut",
|
||||||
finishedTransfers: { label: "Transferts terminés", autoClear: "Effacer auto.", keep: "Garder" },
|
finishedTransfers: { label: "Transferts terminés", autoClear: "Effacer auto.", keep: "Garder" },
|
||||||
|
openFiles: { label: "Entrée / double-clic sur un fichier", nothing: "Ne rien faire", open: "Ouvrir", hint: "Les fichiers locaux s'ouvrent avec leur application par défaut ; les dossiers s'ouvrent toujours." },
|
||||||
},
|
},
|
||||||
kinds: {
|
kinds: {
|
||||||
folder: "Dossier",
|
folder: "Dossier",
|
||||||
@@ -85,17 +77,16 @@ export const fr = {
|
|||||||
panel: {
|
panel: {
|
||||||
choosePanel: "Choisir ce qu'affiche ce panneau",
|
choosePanel: "Choisir ce qu'affiche ce panneau",
|
||||||
copy: "Envoyer",
|
copy: "Envoyer",
|
||||||
copyToRight: "Envoyer vers le panneau droit (F5)",
|
copyToRight: "Envoyer vers le panneau droit",
|
||||||
copyToLeft: "Envoyer vers le panneau gauche (F5)",
|
copyToLeft: "Envoyer vers le panneau gauche",
|
||||||
edit: "Éditer",
|
edit: "Éditer",
|
||||||
editFile: "Éditer le fichier (F4)",
|
editFile: "Éditer le fichier",
|
||||||
rename: "Renommer (F2)",
|
rename: "Renommer",
|
||||||
newFolder: "Nouveau dossier (F7)",
|
newFolder: "Nouveau dossier",
|
||||||
newFile: "Nouveau fichier",
|
newFile: "Nouveau fichier",
|
||||||
permissions: "Autorisations",
|
permissions: "Autorisations",
|
||||||
delete: "Supprimer",
|
delete: "Supprimer",
|
||||||
deleteHint: "Supprimer (F8 / Suppr)",
|
parentDir: "Dossier parent",
|
||||||
parentDir: "Dossier parent (Retour arrière)",
|
|
||||||
refresh: "Actualiser",
|
refresh: "Actualiser",
|
||||||
clearFilter: "Effacer le filtre (Échap)",
|
clearFilter: "Effacer le filtre (Échap)",
|
||||||
sshConfig: "~/.ssh/config",
|
sshConfig: "~/.ssh/config",
|
||||||
@@ -154,16 +145,16 @@ export const fr = {
|
|||||||
noFileTitle: "Aucun fichier à éditer",
|
noFileTitle: "Aucun fichier à éditer",
|
||||||
noFileBody: "Cette fenêtre a été ouverte sans contexte d'éditeur. Ouvrez des fichiers depuis l'application.",
|
noFileBody: "Cette fenêtre a été ouverte sans contexte d'éditeur. Ouvrez des fichiers depuis l'application.",
|
||||||
wrap: "Retour",
|
wrap: "Retour",
|
||||||
wrapHint: "Retour à la ligne — masque les numéros de ligne ({alt}Z)",
|
wrapHint: "Retour à la ligne — masque les numéros de ligne",
|
||||||
findHint: "Rechercher ({mod}F)",
|
findHint: "Rechercher",
|
||||||
findPlaceholder: "Rechercher dans le fichier",
|
findPlaceholder: "Rechercher dans le fichier",
|
||||||
prevMatch: "Résultat précédent (⇧↵)",
|
prevMatch: "Résultat précédent",
|
||||||
nextMatch: "Résultat suivant (↵)",
|
nextMatch: "Résultat suivant",
|
||||||
closeHint: "Fermer (Échap)",
|
closeHint: "Fermer (Échap)",
|
||||||
disconnected: "Déconnecté de {name} — l'enregistrement échouera, mais votre texte est conservé dans cette fenêtre.",
|
disconnected: "Déconnecté de {name} — l'enregistrement échouera, mais votre texte est conservé dans cette fenêtre.",
|
||||||
cantOpen: "Impossible d'ouvrir {name}",
|
cantOpen: "Impossible d'ouvrir {name}",
|
||||||
closeWindow: "Fermer la fenêtre",
|
closeWindow: "Fermer la fenêtre",
|
||||||
shortcuts: "{mod}S enregistrer · {mod}F rechercher · {mod}W fermer",
|
verbs: { save: "enregistrer", find: "rechercher", close: "fermer" },
|
||||||
lines: { one: "{count} ligne", other: "{count} lignes" },
|
lines: { one: "{count} ligne", other: "{count} lignes" },
|
||||||
close: "Fermer",
|
close: "Fermer",
|
||||||
save: "Enregistrer",
|
save: "Enregistrer",
|
||||||
@@ -219,6 +210,7 @@ export const fr = {
|
|||||||
},
|
},
|
||||||
toasts: {
|
toasts: {
|
||||||
movedToTrash: '« {name} » mis à la corbeille',
|
movedToTrash: '« {name} » mis à la corbeille',
|
||||||
|
movedManyToTrash: { one: "{count} élément mis à la corbeille", other: "{count} éléments mis à la corbeille" },
|
||||||
editingExternal: "Édition de {name} — les enregistrements sont envoyés automatiquement",
|
editingExternal: "Édition de {name} — les enregistrements sont envoyés automatiquement",
|
||||||
nothingToCopy: "Rien à copier (les liens symboliques sont ignorés)",
|
nothingToCopy: "Rien à copier (les liens symboliques sont ignorés)",
|
||||||
nothingToCopyAllSkipped: "Rien à copier — tous les éléments en conflit ont été ignorés",
|
nothingToCopyAllSkipped: "Rien à copier — tous les éléments en conflit ont été ignorés",
|
||||||
@@ -255,6 +247,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",
|
||||||
@@ -301,6 +294,129 @@ export const fr = {
|
|||||||
tabDisconnect: "Déconnecter",
|
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",
|
||||||
},
|
},
|
||||||
|
keys: {
|
||||||
|
"cursor-up": "Monter",
|
||||||
|
"cursor-down": "Descendre",
|
||||||
|
"page-up": "Page précédente",
|
||||||
|
"page-down": "Page suivante",
|
||||||
|
"cursor-first": "Premier élément",
|
||||||
|
"cursor-last": "Dernier élément",
|
||||||
|
"toggle-select": "Sélectionner / désélectionner",
|
||||||
|
"select-all": "Tout sélectionner",
|
||||||
|
open: "Ouvrir",
|
||||||
|
parent: "Dossier parent",
|
||||||
|
"switch-panel": "Changer de panneau",
|
||||||
|
"row-menu": "Afficher le menu de l'élément",
|
||||||
|
send: "Envoyer vers l'autre panneau",
|
||||||
|
edit: "Éditer",
|
||||||
|
rename: "Renommer",
|
||||||
|
"new-folder": "Nouveau dossier",
|
||||||
|
"new-file": "Nouveau fichier",
|
||||||
|
chmod: "Permissions",
|
||||||
|
delete: "Supprimer",
|
||||||
|
trash: "Mettre à la corbeille",
|
||||||
|
"copy-path": "Copier le chemin",
|
||||||
|
reveal: "Afficher dans le gestionnaire de fichiers",
|
||||||
|
"open-default": "Ouvrir avec l'application par défaut",
|
||||||
|
refresh: "Actualiser",
|
||||||
|
"focus-path": "Aller au chemin",
|
||||||
|
"source-picker": "Choisir ce qu'affiche le panneau",
|
||||||
|
"sort-name": "Trier par nom",
|
||||||
|
"sort-size": "Trier par taille",
|
||||||
|
"sort-mtime": "Trier par date de modification",
|
||||||
|
"sort-kind": "Trier par type",
|
||||||
|
"sort-rights": "Trier par droits",
|
||||||
|
"sort-owner": "Trier par propriétaire",
|
||||||
|
"sort-group": "Trier par groupe",
|
||||||
|
"new-tab": "Nouvel onglet",
|
||||||
|
"close-tab": "Fermer l'onglet",
|
||||||
|
"close-other-tabs": "Fermer les autres onglets",
|
||||||
|
"close-tabs-right": "Fermer les onglets à droite",
|
||||||
|
"next-tab": "Onglet suivant",
|
||||||
|
"prev-tab": "Onglet précédent",
|
||||||
|
"go-to-tab": "Aller à l'onglet {n}",
|
||||||
|
"go-to-last-tab": "Aller au dernier onglet",
|
||||||
|
"move-tab-left": "Déplacer l'onglet à gauche",
|
||||||
|
"move-tab-right": "Déplacer l'onglet à droite",
|
||||||
|
"disconnect-tab": "Déconnecter l'onglet",
|
||||||
|
"open-settings": "Réglages",
|
||||||
|
"connection-manager": "Gestionnaire de connexions",
|
||||||
|
"toggle-transfers": "Afficher / masquer les transferts",
|
||||||
|
"install-update": "Installer la mise à jour",
|
||||||
|
"scale-up": "Interface plus grande",
|
||||||
|
"scale-down": "Interface plus petite",
|
||||||
|
"scale-reset": "Interface à 100 %",
|
||||||
|
"close-window": "Fermer la fenêtre",
|
||||||
|
save: "Enregistrer",
|
||||||
|
find: "Rechercher",
|
||||||
|
"find-next": "Résultat suivant",
|
||||||
|
"find-prev": "Résultat précédent",
|
||||||
|
"toggle-wrap": "Retour à la ligne",
|
||||||
|
"conn-new": "Nouvelle connexion",
|
||||||
|
"conn-new-stack": "Nouveau groupe",
|
||||||
|
"conn-save": "Enregistrer la connexion",
|
||||||
|
"conn-connect": "Se connecter / ouvrir le groupe",
|
||||||
|
"conn-delete": "Supprimer la connexion ou le groupe",
|
||||||
|
"conn-rename-stack": "Renommer le groupe",
|
||||||
|
"conn-import": "Importer depuis WinSCP",
|
||||||
|
groups: {
|
||||||
|
panels: "Liste des fichiers",
|
||||||
|
files: "Fichiers",
|
||||||
|
sort: "Tri",
|
||||||
|
tabs: "Onglets",
|
||||||
|
window: "Fenêtre",
|
||||||
|
editor: "Éditeur",
|
||||||
|
connections: "Gestionnaire de connexions",
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
filter: "Rechercher un raccourci",
|
||||||
|
intro: "Cliquez sur + et tapez les touches pour ajouter un raccourci. Le premier est celui qu'affichent les menus et les infobulles.",
|
||||||
|
add: "Ajouter un raccourci",
|
||||||
|
remove: "Retirer {key}",
|
||||||
|
reset: "Rétablir la valeur par défaut",
|
||||||
|
resetAll: "Tout rétablir",
|
||||||
|
resetAllConfirm: "Rétablir tous les raccourcis",
|
||||||
|
press: "Tapez les touches… ({esc} annule)",
|
||||||
|
unbound: "Aucun raccourci",
|
||||||
|
reserved: "{key} est réservé par le système",
|
||||||
|
needsModifier: "Dans l'éditeur, {key} ne ferait que taper : ajoutez un modificateur ou utilisez une touche F",
|
||||||
|
usedBy: "{key} est déjà utilisé par « {action} »",
|
||||||
|
replace: "Remplacer",
|
||||||
|
conflict: "Même touche que {action}",
|
||||||
|
plainLetter: "Taper {key} dans la liste des fichiers ne la filtre plus",
|
||||||
|
noResults: "Aucun raccourci ne correspond",
|
||||||
|
fixed: "Touches fixes",
|
||||||
|
},
|
||||||
|
fixed: {
|
||||||
|
escape: "{esc} ferme les menus, les dialogues et la barre de recherche",
|
||||||
|
escList: "{esc} dans la liste des fichiers efface le filtre, puis annule une connexion, puis vide la sélection",
|
||||||
|
dialogs: "{enter} et {esc} valident ou annulent un dialogue ; {tab} et {shiftTab} passent d'un contrôle à l'autre",
|
||||||
|
menus: "↑ ↓ {home} {end} et {enter} fonctionnent dans tous les menus",
|
||||||
|
filter: "Taper dans la liste des fichiers la filtre ; {backspace} efface le dernier caractère du filtre",
|
||||||
|
shift: "Maj avec une touche de déplacement sélectionne les lignes parcourues",
|
||||||
|
find: "{enter} et {shiftEnter} parcourent les résultats de la recherche",
|
||||||
|
manager: "↑ ↓ ← → et {enter} parcourent la liste des connexions",
|
||||||
|
sidebar: "↑ ↓ changent de section ici",
|
||||||
|
},
|
||||||
|
names: {
|
||||||
|
ctrl: "Ctrl",
|
||||||
|
alt: "Alt",
|
||||||
|
shift: "Maj",
|
||||||
|
enter: "Entrée",
|
||||||
|
backspace: "Retour arrière",
|
||||||
|
del: "Suppr",
|
||||||
|
esc: "Échap",
|
||||||
|
space: "Espace",
|
||||||
|
tab: "Tab",
|
||||||
|
pgup: "Pg préc",
|
||||||
|
pgdn: "Pg suiv",
|
||||||
|
home: "Début",
|
||||||
|
end: "Fin",
|
||||||
|
ins: "Inser",
|
||||||
|
menu: "Menu",
|
||||||
|
num: "Num",
|
||||||
|
},
|
||||||
|
},
|
||||||
errors: {
|
errors: {
|
||||||
sshAgentUnavailable: "ssh-agent indisponible (SSH_AUTH_SOCK non défini)",
|
sshAgentUnavailable: "ssh-agent indisponible (SSH_AUTH_SOCK non défini)",
|
||||||
identityFileNotFound: "fichier d'identité introuvable : {path}",
|
identityFileNotFound: "fichier d'identité introuvable : {path}",
|
||||||
|
|||||||
+151
-35
@@ -22,38 +22,29 @@ export const ja = {
|
|||||||
tabBar: {
|
tabBar: {
|
||||||
connections: "接続",
|
connections: "接続",
|
||||||
manageConnections: "接続を管理",
|
manageConnections: "接続を管理",
|
||||||
newTab: "新しいタブ ({mod}T)",
|
newTab: "新しいタブ",
|
||||||
closeTab: "タブを閉じる ({mod}W)",
|
closeTab: "タブを閉じる",
|
||||||
options: "オプション",
|
options: "設定",
|
||||||
update: "アップデートあり",
|
update: "アップデートあり",
|
||||||
updating: "更新中",
|
updating: "更新中",
|
||||||
restartToUpdate: "再起動して適用",
|
restartToUpdate: "再起動して適用",
|
||||||
updateTooltip: "アップデートがあります: miniSCP {version}",
|
updateTooltip: "アップデートがあります: miniSCP {version}",
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
title: "オプション",
|
title: "設定",
|
||||||
|
sections: {
|
||||||
|
appearance: "外観",
|
||||||
|
transfers: "転送",
|
||||||
|
editor: "エディタ",
|
||||||
|
fileList: "ファイル一覧",
|
||||||
|
keyboard: "キーボード",
|
||||||
|
about: "情報",
|
||||||
|
},
|
||||||
|
columns: "表示する列",
|
||||||
language: "言語",
|
language: "言語",
|
||||||
system: "システム",
|
system: "システム",
|
||||||
skin: {
|
|
||||||
label: "外観",
|
|
||||||
modern: "モダン",
|
|
||||||
win7: "Windows 7",
|
|
||||||
restartHint: "次回 miniSCP を起動したときに適用されます",
|
|
||||||
},
|
|
||||||
theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" },
|
theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" },
|
||||||
tone: { label: "トーン", dark: "ダーク", darker: "より暗く" },
|
tone: { label: "トーン", dark: "ダーク", darker: "AMOLED" },
|
||||||
accent: { label: "アクセント" },
|
|
||||||
accentNames: {
|
|
||||||
graphite: "グラファイト",
|
|
||||||
blue: "ブルー",
|
|
||||||
indigo: "インディゴ",
|
|
||||||
purple: "パープル",
|
|
||||||
rose: "ローズ",
|
|
||||||
orange: "オレンジ",
|
|
||||||
green: "グリーン",
|
|
||||||
teal: "ティール",
|
|
||||||
},
|
|
||||||
fileIcons: { label: "ファイルアイコン", plain: "シンプル", multi: "カラー" },
|
|
||||||
interface: { label: "インターフェース", smaller: "縮小", larger: "拡大", reset: "100%に戻す" },
|
interface: { label: "インターフェース", smaller: "縮小", larger: "拡大", reset: "100%に戻す" },
|
||||||
speedUnit: { label: "速度の単位" },
|
speedUnit: { label: "速度の単位" },
|
||||||
transfers: { label: "転送", single: "1件ずつ", multi: "複数" },
|
transfers: { label: "転送", single: "1件ずつ", multi: "複数" },
|
||||||
@@ -62,6 +53,7 @@ export const ja = {
|
|||||||
systemDefault: "システムの既定",
|
systemDefault: "システムの既定",
|
||||||
resetDefault: "既定に戻す",
|
resetDefault: "既定に戻す",
|
||||||
finishedTransfers: { label: "完了した転送", autoClear: "自動クリア", keep: "保持" },
|
finishedTransfers: { label: "完了した転送", autoClear: "自動クリア", keep: "保持" },
|
||||||
|
openFiles: { label: "Enter / ファイルをダブルクリック", nothing: "何もしない", open: "開く", hint: "ローカルファイルは既定のアプリで開きます。フォルダは常に開きます。" },
|
||||||
},
|
},
|
||||||
kinds: {
|
kinds: {
|
||||||
folder: "フォルダ",
|
folder: "フォルダ",
|
||||||
@@ -86,17 +78,16 @@ export const ja = {
|
|||||||
panel: {
|
panel: {
|
||||||
choosePanel: "このパネルに表示する内容を選択",
|
choosePanel: "このパネルに表示する内容を選択",
|
||||||
copy: "送信",
|
copy: "送信",
|
||||||
copyToRight: "右のパネルへ送信 (F5)",
|
copyToRight: "右のパネルへ送信",
|
||||||
copyToLeft: "左のパネルへ送信 (F5)",
|
copyToLeft: "左のパネルへ送信",
|
||||||
edit: "編集",
|
edit: "編集",
|
||||||
editFile: "ファイルを編集 (F4)",
|
editFile: "ファイルを編集",
|
||||||
rename: "名前を変更 (F2)",
|
rename: "名前を変更",
|
||||||
newFolder: "新規フォルダ (F7)",
|
newFolder: "新規フォルダ",
|
||||||
newFile: "新規ファイル",
|
newFile: "新規ファイル",
|
||||||
permissions: "アクセス権",
|
permissions: "アクセス権",
|
||||||
delete: "削除",
|
delete: "削除",
|
||||||
deleteHint: "削除 (F8 / Del)",
|
parentDir: "親フォルダ",
|
||||||
parentDir: "親フォルダ (Backspace)",
|
|
||||||
refresh: "更新",
|
refresh: "更新",
|
||||||
clearFilter: "フィルターをクリア (Esc)",
|
clearFilter: "フィルターをクリア (Esc)",
|
||||||
sshConfig: "~/.ssh/config",
|
sshConfig: "~/.ssh/config",
|
||||||
@@ -155,16 +146,16 @@ export const ja = {
|
|||||||
noFileTitle: "編集するファイルがありません",
|
noFileTitle: "編集するファイルがありません",
|
||||||
noFileBody: "このウィンドウはエディタコンテキストなしで開かれました。アプリからファイルを開いてください。",
|
noFileBody: "このウィンドウはエディタコンテキストなしで開かれました。アプリからファイルを開いてください。",
|
||||||
wrap: "折り返し",
|
wrap: "折り返し",
|
||||||
wrapHint: "長い行を折り返す — 行番号を非表示 ({alt}Z)",
|
wrapHint: "長い行を折り返す — 行番号を非表示",
|
||||||
findHint: "検索 ({mod}F)",
|
findHint: "検索",
|
||||||
findPlaceholder: "ファイル内を検索",
|
findPlaceholder: "ファイル内を検索",
|
||||||
prevMatch: "前の一致 (⇧↵)",
|
prevMatch: "前の一致",
|
||||||
nextMatch: "次の一致 (↵)",
|
nextMatch: "次の一致",
|
||||||
closeHint: "閉じる (Esc)",
|
closeHint: "閉じる (Esc)",
|
||||||
disconnected: "{name} から切断されました — 保存は失敗しますが、テキストはこのウィンドウに保持されます。",
|
disconnected: "{name} から切断されました — 保存は失敗しますが、テキストはこのウィンドウに保持されます。",
|
||||||
cantOpen: "{name} を開けません",
|
cantOpen: "{name} を開けません",
|
||||||
closeWindow: "ウィンドウを閉じる",
|
closeWindow: "ウィンドウを閉じる",
|
||||||
shortcuts: "{mod}S 保存 · {mod}F 検索 · {mod}W 閉じる",
|
verbs: { save: "保存", find: "検索", close: "閉じる" },
|
||||||
lines: { other: "{count} 行" },
|
lines: { other: "{count} 行" },
|
||||||
close: "閉じる",
|
close: "閉じる",
|
||||||
save: "保存",
|
save: "保存",
|
||||||
@@ -220,6 +211,7 @@ export const ja = {
|
|||||||
},
|
},
|
||||||
toasts: {
|
toasts: {
|
||||||
movedToTrash: '"{name}" をゴミ箱に移動しました',
|
movedToTrash: '"{name}" をゴミ箱に移動しました',
|
||||||
|
movedManyToTrash: { other: "{count} 個の項目をゴミ箱に移動しました" },
|
||||||
editingExternal: "{name} を編集中 — 保存すると自動でアップロードされます",
|
editingExternal: "{name} を編集中 — 保存すると自動でアップロードされます",
|
||||||
nothingToCopy: "コピーする項目がありません(シンボリックリンクはスキップされます)",
|
nothingToCopy: "コピーする項目がありません(シンボリックリンクはスキップされます)",
|
||||||
nothingToCopyAllSkipped: "コピーする項目がありません — 競合する項目はすべてスキップされました",
|
nothingToCopyAllSkipped: "コピーする項目がありません — 競合する項目はすべてスキップされました",
|
||||||
@@ -255,6 +247,7 @@ export const ja = {
|
|||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
about: "{app} について",
|
about: "{app} について",
|
||||||
|
settings: "設定…",
|
||||||
checkForUpdates: "アップデートを確認…",
|
checkForUpdates: "アップデートを確認…",
|
||||||
quit: "終了",
|
quit: "終了",
|
||||||
file: "ファイル",
|
file: "ファイル",
|
||||||
@@ -301,6 +294,129 @@ export const ja = {
|
|||||||
tabDisconnect: "切断",
|
tabDisconnect: "切断",
|
||||||
trashTooltip: "ここからゴミ箱に入れた項目は Finder の「戻す」を利用できません",
|
trashTooltip: "ここからゴミ箱に入れた項目は Finder の「戻す」を利用できません",
|
||||||
},
|
},
|
||||||
|
keys: {
|
||||||
|
"cursor-up": "上へ",
|
||||||
|
"cursor-down": "下へ",
|
||||||
|
"page-up": "前のページ",
|
||||||
|
"page-down": "次のページ",
|
||||||
|
"cursor-first": "先頭の項目",
|
||||||
|
"cursor-last": "最後の項目",
|
||||||
|
"toggle-select": "選択 / 選択解除",
|
||||||
|
"select-all": "すべて選択",
|
||||||
|
open: "開く",
|
||||||
|
parent: "親フォルダ",
|
||||||
|
"switch-panel": "パネルを切り替え",
|
||||||
|
"row-menu": "項目のメニューを表示",
|
||||||
|
send: "もう一方のパネルへ送信",
|
||||||
|
edit: "編集",
|
||||||
|
rename: "名前を変更",
|
||||||
|
"new-folder": "新規フォルダ",
|
||||||
|
"new-file": "新規ファイル",
|
||||||
|
chmod: "アクセス権",
|
||||||
|
delete: "削除",
|
||||||
|
trash: "ゴミ箱に移動",
|
||||||
|
"copy-path": "パスをコピー",
|
||||||
|
reveal: "ファイルマネージャーで表示",
|
||||||
|
"open-default": "既定のアプリで開く",
|
||||||
|
refresh: "更新",
|
||||||
|
"focus-path": "パスへ移動",
|
||||||
|
"source-picker": "パネルの表示内容を選択",
|
||||||
|
"sort-name": "名前で並べ替え",
|
||||||
|
"sort-size": "サイズで並べ替え",
|
||||||
|
"sort-mtime": "更新日時で並べ替え",
|
||||||
|
"sort-kind": "種類で並べ替え",
|
||||||
|
"sort-rights": "権限で並べ替え",
|
||||||
|
"sort-owner": "所有者で並べ替え",
|
||||||
|
"sort-group": "グループで並べ替え",
|
||||||
|
"new-tab": "新しいタブ",
|
||||||
|
"close-tab": "タブを閉じる",
|
||||||
|
"close-other-tabs": "他のタブを閉じる",
|
||||||
|
"close-tabs-right": "右側のタブを閉じる",
|
||||||
|
"next-tab": "次のタブ",
|
||||||
|
"prev-tab": "前のタブ",
|
||||||
|
"go-to-tab": "タブ {n} へ移動",
|
||||||
|
"go-to-last-tab": "最後のタブへ移動",
|
||||||
|
"move-tab-left": "タブを左へ移動",
|
||||||
|
"move-tab-right": "タブを右へ移動",
|
||||||
|
"disconnect-tab": "タブを切断",
|
||||||
|
"open-settings": "設定",
|
||||||
|
"connection-manager": "接続マネージャー",
|
||||||
|
"toggle-transfers": "転送の表示 / 非表示",
|
||||||
|
"install-update": "アップデートをインストール",
|
||||||
|
"scale-up": "インターフェースを拡大",
|
||||||
|
"scale-down": "インターフェースを縮小",
|
||||||
|
"scale-reset": "インターフェースを 100% に",
|
||||||
|
"close-window": "ウインドウを閉じる",
|
||||||
|
save: "保存",
|
||||||
|
find: "検索",
|
||||||
|
"find-next": "次を検索",
|
||||||
|
"find-prev": "前を検索",
|
||||||
|
"toggle-wrap": "行を折り返す",
|
||||||
|
"conn-new": "新規接続",
|
||||||
|
"conn-new-stack": "新規グループ",
|
||||||
|
"conn-save": "接続を保存",
|
||||||
|
"conn-connect": "接続 / グループを開く",
|
||||||
|
"conn-delete": "接続またはグループを削除",
|
||||||
|
"conn-rename-stack": "グループ名を変更",
|
||||||
|
"conn-import": "WinSCP からインポート",
|
||||||
|
groups: {
|
||||||
|
panels: "ファイル一覧",
|
||||||
|
files: "ファイル",
|
||||||
|
sort: "並べ替え",
|
||||||
|
tabs: "タブ",
|
||||||
|
window: "ウインドウ",
|
||||||
|
editor: "エディタ",
|
||||||
|
connections: "接続マネージャー",
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
filter: "ショートカットを検索",
|
||||||
|
intro: "+ を押してキーを入力するとショートカットを追加できます。最初のものがメニューとツールチップに表示されます。",
|
||||||
|
add: "ショートカットを追加",
|
||||||
|
remove: "{key} を削除",
|
||||||
|
reset: "既定に戻す",
|
||||||
|
resetAll: "すべて戻す",
|
||||||
|
resetAllConfirm: "すべてのショートカットを戻す",
|
||||||
|
press: "キーを押してください…({esc} でキャンセル)",
|
||||||
|
unbound: "ショートカットなし",
|
||||||
|
reserved: "{key} はシステムで予約されています",
|
||||||
|
needsModifier: "エディタでは {key} は入力されるだけです。修飾キーを加えるか F キーを使ってください",
|
||||||
|
usedBy: "{key} はすでに「{action}」で使われています",
|
||||||
|
replace: "置き換える",
|
||||||
|
conflict: "{action} と同じキー",
|
||||||
|
plainLetter: "ファイル一覧で {key} を入力しても絞り込まれなくなります",
|
||||||
|
noResults: "一致するショートカットはありません",
|
||||||
|
fixed: "固定キー",
|
||||||
|
},
|
||||||
|
fixed: {
|
||||||
|
escape: "{esc} でメニュー、ダイアログ、検索バーを閉じます",
|
||||||
|
escList: "ファイル一覧の {esc} は、絞り込みの解除、接続のキャンセル、選択の解除の順に働きます",
|
||||||
|
dialogs: "{enter} と {esc} でダイアログを確定 / キャンセルし、{tab} と {shiftTab} でコントロール間を移動します",
|
||||||
|
menus: "↑ ↓ {home} {end} と {enter} はすべてのメニューで使えます",
|
||||||
|
filter: "ファイル一覧で入力すると絞り込まれます。{backspace} で最後の文字を消します",
|
||||||
|
shift: "Shift と移動キーで、カーソルが通過した行を選択します",
|
||||||
|
find: "{enter} と {shiftEnter} で検索結果を移動します",
|
||||||
|
manager: "↑ ↓ ← → と {enter} で接続一覧を移動します",
|
||||||
|
sidebar: "↑ ↓ でここのセクションを切り替えます",
|
||||||
|
},
|
||||||
|
names: {
|
||||||
|
ctrl: "Ctrl",
|
||||||
|
alt: "Alt",
|
||||||
|
shift: "Shift",
|
||||||
|
enter: "Enter",
|
||||||
|
backspace: "Backspace",
|
||||||
|
del: "Del",
|
||||||
|
esc: "Esc",
|
||||||
|
space: "スペース",
|
||||||
|
tab: "Tab",
|
||||||
|
pgup: "PgUp",
|
||||||
|
pgdn: "PgDn",
|
||||||
|
home: "Home",
|
||||||
|
end: "End",
|
||||||
|
ins: "Ins",
|
||||||
|
menu: "メニュー",
|
||||||
|
num: "Num",
|
||||||
|
},
|
||||||
|
},
|
||||||
errors: {
|
errors: {
|
||||||
sshAgentUnavailable: "ssh-agent が利用できません(SSH_AUTH_SOCK が未設定)",
|
sshAgentUnavailable: "ssh-agent が利用できません(SSH_AUTH_SOCK が未設定)",
|
||||||
identityFileNotFound: "認証鍵ファイルが見つかりません: {path}",
|
identityFileNotFound: "認証鍵ファイルが見つかりません: {path}",
|
||||||
|
|||||||
+151
-35
@@ -22,38 +22,29 @@ export const zh = {
|
|||||||
tabBar: {
|
tabBar: {
|
||||||
connections: "连接",
|
connections: "连接",
|
||||||
manageConnections: "管理连接",
|
manageConnections: "管理连接",
|
||||||
newTab: "新建标签页 ({mod}T)",
|
newTab: "新建标签页",
|
||||||
closeTab: "关闭标签页 ({mod}W)",
|
closeTab: "关闭标签页",
|
||||||
options: "选项",
|
options: "设置",
|
||||||
update: "有可用更新",
|
update: "有可用更新",
|
||||||
updating: "正在更新",
|
updating: "正在更新",
|
||||||
restartToUpdate: "重启以应用",
|
restartToUpdate: "重启以应用",
|
||||||
updateTooltip: "有可用更新:miniSCP {version}",
|
updateTooltip: "有可用更新:miniSCP {version}",
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
title: "选项",
|
title: "设置",
|
||||||
|
sections: {
|
||||||
|
appearance: "外观",
|
||||||
|
transfers: "传输",
|
||||||
|
editor: "编辑器",
|
||||||
|
fileList: "文件列表",
|
||||||
|
keyboard: "键盘",
|
||||||
|
about: "关于",
|
||||||
|
},
|
||||||
|
columns: "显示的列",
|
||||||
language: "语言",
|
language: "语言",
|
||||||
system: "系统",
|
system: "系统",
|
||||||
skin: {
|
|
||||||
label: "外观",
|
|
||||||
modern: "现代",
|
|
||||||
win7: "Windows 7",
|
|
||||||
restartHint: "下次启动 miniSCP 时生效",
|
|
||||||
},
|
|
||||||
theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" },
|
theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" },
|
||||||
tone: { label: "色调", dark: "深色", darker: "更深" },
|
tone: { label: "色调", dark: "深色", darker: "AMOLED" },
|
||||||
accent: { label: "强调色" },
|
|
||||||
accentNames: {
|
|
||||||
graphite: "石墨",
|
|
||||||
blue: "蓝色",
|
|
||||||
indigo: "靛蓝",
|
|
||||||
purple: "紫色",
|
|
||||||
rose: "玫红",
|
|
||||||
orange: "橙色",
|
|
||||||
green: "绿色",
|
|
||||||
teal: "青色",
|
|
||||||
},
|
|
||||||
fileIcons: { label: "文件图标", plain: "简约", multi: "彩色" },
|
|
||||||
interface: { label: "界面", smaller: "缩小", larger: "放大", reset: "重置为100%" },
|
interface: { label: "界面", smaller: "缩小", larger: "放大", reset: "重置为100%" },
|
||||||
speedUnit: { label: "速度单位" },
|
speedUnit: { label: "速度单位" },
|
||||||
transfers: { label: "传输", single: "单个", multi: "多个" },
|
transfers: { label: "传输", single: "单个", multi: "多个" },
|
||||||
@@ -62,6 +53,7 @@ export const zh = {
|
|||||||
systemDefault: "系统默认",
|
systemDefault: "系统默认",
|
||||||
resetDefault: "恢复为系统默认",
|
resetDefault: "恢复为系统默认",
|
||||||
finishedTransfers: { label: "已完成传输", autoClear: "自动清除", keep: "保留" },
|
finishedTransfers: { label: "已完成传输", autoClear: "自动清除", keep: "保留" },
|
||||||
|
openFiles: { label: "回车 / 双击文件", nothing: "不执行操作", open: "打开", hint: "本地文件用默认应用打开;文件夹始终打开。" },
|
||||||
},
|
},
|
||||||
kinds: {
|
kinds: {
|
||||||
folder: "文件夹",
|
folder: "文件夹",
|
||||||
@@ -86,17 +78,16 @@ export const zh = {
|
|||||||
panel: {
|
panel: {
|
||||||
choosePanel: "选择此面板显示的内容",
|
choosePanel: "选择此面板显示的内容",
|
||||||
copy: "发送",
|
copy: "发送",
|
||||||
copyToRight: "发送到右侧面板 (F5)",
|
copyToRight: "发送到右侧面板",
|
||||||
copyToLeft: "发送到左侧面板 (F5)",
|
copyToLeft: "发送到左侧面板",
|
||||||
edit: "编辑",
|
edit: "编辑",
|
||||||
editFile: "编辑文件 (F4)",
|
editFile: "编辑文件",
|
||||||
rename: "重命名 (F2)",
|
rename: "重命名",
|
||||||
newFolder: "新建文件夹 (F7)",
|
newFolder: "新建文件夹",
|
||||||
newFile: "新建文件",
|
newFile: "新建文件",
|
||||||
permissions: "权限",
|
permissions: "权限",
|
||||||
delete: "删除",
|
delete: "删除",
|
||||||
deleteHint: "删除 (F8 / Del)",
|
parentDir: "上级目录",
|
||||||
parentDir: "上级目录 (退格)",
|
|
||||||
refresh: "刷新",
|
refresh: "刷新",
|
||||||
clearFilter: "清除筛选 (Esc)",
|
clearFilter: "清除筛选 (Esc)",
|
||||||
sshConfig: "~/.ssh/config",
|
sshConfig: "~/.ssh/config",
|
||||||
@@ -155,16 +146,16 @@ export const zh = {
|
|||||||
noFileTitle: "没有要编辑的文件",
|
noFileTitle: "没有要编辑的文件",
|
||||||
noFileBody: "此窗口在没有编辑器上下文的情况下打开。请从应用中打开文件。",
|
noFileBody: "此窗口在没有编辑器上下文的情况下打开。请从应用中打开文件。",
|
||||||
wrap: "换行",
|
wrap: "换行",
|
||||||
wrapHint: "长行自动换行 — 隐藏行号 ({alt}Z)",
|
wrapHint: "长行自动换行 — 隐藏行号",
|
||||||
findHint: "查找 ({mod}F)",
|
findHint: "查找",
|
||||||
findPlaceholder: "在文件中查找",
|
findPlaceholder: "在文件中查找",
|
||||||
prevMatch: "上一个匹配 (⇧↵)",
|
prevMatch: "上一个匹配",
|
||||||
nextMatch: "下一个匹配 (↵)",
|
nextMatch: "下一个匹配",
|
||||||
closeHint: "关闭 (Esc)",
|
closeHint: "关闭 (Esc)",
|
||||||
disconnected: "已从 {name} 断开 — 保存将失败,但你的文本会保留在此窗口中。",
|
disconnected: "已从 {name} 断开 — 保存将失败,但你的文本会保留在此窗口中。",
|
||||||
cantOpen: "无法打开 {name}",
|
cantOpen: "无法打开 {name}",
|
||||||
closeWindow: "关闭窗口",
|
closeWindow: "关闭窗口",
|
||||||
shortcuts: "{mod}S 保存 · {mod}F 查找 · {mod}W 关闭",
|
verbs: { save: "保存", find: "查找", close: "关闭" },
|
||||||
lines: { other: "{count} 行" },
|
lines: { other: "{count} 行" },
|
||||||
close: "关闭",
|
close: "关闭",
|
||||||
save: "保存",
|
save: "保存",
|
||||||
@@ -220,6 +211,7 @@ export const zh = {
|
|||||||
},
|
},
|
||||||
toasts: {
|
toasts: {
|
||||||
movedToTrash: '已将 "{name}" 移到废纸篓',
|
movedToTrash: '已将 "{name}" 移到废纸篓',
|
||||||
|
movedManyToTrash: { other: "已将 {count} 个项目移到废纸篓" },
|
||||||
editingExternal: "正在编辑 {name} — 保存后会自动上传",
|
editingExternal: "正在编辑 {name} — 保存后会自动上传",
|
||||||
nothingToCopy: "没有可复制的内容(符号链接将被跳过)",
|
nothingToCopy: "没有可复制的内容(符号链接将被跳过)",
|
||||||
nothingToCopyAllSkipped: "没有可复制的内容 — 所有冲突项都被跳过",
|
nothingToCopyAllSkipped: "没有可复制的内容 — 所有冲突项都被跳过",
|
||||||
@@ -255,6 +247,7 @@ export const zh = {
|
|||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
about: "关于 {app}",
|
about: "关于 {app}",
|
||||||
|
settings: "设置…",
|
||||||
checkForUpdates: "检查更新…",
|
checkForUpdates: "检查更新…",
|
||||||
quit: "退出",
|
quit: "退出",
|
||||||
file: "文件",
|
file: "文件",
|
||||||
@@ -301,6 +294,129 @@ export const zh = {
|
|||||||
tabDisconnect: "断开连接",
|
tabDisconnect: "断开连接",
|
||||||
trashTooltip: "从此处移入废纸篓的项目无法使用访达的放回原处功能",
|
trashTooltip: "从此处移入废纸篓的项目无法使用访达的放回原处功能",
|
||||||
},
|
},
|
||||||
|
keys: {
|
||||||
|
"cursor-up": "上移",
|
||||||
|
"cursor-down": "下移",
|
||||||
|
"page-up": "上一页",
|
||||||
|
"page-down": "下一页",
|
||||||
|
"cursor-first": "第一项",
|
||||||
|
"cursor-last": "最后一项",
|
||||||
|
"toggle-select": "选择 / 取消选择",
|
||||||
|
"select-all": "全选",
|
||||||
|
open: "打开",
|
||||||
|
parent: "上级目录",
|
||||||
|
"switch-panel": "切换面板",
|
||||||
|
"row-menu": "显示该项的菜单",
|
||||||
|
send: "发送到另一个面板",
|
||||||
|
edit: "编辑",
|
||||||
|
rename: "重命名",
|
||||||
|
"new-folder": "新建文件夹",
|
||||||
|
"new-file": "新建文件",
|
||||||
|
chmod: "权限",
|
||||||
|
delete: "删除",
|
||||||
|
trash: "移到废纸篓",
|
||||||
|
"copy-path": "复制路径",
|
||||||
|
reveal: "在文件管理器中显示",
|
||||||
|
"open-default": "用默认应用打开",
|
||||||
|
refresh: "刷新",
|
||||||
|
"focus-path": "转到路径",
|
||||||
|
"source-picker": "选择面板显示的内容",
|
||||||
|
"sort-name": "按名称排序",
|
||||||
|
"sort-size": "按大小排序",
|
||||||
|
"sort-mtime": "按修改时间排序",
|
||||||
|
"sort-kind": "按种类排序",
|
||||||
|
"sort-rights": "按权限排序",
|
||||||
|
"sort-owner": "按所有者排序",
|
||||||
|
"sort-group": "按组排序",
|
||||||
|
"new-tab": "新建标签页",
|
||||||
|
"close-tab": "关闭标签页",
|
||||||
|
"close-other-tabs": "关闭其他标签页",
|
||||||
|
"close-tabs-right": "关闭右侧标签页",
|
||||||
|
"next-tab": "下一个标签页",
|
||||||
|
"prev-tab": "上一个标签页",
|
||||||
|
"go-to-tab": "转到标签页 {n}",
|
||||||
|
"go-to-last-tab": "转到最后一个标签页",
|
||||||
|
"move-tab-left": "标签页左移",
|
||||||
|
"move-tab-right": "标签页右移",
|
||||||
|
"disconnect-tab": "断开标签页",
|
||||||
|
"open-settings": "设置",
|
||||||
|
"connection-manager": "连接管理器",
|
||||||
|
"toggle-transfers": "显示 / 隐藏传输",
|
||||||
|
"install-update": "安装更新",
|
||||||
|
"scale-up": "放大界面",
|
||||||
|
"scale-down": "缩小界面",
|
||||||
|
"scale-reset": "界面 100%",
|
||||||
|
"close-window": "关闭窗口",
|
||||||
|
save: "保存",
|
||||||
|
find: "查找",
|
||||||
|
"find-next": "查找下一个",
|
||||||
|
"find-prev": "查找上一个",
|
||||||
|
"toggle-wrap": "自动换行",
|
||||||
|
"conn-new": "新建连接",
|
||||||
|
"conn-new-stack": "新建分组",
|
||||||
|
"conn-save": "保存连接",
|
||||||
|
"conn-connect": "连接 / 打开分组",
|
||||||
|
"conn-delete": "删除连接或分组",
|
||||||
|
"conn-rename-stack": "重命名分组",
|
||||||
|
"conn-import": "从 WinSCP 导入",
|
||||||
|
groups: {
|
||||||
|
panels: "文件列表",
|
||||||
|
files: "文件",
|
||||||
|
sort: "排序",
|
||||||
|
tabs: "标签页",
|
||||||
|
window: "窗口",
|
||||||
|
editor: "编辑器",
|
||||||
|
connections: "连接管理器",
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
filter: "搜索快捷键",
|
||||||
|
intro: "点击 + 并按下按键即可添加快捷键。第一个会显示在菜单和提示中。",
|
||||||
|
add: "添加快捷键",
|
||||||
|
remove: "移除 {key}",
|
||||||
|
reset: "恢复默认",
|
||||||
|
resetAll: "全部恢复",
|
||||||
|
resetAllConfirm: "恢复所有快捷键",
|
||||||
|
press: "请按键…({esc} 取消)",
|
||||||
|
unbound: "无快捷键",
|
||||||
|
reserved: "{key} 已被系统保留",
|
||||||
|
needsModifier: "在编辑器中 {key} 只会输入字符:请加上修饰键或使用 F 键",
|
||||||
|
usedBy: "{key} 已被“{action}”使用",
|
||||||
|
replace: "替换",
|
||||||
|
conflict: "与 {action} 的按键相同",
|
||||||
|
plainLetter: "在文件列表中输入 {key} 将不再进行筛选",
|
||||||
|
noResults: "没有匹配的快捷键",
|
||||||
|
fixed: "固定按键",
|
||||||
|
},
|
||||||
|
fixed: {
|
||||||
|
escape: "{esc} 关闭菜单、对话框和查找栏",
|
||||||
|
escList: "在文件列表中按 {esc} 依次清除筛选、取消连接、清除选择",
|
||||||
|
dialogs: "{enter} 和 {esc} 确认或取消对话框;{tab} 和 {shiftTab} 在其控件间移动",
|
||||||
|
menus: "↑ ↓ {home} {end} 和 {enter} 适用于所有菜单",
|
||||||
|
filter: "在文件列表中输入即可筛选;{backspace} 删除筛选的最后一个字符",
|
||||||
|
shift: "Shift 加移动键会选中光标经过的行",
|
||||||
|
find: "{enter} 和 {shiftEnter} 在查找结果间跳转",
|
||||||
|
manager: "↑ ↓ ← → 和 {enter} 在连接列表中移动",
|
||||||
|
sidebar: "↑ ↓ 在这些分区间切换",
|
||||||
|
},
|
||||||
|
names: {
|
||||||
|
ctrl: "Ctrl",
|
||||||
|
alt: "Alt",
|
||||||
|
shift: "Shift",
|
||||||
|
enter: "Enter",
|
||||||
|
backspace: "退格",
|
||||||
|
del: "Del",
|
||||||
|
esc: "Esc",
|
||||||
|
space: "空格",
|
||||||
|
tab: "Tab",
|
||||||
|
pgup: "PgUp",
|
||||||
|
pgdn: "PgDn",
|
||||||
|
home: "Home",
|
||||||
|
end: "End",
|
||||||
|
ins: "Ins",
|
||||||
|
menu: "菜单",
|
||||||
|
num: "Num",
|
||||||
|
},
|
||||||
|
},
|
||||||
errors: {
|
errors: {
|
||||||
sshAgentUnavailable: "ssh-agent 不可用(未设置 SSH_AUTH_SOCK)",
|
sshAgentUnavailable: "ssh-agent 不可用(未设置 SSH_AUTH_SOCK)",
|
||||||
identityFileNotFound: "找不到身份文件:{path}",
|
identityFileNotFound: "找不到身份文件:{path}",
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { DICTS } from "./i18n";
|
||||||
|
import { en } from "./i18n/en";
|
||||||
|
import {
|
||||||
|
BINDINGS,
|
||||||
|
type Combo,
|
||||||
|
comboFromEvent,
|
||||||
|
conflicts,
|
||||||
|
EN_KEY_NAMES,
|
||||||
|
effectiveKeymap,
|
||||||
|
firesInTextField,
|
||||||
|
formatCombo,
|
||||||
|
GROUPS,
|
||||||
|
isReserved,
|
||||||
|
type KeyEventLike,
|
||||||
|
matchesEvent,
|
||||||
|
NATIVE_MENU_IDS,
|
||||||
|
parseCombo,
|
||||||
|
serializeCombo,
|
||||||
|
scopesOverlap,
|
||||||
|
toAccelerator,
|
||||||
|
} from "./keymap";
|
||||||
|
|
||||||
|
const US_CHARS: Record<string, string> = {
|
||||||
|
Comma: ",",
|
||||||
|
Period: ".",
|
||||||
|
Slash: "/",
|
||||||
|
Semicolon: ";",
|
||||||
|
Quote: "'",
|
||||||
|
BracketLeft: "[",
|
||||||
|
BracketRight: "]",
|
||||||
|
Backslash: "\\",
|
||||||
|
Backquote: "`",
|
||||||
|
Equal: "=",
|
||||||
|
Minus: "-",
|
||||||
|
NumpadAdd: "+",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The keydown a US keyboard produces for `c`. */
|
||||||
|
function eventFor(c: Combo, isMac: boolean): KeyEventLike {
|
||||||
|
let key = c.key;
|
||||||
|
let code = c.key;
|
||||||
|
if (/^[A-Z]$/.test(c.key)) {
|
||||||
|
key = c.shift ? c.key : c.key.toLowerCase();
|
||||||
|
code = `Key${c.key}`;
|
||||||
|
} else if (/^[0-9]$/.test(c.key)) {
|
||||||
|
code = `Digit${c.key}`;
|
||||||
|
} else if (c.key in US_CHARS) {
|
||||||
|
key = US_CHARS[c.key];
|
||||||
|
} else if (c.key === "Space") {
|
||||||
|
key = " ";
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
code,
|
||||||
|
metaKey: isMac && c.mod,
|
||||||
|
ctrlKey: isMac ? c.ctrl : c.mod,
|
||||||
|
altKey: c.alt,
|
||||||
|
shiftKey: c.shift,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ev = (over: Partial<KeyEventLike>): KeyEventLike => ({
|
||||||
|
key: "",
|
||||||
|
code: "",
|
||||||
|
metaKey: false,
|
||||||
|
ctrlKey: false,
|
||||||
|
altKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const combo = (text: string, isMac: boolean) => {
|
||||||
|
const c = parseCombo(text, isMac);
|
||||||
|
if (!c) throw new Error(`unparsable ${text}`);
|
||||||
|
return c;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const isMac of [true, false]) {
|
||||||
|
const os = isMac ? "macOS" : "Windows/Linux";
|
||||||
|
const keymap = effectiveKeymap({}, isMac);
|
||||||
|
|
||||||
|
test(`${os}: the defaults have no conflicts`, () => {
|
||||||
|
expect([...conflicts(keymap).entries()]).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${os}: every default round-trips and resolves to its own command`, () => {
|
||||||
|
for (const binding of keymap) {
|
||||||
|
for (const c of binding.combos) {
|
||||||
|
expect(parseCombo(serializeCombo(c), isMac)).toEqual(c);
|
||||||
|
const event = eventFor(c, isMac);
|
||||||
|
// table order is resolution order: nothing earlier may shadow it
|
||||||
|
const hit = keymap.find(
|
||||||
|
(other) => scopesOverlap(other.scopes, binding.scopes) && other.combos.some((oc) => matchesEvent(oc, event, isMac)),
|
||||||
|
);
|
||||||
|
expect(`${serializeCombo(c)} → ${hit?.id}`).toBe(`${serializeCombo(c)} → ${binding.id}`);
|
||||||
|
expect(comboFromEvent(event, isMac)).toEqual(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${os}: no default is a reserved combo`, () => {
|
||||||
|
for (const binding of keymap) for (const c of binding.combos) expect(isReserved(c, isMac)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${os}: every macOS menu command has an expressible accelerator`, () => {
|
||||||
|
if (!isMac) return;
|
||||||
|
for (const id of NATIVE_MENU_IDS) {
|
||||||
|
const binding = keymap.find((b) => b.id === id)!;
|
||||||
|
expect(binding.combos.some((c) => toAccelerator(c) !== null)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("every command and group is labelled — in every shipped language", () => {
|
||||||
|
for (const [locale, dict] of Object.entries(DICTS)) {
|
||||||
|
const keys = dict.keys as Record<string, unknown> & { groups: Record<string, unknown> };
|
||||||
|
for (const b of BINDINGS) {
|
||||||
|
expect(`${locale}:${b.id}:${typeof keys["n" in b ? "go-to-tab" : b.id]}`).toBe(`${locale}:${b.id}:string`);
|
||||||
|
}
|
||||||
|
for (const g of GROUPS) expect(`${locale}:${g}:${typeof keys.groups[g]}`).toBe(`${locale}:${g}:string`);
|
||||||
|
}
|
||||||
|
expect(typeof en.keys.ui.press).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("matching is exact about modifiers", () => {
|
||||||
|
// ⌃F is Cocoa's forward-one-character, not ⌘F
|
||||||
|
expect(matchesEvent(combo("Mod+F", true), ev({ key: "f", code: "KeyF", ctrlKey: true }), true)).toBe(false);
|
||||||
|
expect(matchesEvent(combo("Mod+F", true), ev({ key: "f", code: "KeyF", metaKey: true }), true)).toBe(true);
|
||||||
|
// Ctrl+Tab is not Tab (the old panel switch caught both)
|
||||||
|
expect(matchesEvent(combo("Tab", false), ev({ key: "Tab", code: "Tab", ctrlKey: true }), false)).toBe(false);
|
||||||
|
// the Windows key is never part of a binding
|
||||||
|
expect(matchesEvent(combo("Mod+T", false), ev({ key: "t", code: "KeyT", ctrlKey: true, metaKey: true }), false)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("letters follow the layout, Alt chords and other scripts use the physical key", () => {
|
||||||
|
// AZERTY: the comma sits on the M key, the A on the Q key
|
||||||
|
expect(comboFromEvent(ev({ key: ",", code: "KeyM", metaKey: true }), true)).toEqual(combo("Mod+Comma", true));
|
||||||
|
expect(comboFromEvent(ev({ key: "a", code: "KeyQ", ctrlKey: true }), false)).toEqual(combo("Mod+A", false));
|
||||||
|
// macOS Option turns ⌥Z into Ω
|
||||||
|
expect(comboFromEvent(ev({ key: "Ω", code: "KeyZ", altKey: true }), true)).toEqual(combo("Alt+Z", true));
|
||||||
|
// Cyrillic ⌘T arrives as "е" on the T key
|
||||||
|
expect(matchesEvent(combo("Mod+T", true), ev({ key: "е", code: "KeyT", metaKey: true }), true)).toBe(true);
|
||||||
|
// US ⌘+ is ⇧⌘= — matched by the physical key
|
||||||
|
expect(matchesEvent(combo("Mod+Shift+Equal", true), ev({ key: "+", code: "Equal", metaKey: true, shiftKey: true }), true)).toBe(true);
|
||||||
|
// AZERTY ⌘1 types "&" on the 1 key
|
||||||
|
expect(comboFromEvent(ev({ key: "&", code: "Digit1", metaKey: true }), true)).toEqual(combo("Mod+1", true));
|
||||||
|
// a bare modifier press is not a combo
|
||||||
|
expect(comboFromEvent(ev({ key: "Shift", code: "ShiftLeft", shiftKey: true }), true)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formatting per platform, with localized key names off macOS", () => {
|
||||||
|
expect(formatCombo(combo("Mod+Shift+N", true), true)).toBe("⇧⌘N");
|
||||||
|
expect(formatCombo(combo("Ctrl+Tab", true), true)).toBe("⌃⇥");
|
||||||
|
expect(formatCombo(combo("Mod+Alt+C", true), true)).toBe("⌥⌘C");
|
||||||
|
expect(formatCombo(combo("Mod+Comma", true), true)).toBe("⌘,");
|
||||||
|
expect(formatCombo(combo("Shift+F10", true), true)).toBe("⇧F10");
|
||||||
|
expect(formatCombo(combo("Mod+Backspace", true), true)).toBe("⌘⌫");
|
||||||
|
expect(formatCombo(combo("Mod+Shift+N", false), false)).toBe("Ctrl+Shift+N");
|
||||||
|
expect(formatCombo(combo("Alt+Shift+R", false), false)).toBe("Alt+Shift+R");
|
||||||
|
expect(formatCombo(combo("Delete", false), false)).toBe("Del");
|
||||||
|
const de = { ...EN_KEY_NAMES, ctrl: "Strg", del: "Entf" };
|
||||||
|
expect(formatCombo(combo("Mod+Delete", false), false, de)).toBe("Strg+Entf");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("overrides replace a row; garbage is dropped", () => {
|
||||||
|
const keymap = effectiveKeymap(
|
||||||
|
{ rename: ["Mod+Shift+R", "nonsense", 5, "Mod+Shift+R"], save: "F9", bogus: ["F1"], delete: [] },
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const by = (id: string) => keymap.find((b) => b.id === id)!;
|
||||||
|
expect(by("rename").combos.map(serializeCombo)).toEqual(["Mod+Shift+R"]);
|
||||||
|
expect(by("rename").custom).toBe(true);
|
||||||
|
// not an array: ignored, the defaults stand
|
||||||
|
expect(by("save").custom).toBe(false);
|
||||||
|
expect(by("save").combos.map(serializeCombo)).toEqual(["Mod+S"]);
|
||||||
|
// [] unbinds
|
||||||
|
expect(by("delete").combos).toEqual([]);
|
||||||
|
expect(keymap.some((b) => (b.id as string) === "bogus")).toBe(false);
|
||||||
|
// not an object at all
|
||||||
|
expect(effectiveKeymap("junk", true).every((b) => !b.custom)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("off macOS a stored literal Ctrl folds into Mod", () => {
|
||||||
|
expect(parseCombo("Ctrl+Tab", false)).toEqual(parseCombo("Mod+Tab", false));
|
||||||
|
expect(parseCombo("Ctrl+Tab", true)).not.toEqual(parseCombo("Mod+Tab", true));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("native menu accelerators", () => {
|
||||||
|
expect(toAccelerator(combo("Mod+Shift+N", true))).toBe("Command+Shift+n");
|
||||||
|
expect(toAccelerator(combo("Mod+Comma", true))).toBe("Command+,");
|
||||||
|
expect(toAccelerator(combo("Mod+Minus", true))).toBe("Command+minus");
|
||||||
|
expect(toAccelerator(combo("Ctrl+Shift+PageUp", true))).toBe("Control+Shift+pageup");
|
||||||
|
// a bare key would mean ⌘+key to the native parser
|
||||||
|
expect(toAccelerator(combo("F5", true))).toBeNull();
|
||||||
|
// ⌫ / ⌦, Shift+punctuation and modifier-less combos have no faithful form
|
||||||
|
expect(toAccelerator(combo("Mod+Delete", true))).toBeNull();
|
||||||
|
expect(toAccelerator(combo("Mod+Backspace", true))).toBeNull();
|
||||||
|
expect(toAccelerator(combo("Mod+Shift+BracketRight", true))).toBeNull();
|
||||||
|
expect(toAccelerator(combo("Shift+F4", true))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("text fields keep their own keys", () => {
|
||||||
|
// plain keys and text-editing chords type / edit
|
||||||
|
expect(firesInTextField(combo("Delete", true), true)).toBe(false);
|
||||||
|
expect(firesInTextField(combo("Mod+Backspace", true), true)).toBe(false);
|
||||||
|
expect(firesInTextField(combo("Mod+A", true), true)).toBe(false);
|
||||||
|
expect(firesInTextField(combo("Alt+ArrowLeft", true), true)).toBe(false);
|
||||||
|
// app chords and F-keys still fire (⌘S in the editor, ⌘↩ in the manager form)
|
||||||
|
expect(firesInTextField(combo("Mod+S", true), true)).toBe(true);
|
||||||
|
expect(firesInTextField(combo("Mod+Enter", false), false)).toBe(true);
|
||||||
|
expect(firesInTextField(combo("F3", false), false)).toBe(true);
|
||||||
|
// Ctrl+Alt off macOS is AltGr typing a character
|
||||||
|
expect(firesInTextField(combo("Mod+Alt+Q", false), false)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("AltGr is never a shortcut off macOS", () => {
|
||||||
|
const altGrQ = ev({ key: "@", code: "KeyQ", ctrlKey: true, altKey: true, getModifierState: (k) => k === "AltGraph" });
|
||||||
|
expect(matchesEvent(combo("Mod+Alt+Q", false), altGrQ, false)).toBe(false);
|
||||||
|
expect(comboFromEvent(altGrQ, false)).toBeNull();
|
||||||
|
});
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
// The app's one keymap: every keyboard command, where it fires and its default
|
||||||
|
// keys per platform, plus the pure helpers that parse, match, format and check
|
||||||
|
// key combos. Shared on purpose: the webviews resolve keys, show hints and edit
|
||||||
|
// remaps against this table, and bun builds the macOS menu-bar accelerators
|
||||||
|
// from it, so the two can never disagree. User remaps are stored as overrides
|
||||||
|
// in AppSettings.keybindings and applied by effectiveKeymap().
|
||||||
|
|
||||||
|
/** Where a binding fires. `panel` = the focused file list, `main` = anywhere
|
||||||
|
* in the main window, `any` = every window. `panel` sits inside `main`, so
|
||||||
|
* the two overlap for conflict purposes. */
|
||||||
|
export type Scope = "panel" | "main" | "editor" | "manager" | "settings" | "any";
|
||||||
|
|
||||||
|
/** Settings → Keyboard sections, in display order. */
|
||||||
|
export const GROUPS = ["panels", "files", "sort", "tabs", "window", "editor", "connections"] as const;
|
||||||
|
export type Group = (typeof GROUPS)[number];
|
||||||
|
|
||||||
|
interface BindingDef {
|
||||||
|
readonly id: string;
|
||||||
|
readonly group: Group;
|
||||||
|
readonly scopes: readonly Scope[];
|
||||||
|
/** Default combos, best first: the first is the one menus and tooltips show. */
|
||||||
|
readonly mac: readonly string[];
|
||||||
|
readonly other: readonly string[];
|
||||||
|
/** go-to-tab-N: the N its label interpolates. */
|
||||||
|
readonly n?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const both = (...combos: string[]) => ({ mac: combos, other: combos });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every command, in Settings display order within its group. Combo strings are
|
||||||
|
* platform-neutral (see parseCombo): `Mod` is ⌘ on macOS and Ctrl elsewhere,
|
||||||
|
* `Ctrl` a literal ⌃ that only macOS tells apart.
|
||||||
|
*/
|
||||||
|
export const BINDINGS = [
|
||||||
|
// ---- panels: the focused file list ----
|
||||||
|
{ id: "cursor-up", group: "panels", scopes: ["panel"], ...both("ArrowUp") },
|
||||||
|
{ id: "cursor-down", group: "panels", scopes: ["panel"], ...both("ArrowDown") },
|
||||||
|
{ id: "page-up", group: "panels", scopes: ["panel"], ...both("PageUp") },
|
||||||
|
{ id: "page-down", group: "panels", scopes: ["panel"], ...both("PageDown") },
|
||||||
|
{ id: "cursor-first", group: "panels", scopes: ["panel"], ...both("Home") },
|
||||||
|
{ id: "cursor-last", group: "panels", scopes: ["panel"], ...both("End") },
|
||||||
|
{ id: "toggle-select", group: "panels", scopes: ["panel"], mac: ["Space"], other: ["Space", "Insert"] },
|
||||||
|
{ id: "select-all", group: "panels", scopes: ["panel"], ...both("Mod+A") },
|
||||||
|
{ id: "open", group: "panels", scopes: ["panel"], mac: ["Enter", "Mod+ArrowDown"], other: ["Enter"] },
|
||||||
|
{ id: "parent", group: "panels", scopes: ["panel"], mac: ["Backspace", "Mod+ArrowUp"], other: ["Backspace", "Alt+ArrowUp"] },
|
||||||
|
{ id: "switch-panel", group: "panels", scopes: ["panel"], ...both("Tab", "Shift+Tab") },
|
||||||
|
{ id: "row-menu", group: "panels", scopes: ["panel"], mac: ["Shift+F10", "Ctrl+Enter"], other: ["Shift+F10", "ContextMenu"] },
|
||||||
|
// ---- files: act on the focused panel's selection, else its cursor row ----
|
||||||
|
{ id: "send", group: "files", scopes: ["panel"], ...both("F5") },
|
||||||
|
{ id: "edit", group: "files", scopes: ["panel"], mac: ["Mod+E", "F4"], other: ["F4", "Mod+E"] },
|
||||||
|
{ id: "rename", group: "files", scopes: ["panel"], ...both("F2") },
|
||||||
|
{ id: "new-folder", group: "files", scopes: ["panel"], mac: ["Mod+Shift+N", "F7"], other: ["F7", "Mod+Shift+N"] },
|
||||||
|
{ id: "new-file", group: "files", scopes: ["panel"], mac: ["Mod+N", "Shift+F4"], other: ["Shift+F4", "Mod+N"] },
|
||||||
|
{ id: "chmod", group: "files", scopes: ["panel"], ...both("Mod+I") },
|
||||||
|
{ id: "delete", group: "files", scopes: ["panel"], ...both("F8", "Delete") },
|
||||||
|
{ id: "trash", group: "files", scopes: ["panel"], mac: ["Mod+Backspace"], other: ["Mod+Delete"] },
|
||||||
|
{ id: "copy-path", group: "files", scopes: ["panel"], mac: ["Mod+Alt+C"], other: ["Mod+Shift+C"] },
|
||||||
|
{ id: "reveal", group: "files", scopes: ["panel"], mac: ["Mod+Alt+R"], other: ["Alt+Shift+R"] },
|
||||||
|
{ id: "open-default", group: "files", scopes: ["panel"], ...both("Mod+O") },
|
||||||
|
{ id: "refresh", group: "files", scopes: ["panel"], ...both("Mod+R") },
|
||||||
|
{ id: "focus-path", group: "files", scopes: ["panel"], ...both("Mod+L") },
|
||||||
|
{ id: "source-picker", group: "files", scopes: ["panel"], ...both("Mod+K") },
|
||||||
|
// ---- sort the focused panel (again = reverse) ----
|
||||||
|
{ id: "sort-name", group: "sort", scopes: ["panel"], ...both("Alt+1") },
|
||||||
|
{ id: "sort-size", group: "sort", scopes: ["panel"], ...both("Alt+2") },
|
||||||
|
{ id: "sort-mtime", group: "sort", scopes: ["panel"], ...both("Alt+3") },
|
||||||
|
{ id: "sort-kind", group: "sort", scopes: ["panel"], ...both("Alt+4") },
|
||||||
|
{ id: "sort-rights", group: "sort", scopes: ["panel"], ...both("Alt+5") },
|
||||||
|
{ id: "sort-owner", group: "sort", scopes: ["panel"], ...both("Alt+6") },
|
||||||
|
{ id: "sort-group", group: "sort", scopes: ["panel"], ...both("Alt+7") },
|
||||||
|
// ---- tabs (Ctrl+Tab is a literal ⌃ on macOS: ⌘Tab is the app switcher) ----
|
||||||
|
{ id: "new-tab", group: "tabs", scopes: ["main"], ...both("Mod+T") },
|
||||||
|
{ id: "close-tab", group: "tabs", scopes: ["main"], ...both("Mod+W") },
|
||||||
|
// Ctrl+Alt+letter is AltGr typing on many non-mac layouts — never a default there
|
||||||
|
{ id: "close-other-tabs", group: "tabs", scopes: ["main"], mac: ["Mod+Alt+W"], other: ["Mod+Shift+W"] },
|
||||||
|
{ id: "close-tabs-right", group: "tabs", scopes: ["main"], ...both() },
|
||||||
|
{ id: "next-tab", group: "tabs", scopes: ["main"], mac: ["Ctrl+Tab", "Mod+Shift+BracketRight"], other: ["Ctrl+Tab", "Mod+PageDown"] },
|
||||||
|
{ id: "prev-tab", group: "tabs", scopes: ["main"], mac: ["Ctrl+Shift+Tab", "Mod+Shift+BracketLeft"], other: ["Ctrl+Shift+Tab", "Mod+PageUp"] },
|
||||||
|
{ id: "go-to-tab-1", group: "tabs", scopes: ["main"], n: 1, ...both("Mod+1") },
|
||||||
|
{ id: "go-to-tab-2", group: "tabs", scopes: ["main"], n: 2, ...both("Mod+2") },
|
||||||
|
{ id: "go-to-tab-3", group: "tabs", scopes: ["main"], n: 3, ...both("Mod+3") },
|
||||||
|
{ id: "go-to-tab-4", group: "tabs", scopes: ["main"], n: 4, ...both("Mod+4") },
|
||||||
|
{ id: "go-to-tab-5", group: "tabs", scopes: ["main"], n: 5, ...both("Mod+5") },
|
||||||
|
{ id: "go-to-tab-6", group: "tabs", scopes: ["main"], n: 6, ...both("Mod+6") },
|
||||||
|
{ id: "go-to-tab-7", group: "tabs", scopes: ["main"], n: 7, ...both("Mod+7") },
|
||||||
|
{ id: "go-to-tab-8", group: "tabs", scopes: ["main"], n: 8, ...both("Mod+8") },
|
||||||
|
{ id: "go-to-last-tab", group: "tabs", scopes: ["main"], ...both("Mod+9") },
|
||||||
|
{ id: "move-tab-left", group: "tabs", scopes: ["main"], mac: ["Ctrl+Shift+PageUp"], other: ["Mod+Shift+PageUp"] },
|
||||||
|
{ id: "move-tab-right", group: "tabs", scopes: ["main"], mac: ["Ctrl+Shift+PageDown"], other: ["Mod+Shift+PageDown"] },
|
||||||
|
{ id: "disconnect-tab", group: "tabs", scopes: ["main"], ...both("Mod+D") },
|
||||||
|
// ---- window ----
|
||||||
|
{ id: "open-settings", group: "window", scopes: ["main"], ...both("Mod+Comma") },
|
||||||
|
{ id: "connection-manager", group: "window", scopes: ["main"], ...both("Mod+Shift+K") },
|
||||||
|
{ id: "toggle-transfers", group: "window", scopes: ["main"], ...both("Mod+J") },
|
||||||
|
{ id: "install-update", group: "window", scopes: ["main"], ...both() },
|
||||||
|
// ⌘+ is ⇧⌘= on US layouts — both spellings, plus the keypad
|
||||||
|
{ id: "scale-up", group: "window", scopes: ["any"], ...both("Mod+Equal", "Mod+Shift+Equal", "Mod+NumpadAdd") },
|
||||||
|
{ id: "scale-down", group: "window", scopes: ["any"], ...both("Mod+Minus") },
|
||||||
|
{ id: "scale-reset", group: "window", scopes: ["any"], ...both("Mod+0") },
|
||||||
|
{ id: "close-window", group: "window", scopes: ["editor", "manager", "settings"], ...both("Mod+W") },
|
||||||
|
// ---- editor window ----
|
||||||
|
{ id: "save", group: "editor", scopes: ["editor"], ...both("Mod+S") },
|
||||||
|
{ id: "find", group: "editor", scopes: ["editor"], ...both("Mod+F") },
|
||||||
|
{ id: "find-next", group: "editor", scopes: ["editor"], mac: ["Mod+G"], other: ["F3"] },
|
||||||
|
{ id: "find-prev", group: "editor", scopes: ["editor"], mac: ["Mod+Shift+G"], other: ["Shift+F3"] },
|
||||||
|
{ id: "toggle-wrap", group: "editor", scopes: ["editor"], ...both("Alt+Z") },
|
||||||
|
// ---- connection manager ----
|
||||||
|
{ id: "conn-new", group: "connections", scopes: ["manager"], ...both("Mod+N") },
|
||||||
|
{ id: "conn-new-stack", group: "connections", scopes: ["manager"], ...both("Mod+Shift+N") },
|
||||||
|
{ id: "conn-save", group: "connections", scopes: ["manager"], ...both("Mod+S") },
|
||||||
|
{ id: "conn-connect", group: "connections", scopes: ["manager"], ...both("Mod+Enter") },
|
||||||
|
{ id: "conn-delete", group: "connections", scopes: ["manager"], mac: ["Mod+Backspace"], other: ["Delete"] },
|
||||||
|
{ id: "conn-rename-stack", group: "connections", scopes: ["manager"], ...both("F2") },
|
||||||
|
{ id: "conn-import", group: "connections", scopes: ["manager"], ...both("Mod+O") },
|
||||||
|
] as const satisfies readonly BindingDef[];
|
||||||
|
|
||||||
|
export type ShortcutId = (typeof BINDINGS)[number]["id"];
|
||||||
|
|
||||||
|
/** Commands the macOS menu bar carries; their accelerators follow the keymap. */
|
||||||
|
export const NATIVE_MENU_IDS: readonly ShortcutId[] = [
|
||||||
|
"open-settings",
|
||||||
|
"new-tab",
|
||||||
|
"close-tab",
|
||||||
|
"new-folder",
|
||||||
|
"new-file",
|
||||||
|
"edit",
|
||||||
|
"connection-manager",
|
||||||
|
"refresh",
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface Combo {
|
||||||
|
/** A–Z, 0–9, a punctuation event.code (Comma, Equal…) or a key name (Enter, F2…). */
|
||||||
|
key: string;
|
||||||
|
/** ⌘ on macOS, Ctrl elsewhere. */
|
||||||
|
mod: boolean;
|
||||||
|
/** A literal ⌃, macOS only — elsewhere Ctrl IS Mod. */
|
||||||
|
ctrl: boolean;
|
||||||
|
alt: boolean;
|
||||||
|
shift: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The KeyboardEvent fields matching needs (tests build these by hand). */
|
||||||
|
export interface KeyEventLike {
|
||||||
|
key: string;
|
||||||
|
code: string;
|
||||||
|
metaKey: boolean;
|
||||||
|
ctrlKey: boolean;
|
||||||
|
altKey: boolean;
|
||||||
|
shiftKey: boolean;
|
||||||
|
getModifierState?(key: string): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedBinding {
|
||||||
|
id: ShortcutId;
|
||||||
|
group: Group;
|
||||||
|
scopes: readonly Scope[];
|
||||||
|
n?: number;
|
||||||
|
/** What fires: the user's override when there is one, else the defaults. */
|
||||||
|
combos: Combo[];
|
||||||
|
defaults: Combo[];
|
||||||
|
/** The user replaced this row's defaults (possibly with nothing). */
|
||||||
|
custom: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LETTER = /^[A-Z]$/;
|
||||||
|
const DIGIT = /^[0-9]$/;
|
||||||
|
const FKEY = /^F([1-9]|1[0-2])$/;
|
||||||
|
/** Punctuation tokens and the character each stands for. Matched by character
|
||||||
|
* first (AZERTY's comma is on the M key) and by physical key second (⇧] makes
|
||||||
|
* "}", the US ⌘+ is ⇧⌘=). */
|
||||||
|
const CHAR_OF: Record<string, string> = {
|
||||||
|
Comma: ",",
|
||||||
|
Period: ".",
|
||||||
|
Slash: "/",
|
||||||
|
Semicolon: ";",
|
||||||
|
Quote: "'",
|
||||||
|
BracketLeft: "[",
|
||||||
|
BracketRight: "]",
|
||||||
|
Backslash: "\\",
|
||||||
|
Backquote: "`",
|
||||||
|
Equal: "=",
|
||||||
|
Minus: "-",
|
||||||
|
};
|
||||||
|
const TOKEN_OF_CHAR: Record<string, string> = Object.fromEntries(
|
||||||
|
Object.entries(CHAR_OF).map(([token, char]) => [char, token]),
|
||||||
|
);
|
||||||
|
/** Physical-only keys: the keypad's + (its − already types "-", which is Minus). */
|
||||||
|
const KEYPAD = new Set(["NumpadAdd"]);
|
||||||
|
/** KeyboardEvent.key names, the same on every layout. */
|
||||||
|
const NAMED = new Set([
|
||||||
|
"Enter",
|
||||||
|
"Escape",
|
||||||
|
"Backspace",
|
||||||
|
"Delete",
|
||||||
|
"Tab",
|
||||||
|
"Space",
|
||||||
|
"ArrowUp",
|
||||||
|
"ArrowDown",
|
||||||
|
"ArrowLeft",
|
||||||
|
"ArrowRight",
|
||||||
|
"Home",
|
||||||
|
"End",
|
||||||
|
"PageUp",
|
||||||
|
"PageDown",
|
||||||
|
"Insert",
|
||||||
|
"ContextMenu",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isToken(key: string): boolean {
|
||||||
|
return (
|
||||||
|
LETTER.test(key) || DIGIT.test(key) || FKEY.test(key) || Object.hasOwn(CHAR_OF, key) || KEYPAD.has(key) || NAMED.has(key)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An event's key token, or null for modifier-only presses and keys the keymap
|
||||||
|
* doesn't cover. Letters follow the layout (AZERTY's A is A); under Alt
|
||||||
|
* (macOS Option turns ⌥Z into Ω) and on non-Latin layouts they fall back to the
|
||||||
|
* physical key. Digits are physical too: AZERTY types "&" on the 1 key.
|
||||||
|
*/
|
||||||
|
export function keyToken(e: Pick<KeyEventLike, "key" | "code" | "altKey">): string | null {
|
||||||
|
const { key, code } = e;
|
||||||
|
const upper = key.length === 1 ? key.toUpperCase() : "";
|
||||||
|
if (!e.altKey && LETTER.test(upper)) return upper;
|
||||||
|
if (/^Key[A-Z]$/.test(code) && (e.altKey || /\p{L}/u.test(key))) return code.slice(3);
|
||||||
|
if (/^Digit[0-9]$/.test(code)) return code.slice(5);
|
||||||
|
if (/^Numpad[0-9]$/.test(code)) return DIGIT.test(key) ? key : NAMED.has(key) ? key : null;
|
||||||
|
if (Object.hasOwn(TOKEN_OF_CHAR, key)) return TOKEN_OF_CHAR[key];
|
||||||
|
if (Object.hasOwn(CHAR_OF, code) || KEYPAD.has(code)) return code;
|
||||||
|
if (key === " " || code === "Space") return "Space";
|
||||||
|
if (FKEY.test(key) || NAMED.has(key)) return key;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventMods(e: KeyEventLike, isMac: boolean) {
|
||||||
|
return isMac
|
||||||
|
? { mod: e.metaKey, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, other: false }
|
||||||
|
: // never part of a binding: the Windows / Super key, and AltGr — which
|
||||||
|
// arrives as Ctrl+Alt while it types "@", "€", "{"…
|
||||||
|
{
|
||||||
|
mod: e.ctrlKey,
|
||||||
|
ctrl: false,
|
||||||
|
alt: e.altKey,
|
||||||
|
shift: e.shiftKey,
|
||||||
|
other: e.metaKey || !!e.getModifierState?.("AltGraph"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exact match: the same key AND exactly the same modifiers. ⌃ is not ⌘ on
|
||||||
|
* macOS (⌃F is Cocoa's forward-one-character), and Ctrl+Tab is not Tab. */
|
||||||
|
export function matchesEvent(combo: Combo, e: KeyEventLike, isMac: boolean): boolean {
|
||||||
|
const m = eventMods(e, isMac);
|
||||||
|
if (m.other || m.mod !== combo.mod || m.ctrl !== combo.ctrl || m.alt !== combo.alt || m.shift !== combo.shift) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return keyToken(e) === combo.key || ((Object.hasOwn(CHAR_OF, combo.key) || KEYPAD.has(combo.key)) && e.code === combo.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The combo a keydown spells (Settings → Keyboard recording), or null. */
|
||||||
|
export function comboFromEvent(e: KeyEventLike, isMac: boolean): Combo | null {
|
||||||
|
const key = keyToken(e);
|
||||||
|
const m = eventMods(e, isMac);
|
||||||
|
if (!key || m.other) return null;
|
||||||
|
return { key, mod: m.mod, ctrl: m.ctrl, alt: m.alt, shift: m.shift };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Mod+Shift+N" → Combo; null for anything malformed. Off macOS a stored
|
||||||
|
* literal Ctrl folds into Mod — on those keyboards they are the same key. */
|
||||||
|
export function parseCombo(text: unknown, isMac: boolean): Combo | null {
|
||||||
|
if (typeof text !== "string" || !text) return null;
|
||||||
|
const parts = text.split("+");
|
||||||
|
let key = parts.pop() ?? "";
|
||||||
|
if (key.length === 1) key = key.toUpperCase();
|
||||||
|
if (!isToken(key)) return null;
|
||||||
|
const combo: Combo = { key, mod: false, ctrl: false, alt: false, shift: false };
|
||||||
|
for (const part of parts) {
|
||||||
|
const name = part.toLowerCase();
|
||||||
|
if (name === "mod") combo.mod = true;
|
||||||
|
else if (name === "ctrl") combo.ctrl = true;
|
||||||
|
else if (name === "alt") combo.alt = true;
|
||||||
|
else if (name === "shift") combo.shift = true;
|
||||||
|
else return null;
|
||||||
|
}
|
||||||
|
if (!isMac && combo.ctrl) {
|
||||||
|
combo.ctrl = false;
|
||||||
|
combo.mod = true;
|
||||||
|
}
|
||||||
|
return combo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeCombo(c: Combo): string {
|
||||||
|
return [c.mod && "Mod", c.ctrl && "Ctrl", c.alt && "Alt", c.shift && "Shift", c.key].filter(Boolean).join("+");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameCombo(a: Combo, b: Combo): boolean {
|
||||||
|
return serializeCombo(a) === serializeCombo(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseList(list: readonly unknown[], isMac: boolean): Combo[] {
|
||||||
|
const out: Combo[] = [];
|
||||||
|
for (const text of list) {
|
||||||
|
const combo = parseCombo(text, isMac);
|
||||||
|
if (combo && !out.some((c) => sameCombo(c, combo))) out.push(combo);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The table with the user's overrides applied — the single place a hand-edited
|
||||||
|
* settings.json is sanitized: unknown ids, non-arrays and malformed combos are
|
||||||
|
* dropped. An override replaces a row's whole list; [] unbinds it.
|
||||||
|
*/
|
||||||
|
export function effectiveKeymap(overrides: unknown, isMac: boolean): ResolvedBinding[] {
|
||||||
|
const table: Record<string, unknown> =
|
||||||
|
overrides && typeof overrides === "object" && !Array.isArray(overrides)
|
||||||
|
? (overrides as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
return BINDINGS.map((b): ResolvedBinding => {
|
||||||
|
const defaults = parseList(isMac ? b.mac : b.other, isMac);
|
||||||
|
const raw = Object.hasOwn(table, b.id) ? table[b.id] : undefined;
|
||||||
|
const custom = Array.isArray(raw);
|
||||||
|
return {
|
||||||
|
id: b.id,
|
||||||
|
group: b.group,
|
||||||
|
scopes: b.scopes,
|
||||||
|
n: "n" in b ? b.n : undefined,
|
||||||
|
defaults,
|
||||||
|
custom,
|
||||||
|
combos: custom ? parseList(raw, isMac) : defaults,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stored shape, coerced to arrays of combo strings, for
|
||||||
|
* SettingsStore.load: a hand-edited settings.json can't break boot. Unknown
|
||||||
|
* ids are kept (a newer build's commands survive a downgrade round trip);
|
||||||
|
* effectiveKeymap ignores them. */
|
||||||
|
export function sanitizeKeybindings(raw: unknown): Record<string, string[]> {
|
||||||
|
const out: Record<string, string[]> = {};
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
|
||||||
|
for (const [id, list] of Object.entries(raw)) {
|
||||||
|
if (Array.isArray(list)) out[id] = list.filter((t): t is string => parseCombo(t, true) !== null);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a binding in `a` and one in `b` can fire on the same keystroke. */
|
||||||
|
export function scopesOverlap(a: readonly Scope[], b: readonly Scope[]): boolean {
|
||||||
|
return a.some((x) =>
|
||||||
|
b.some(
|
||||||
|
(y) =>
|
||||||
|
x === y ||
|
||||||
|
x === "any" ||
|
||||||
|
y === "any" ||
|
||||||
|
(x === "panel" && y === "main") ||
|
||||||
|
(x === "main" && y === "panel"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The binding that already owns `combo` where `scopes` fire, if any. */
|
||||||
|
export function ownerOf(
|
||||||
|
keymap: readonly ResolvedBinding[],
|
||||||
|
combo: Combo,
|
||||||
|
scopes: readonly Scope[],
|
||||||
|
except?: ShortcutId,
|
||||||
|
): ResolvedBinding | undefined {
|
||||||
|
return keymap.find(
|
||||||
|
(b) => b.id !== except && scopesOverlap(b.scopes, scopes) && b.combos.some((c) => sameCombo(c, combo)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every binding that shares a combo with another one it can collide with. */
|
||||||
|
export function conflicts(keymap: readonly ResolvedBinding[]): Map<ShortcutId, ShortcutId[]> {
|
||||||
|
const out = new Map<ShortcutId, ShortcutId[]>();
|
||||||
|
const link = (from: ShortcutId, to: ShortcutId) => out.set(from, [...(out.get(from) ?? []), to]);
|
||||||
|
for (let i = 0; i < keymap.length; i++) {
|
||||||
|
for (let j = i + 1; j < keymap.length; j++) {
|
||||||
|
const a = keymap[i];
|
||||||
|
const b = keymap[j];
|
||||||
|
if (!scopesOverlap(a.scopes, b.scopes)) continue;
|
||||||
|
if (a.combos.some((ca) => b.combos.some((cb) => sameCombo(ca, cb)))) {
|
||||||
|
link(a.id, b.id);
|
||||||
|
link(b.id, a.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keys whose modified forms edit text: ⌘⌫ deletes to line start, ⌥← jumps a word… */
|
||||||
|
const TEXT_EDITING = new Set(["Backspace", "Delete", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End"]);
|
||||||
|
|
||||||
|
/** Whether a combo may fire while focus is in a text field. Plain and
|
||||||
|
* Shift-only keys type there, and so do the text-editing chords (⌘⌫, ⌥←, ⌘A);
|
||||||
|
* other ⌘/Ctrl and Alt chords and F-keys fire — except Ctrl+Alt off macOS,
|
||||||
|
* which is AltGr. */
|
||||||
|
export function firesInTextField(c: Combo, isMac: boolean): boolean {
|
||||||
|
if (FKEY.test(c.key)) return true;
|
||||||
|
if (TEXT_EDITING.has(c.key) || (c.mod && c.key === "A")) return false;
|
||||||
|
if (!isMac && c.mod && c.alt) return false;
|
||||||
|
return c.mod || c.alt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a plain press of the combo types a character — bound in the file
|
||||||
|
* list, that key then no longer starts a type-to-filter query. */
|
||||||
|
export function typesCharacter(c: Combo): boolean {
|
||||||
|
return !c.mod && !c.ctrl && !c.alt && (LETTER.test(c.key) || DIGIT.test(c.key) || Object.hasOwn(CHAR_OF, c.key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Commands a held key may repeat; any other held shortcut fires once
|
||||||
|
* (holding ⌘W must not close every tab). */
|
||||||
|
const REPEATABLE = new Set<ShortcutId>([
|
||||||
|
"cursor-up",
|
||||||
|
"cursor-down",
|
||||||
|
"page-up",
|
||||||
|
"page-down",
|
||||||
|
"toggle-select",
|
||||||
|
"next-tab",
|
||||||
|
"prev-tab",
|
||||||
|
"move-tab-left",
|
||||||
|
"move-tab-right",
|
||||||
|
"scale-up",
|
||||||
|
"scale-down",
|
||||||
|
"find-next",
|
||||||
|
"find-prev",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isRepeatable(id: ShortcutId): boolean {
|
||||||
|
return REPEATABLE.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESERVED = {
|
||||||
|
mac: ["Mod+Q", "Mod+X", "Mod+C", "Mod+V", "Mod+Z", "Mod+Shift+Z", "Mod+H", "Mod+Alt+H", "Mod+M", "Mod+Backquote"],
|
||||||
|
other: ["Mod+X", "Mod+C", "Mod+V", "Mod+Z", "Mod+Y"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Quit, the clipboard / undo keys and macOS's window keys (⌘H ⌘M ⌘`) can't be rebound. */
|
||||||
|
export function isReserved(c: Combo, isMac: boolean): boolean {
|
||||||
|
return parseList(isMac ? RESERVED.mac : RESERVED.other, isMac).some((r) => sameCombo(r, c));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Key names for the non-macOS spelling ("Ctrl+Shift+Del"); i18n supplies
|
||||||
|
* localized ones (German keyboards say Strg and Entf). */
|
||||||
|
export interface KeyNames {
|
||||||
|
ctrl: string;
|
||||||
|
alt: string;
|
||||||
|
shift: string;
|
||||||
|
enter: string;
|
||||||
|
backspace: string;
|
||||||
|
del: string;
|
||||||
|
esc: string;
|
||||||
|
space: string;
|
||||||
|
tab: string;
|
||||||
|
pgup: string;
|
||||||
|
pgdn: string;
|
||||||
|
home: string;
|
||||||
|
end: string;
|
||||||
|
ins: string;
|
||||||
|
menu: string;
|
||||||
|
num: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EN_KEY_NAMES: KeyNames = {
|
||||||
|
ctrl: "Ctrl",
|
||||||
|
alt: "Alt",
|
||||||
|
shift: "Shift",
|
||||||
|
enter: "Enter",
|
||||||
|
backspace: "Backspace",
|
||||||
|
del: "Del",
|
||||||
|
esc: "Esc",
|
||||||
|
space: "Space",
|
||||||
|
tab: "Tab",
|
||||||
|
pgup: "PgUp",
|
||||||
|
pgdn: "PgDn",
|
||||||
|
home: "Home",
|
||||||
|
end: "End",
|
||||||
|
ins: "Ins",
|
||||||
|
menu: "Menu",
|
||||||
|
num: "Num",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ARROWS: Record<string, string> = { ArrowUp: "↑", ArrowDown: "↓", ArrowLeft: "←", ArrowRight: "→" };
|
||||||
|
const MAC_GLYPHS: Record<string, string> = { Enter: "↩", Escape: "⎋", Backspace: "⌫", Delete: "⌦", Tab: "⇥" };
|
||||||
|
|
||||||
|
function keyLabel(key: string, isMac: boolean, names: KeyNames): string {
|
||||||
|
if (Object.hasOwn(CHAR_OF, key)) return CHAR_OF[key];
|
||||||
|
if (Object.hasOwn(ARROWS, key)) return ARROWS[key];
|
||||||
|
if (isMac && Object.hasOwn(MAC_GLYPHS, key)) return MAC_GLYPHS[key];
|
||||||
|
const named: Record<string, string> = {
|
||||||
|
Enter: names.enter,
|
||||||
|
Escape: names.esc,
|
||||||
|
Backspace: names.backspace,
|
||||||
|
Delete: names.del,
|
||||||
|
Tab: names.tab,
|
||||||
|
Space: names.space,
|
||||||
|
Home: names.home,
|
||||||
|
End: names.end,
|
||||||
|
PageUp: names.pgup,
|
||||||
|
PageDown: names.pgdn,
|
||||||
|
Insert: names.ins,
|
||||||
|
ContextMenu: names.menu,
|
||||||
|
NumpadAdd: `${names.num} +`,
|
||||||
|
};
|
||||||
|
return Object.hasOwn(named, key) ? named[key] : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "⇧⌘N" on macOS (Apple's ⌃⌥⇧⌘ order), "Ctrl+Shift+N" elsewhere. */
|
||||||
|
export function formatCombo(c: Combo, isMac: boolean, names: KeyNames = EN_KEY_NAMES): string {
|
||||||
|
const key = keyLabel(c.key, isMac, names);
|
||||||
|
if (isMac) return `${c.ctrl ? "⌃" : ""}${c.alt ? "⌥" : ""}${c.shift ? "⇧" : ""}${c.mod ? "⌘" : ""}${key}`;
|
||||||
|
return [c.mod && names.ctrl, c.alt && names.alt, c.shift && names.shift, key].filter(Boolean).join("+");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACCELERATOR_KEYS: Record<string, string> = {
|
||||||
|
Enter: "return",
|
||||||
|
Escape: "escape",
|
||||||
|
Tab: "tab",
|
||||||
|
Space: "space",
|
||||||
|
ArrowUp: "up",
|
||||||
|
ArrowDown: "down",
|
||||||
|
ArrowLeft: "left",
|
||||||
|
ArrowRight: "right",
|
||||||
|
Home: "home",
|
||||||
|
End: "end",
|
||||||
|
PageUp: "pageup",
|
||||||
|
PageDown: "pagedown",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Electrobun's native menu accelerator ("Command+Shift+n"), or null when the
|
||||||
|
* menu can't express the combo faithfully:
|
||||||
|
* - only ⌘ / ⌃ chords — the native parser turns any combo without a modifier
|
||||||
|
* token into ⌘+key;
|
||||||
|
* - no ⌫ / ⌦ (its "backspace" and "delete" tokens map to characters AppKit
|
||||||
|
* matches unreliably), Insert, the Menu key or the keypad;
|
||||||
|
* - no Shift+punctuation — AppKit matches the shifted character ("}"), not "]".
|
||||||
|
*/
|
||||||
|
export function toAccelerator(c: Combo): string | null {
|
||||||
|
if (!c.mod && !c.ctrl) return null;
|
||||||
|
if (c.shift && Object.hasOwn(CHAR_OF, c.key)) return null;
|
||||||
|
let key: string | undefined;
|
||||||
|
if (LETTER.test(c.key)) key = c.key.toLowerCase();
|
||||||
|
else if (DIGIT.test(c.key)) key = c.key;
|
||||||
|
else if (FKEY.test(c.key)) key = c.key.toLowerCase();
|
||||||
|
else if (Object.hasOwn(CHAR_OF, c.key)) key = c.key === "Minus" ? "minus" : CHAR_OF[c.key];
|
||||||
|
else if (Object.hasOwn(ACCELERATOR_KEYS, c.key)) key = ACCELERATOR_KEYS[c.key];
|
||||||
|
if (!key) return null;
|
||||||
|
return [c.mod && "Command", c.ctrl && "Control", c.alt && "Alt", c.shift && "Shift", key]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("+");
|
||||||
|
}
|
||||||
+9
-40
@@ -26,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: {
|
||||||
@@ -128,7 +128,7 @@ export type AppRPC = {
|
|||||||
// local shell integration
|
// local shell integration
|
||||||
/**
|
/**
|
||||||
* Shell actions the context menu triggers on a LOCAL path. The menu
|
* Shell actions the context menu triggers on a LOCAL path. The menu
|
||||||
* itself is HTML (ui/src/app/context-menu.ts) — electrobun's native
|
* itself is HTML (ui/src/app/core/context-menu.service.ts) — electrobun's native
|
||||||
* context menu is a no-op on Linux — but these three still need the
|
* context menu is a no-op on Linux — but these three still need the
|
||||||
* bun side's Utils.
|
* bun side's Utils.
|
||||||
*/
|
*/
|
||||||
@@ -154,35 +154,11 @@ 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 };
|
||||||
// custom window chrome (win7 skin — the window is frameless, so the
|
// settings window
|
||||||
// webview's title bar owns the controls). Served by every window kind.
|
/** Main window: open (or focus) the single Settings window. */
|
||||||
windowMinimize: { params: Record<string, never>; response: Result };
|
openSettings: { params: Record<string, never>; response: Result };
|
||||||
/** Maximize/restore in one call; responds with the resulting state. */
|
/** Settings window only: its close-window key (Ctrl+W off macOS, ⌘W). */
|
||||||
windowToggleMaximize: {
|
settingsClose: { params: Record<string, never>; response: Result };
|
||||||
params: Record<string, never>;
|
|
||||||
response: Result<{ maximized: boolean }>;
|
|
||||||
};
|
|
||||||
windowClose: { params: Record<string, never>; response: Result };
|
|
||||||
/** First paint: the title bar needs the state before any resize fires. */
|
|
||||||
windowIsMaximized: {
|
|
||||||
params: Record<string, never>;
|
|
||||||
response: Result<{ maximized: boolean }>;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* STOPGAP — the two halves of the synthesized resize. A frameless GTK
|
|
||||||
* window has no resize borders and electrobun exposes no native resize
|
|
||||||
* gesture, so the webview drags its own handles and moves the frame
|
|
||||||
* itself. Delete both with the rest of the hack; see the removal
|
|
||||||
* checklist in ui/src/app/skins/7like/window-resize/window-resize.ts.
|
|
||||||
*/
|
|
||||||
windowGetFrame: {
|
|
||||||
params: Record<string, never>;
|
|
||||||
response: Result<{ x: number; y: number; width: number; height: number }>;
|
|
||||||
};
|
|
||||||
windowSetFrame: {
|
|
||||||
params: { x: number; y: number; width: number; height: number };
|
|
||||||
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: {
|
||||||
@@ -213,13 +189,6 @@ export type AppRPC = {
|
|||||||
editorCloseRequest: Record<string, never>;
|
editorCloseRequest: Record<string, never>;
|
||||||
/** bun → main: the modal connection manager opened/closed. */
|
/** bun → main: the modal connection manager opened/closed. */
|
||||||
managerState: { open: boolean };
|
managerState: { open: boolean };
|
||||||
/**
|
|
||||||
* bun → any window: maximize state changed, so the custom title bar can
|
|
||||||
* swap its maximize/restore glyph. electrobun has no maximize event —
|
|
||||||
* this is derived from the window's `resize` event (see windows.ts
|
|
||||||
* trackMaximizeState), which also covers OS-initiated Aero Snap.
|
|
||||||
*/
|
|
||||||
windowState: { maximized: boolean };
|
|
||||||
/** bun → main: the manager window changed the connection store. */
|
/** bun → main: the manager window changed the connection store. */
|
||||||
connectionsChanged: Record<string, never>;
|
connectionsChanged: Record<string, never>;
|
||||||
/**
|
/**
|
||||||
|
|||||||
+11
-30
@@ -65,45 +65,19 @@ 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;
|
||||||
/**
|
|
||||||
* Visual skin. "modern" is the shadcn-style look; "win7" swaps in 7.css
|
|
||||||
* (Windows 7 Aero) and switches the window to custom frameless chrome.
|
|
||||||
* Each skin is a whole component tree under ui/src/app/skins/ — "modern" is
|
|
||||||
* shadlike/, "win7" is 7like/ — selected at bootstrap in ui/src/main.ts.
|
|
||||||
* The value is NOT named after its folder on purpose: it is persisted in
|
|
||||||
* user settings, and "win7" is also the literal class 7.scoped.css is
|
|
||||||
* precompiled to require, so neither can be renamed freely.
|
|
||||||
* Changing this needs a window reopen to take effect on the frame itself —
|
|
||||||
* the CSS half applies live.
|
|
||||||
*/
|
|
||||||
skin: "modern" | "win7";
|
|
||||||
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";
|
||||||
/**
|
/**
|
||||||
* Interface scale factor, 0.5–4 in 0.1 steps (shown as 50%–400%). Works by
|
* 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
|
* setting the root font-size — every size in the UI is rem, so one lever
|
||||||
* scales BOTH skins whole. Replaced the 3-tier `textSize`
|
* scales the whole UI. Replaced the 3-tier `textSize`
|
||||||
* (compact|default|large); SettingsStore.load migrates old files.
|
* (compact|default|large); SettingsStore.load migrates old files.
|
||||||
*/
|
*/
|
||||||
uiScale: number;
|
uiScale: number;
|
||||||
@@ -117,6 +91,14 @@ export interface AppSettings {
|
|||||||
editor: "builtin" | "custom";
|
editor: "builtin" | "custom";
|
||||||
/** Full path to the external editor .app; empty = OS default application. */
|
/** Full path to the external editor .app; empty = OS default application. */
|
||||||
editorApp: string;
|
editorApp: string;
|
||||||
|
/**
|
||||||
|
* Keyboard remaps from Settings → Keyboard: command id → combo strings, only
|
||||||
|
* for rows that differ from the defaults ([] unbinds). Format and sanitizing
|
||||||
|
* live in keymap.ts — a hand-edited entry it can't parse is simply ignored.
|
||||||
|
*/
|
||||||
|
keybindings: Record<string, string[]>;
|
||||||
|
/** Enter / double-click on a LOCAL file opens it in its default app. */
|
||||||
|
openFilesOnEnter: boolean;
|
||||||
// Auto-refresh the active tab's panels (local watched, remote polled). Always
|
// Auto-refresh the active tab's panels (local watched, remote polled). Always
|
||||||
// on for now; uncomment (+ the settings.service and tab-bar breadcrumbs, and
|
// on for now; uncomment (+ the settings.service and tab-bar breadcrumbs, and
|
||||||
// gate the poll/watch in panels.service) to expose a toggle later.
|
// gate the poll/watch in panels.service) to expose a toggle later.
|
||||||
@@ -125,11 +107,8 @@ export interface AppSettings {
|
|||||||
|
|
||||||
export const DEFAULT_SETTINGS: AppSettings = {
|
export const DEFAULT_SETTINGS: AppSettings = {
|
||||||
locale: "system",
|
locale: "system",
|
||||||
skin: "modern",
|
|
||||||
theme: "system",
|
theme: "system",
|
||||||
darkTone: "dark",
|
darkTone: "dark",
|
||||||
accent: "blue",
|
|
||||||
iconColors: "multi",
|
|
||||||
columns: ["size", "mtime"],
|
columns: ["size", "mtime"],
|
||||||
speedUnit: "MBps",
|
speedUnit: "MBps",
|
||||||
uiScale: 1,
|
uiScale: 1,
|
||||||
@@ -138,6 +117,8 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
autoClearCompleted: true,
|
autoClearCompleted: true,
|
||||||
editor: "builtin",
|
editor: "builtin",
|
||||||
editorApp: "",
|
editorApp: "",
|
||||||
|
keybindings: {},
|
||||||
|
openFilesOnEnter: false,
|
||||||
// autoRefresh: true,
|
// autoRefresh: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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,292 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<!--
|
|
||||||
Skin harness — a static gallery rendered headlessly by scripts/shot.ts so the
|
|
||||||
Win7 skin can be LOOKED AT during development instead of inferred from a
|
|
||||||
clean build log. Not shipped: it lives outside ui/src, so the Angular build
|
|
||||||
never sees it.
|
|
||||||
|
|
||||||
Each section pairs 7.css's own documented markup ("reference") with what the
|
|
||||||
app currently renders ("ours"). Where those two disagree visually, 7.css is
|
|
||||||
not reaching our component — that mismatch is the whole point of the page.
|
|
||||||
|
|
||||||
Layout here uses INLINE STYLES on purpose: Tailwind v4 only emits utilities it
|
|
||||||
found while scanning ui/src, so utility classes used only here would not exist
|
|
||||||
in the built stylesheet. {{STYLES}} is replaced by scripts/shot.ts with the
|
|
||||||
real hashed build output.
|
|
||||||
-->
|
|
||||||
<html class="win7" lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<title>7.css harness</title>
|
|
||||||
<link rel="stylesheet" href="{{STYLES}}" />
|
|
||||||
<style>
|
|
||||||
body { margin: 0; padding: 20px; }
|
|
||||||
h2 { font-size: 13px; margin: 22px 0 6px; opacity: 0.75; }
|
|
||||||
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; }
|
|
||||||
.cell { min-width: 0; }
|
|
||||||
.cell > figcaption {
|
|
||||||
font-size: 11px; opacity: 0.6; margin-bottom: 5px; text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
.stage { padding: 14px; background: #6ba5d7; border-radius: 4px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h2>1 — Window frame, glass, drop shadow, active colour</h2>
|
|
||||||
<div class="pair">
|
|
||||||
<figure class="cell" style="margin:0">
|
|
||||||
<figcaption>reference — .window.glass.active</figcaption>
|
|
||||||
<div class="stage">
|
|
||||||
<div class="window glass active" style="width: 320px">
|
|
||||||
<div class="title-bar">
|
|
||||||
<div class="title-bar-text">miniSCP</div>
|
|
||||||
<div class="title-bar-controls">
|
|
||||||
<button aria-label="Minimize"></button>
|
|
||||||
<button aria-label="Maximize"></button>
|
|
||||||
<button aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="window-body"><p>Glass frame, 6px radius, drop shadow.</p></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</figure>
|
|
||||||
<figure class="cell" style="margin:0">
|
|
||||||
<figcaption>ours — app.html shell (active above, inactive below)</figcaption>
|
|
||||||
<div class="stage">
|
|
||||||
<div class="window glass active" style="width: 320px">
|
|
||||||
<div class="title-bar">
|
|
||||||
<div class="title-bar-text">miniSCP</div>
|
|
||||||
<div class="title-bar-controls">
|
|
||||||
<button aria-label="Minimize"></button>
|
|
||||||
<button aria-label="Maximize"></button>
|
|
||||||
<button aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="window-body"><p style="margin:0">Focused window.</p></div>
|
|
||||||
</div>
|
|
||||||
<div class="window glass" style="width: 320px; margin-top: 12px">
|
|
||||||
<div class="title-bar">
|
|
||||||
<div class="title-bar-text">miniSCP</div>
|
|
||||||
<div class="title-bar-controls">
|
|
||||||
<button aria-label="Minimize"></button>
|
|
||||||
<button aria-label="Maximize"></button>
|
|
||||||
<button aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="window-body"><p style="margin:0">Unfocused — chrome desaturates.</p></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</figure>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>2 — ListView (file panel)</h2>
|
|
||||||
<div class="pair">
|
|
||||||
<figure class="cell" style="margin:0">
|
|
||||||
<figcaption>reference — <table></figcaption>
|
|
||||||
<table class="has-shadow" style="width:100%">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th class="highlighted indicator">Size</th>
|
|
||||||
<th>Modified</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>..</td><td style="text-align:right">—</td><td>—</td></tr>
|
|
||||||
<tr><td>Documents</td><td style="text-align:right">—</td><td>3/29/2007 12:00 AM</td></tr>
|
|
||||||
<tr class="highlighted"><td>report.pdf</td><td style="text-align:right">27 KB</td><td>3/23/2007 12:00 AM</td></tr>
|
|
||||||
<tr><td>notes.txt</td><td style="text-align:right">4 KB</td><td>3/13/2007 12:00 AM</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</figure>
|
|
||||||
<figure class="cell" style="margin:0">
|
|
||||||
<figcaption>ours — focused pane (top) vs unfocused pane (bottom)</figcaption>
|
|
||||||
<!-- Focused pane: strong band on selected rows AND the cursor row;
|
|
||||||
the cursor row additionally gets the dotted focus rect. -->
|
|
||||||
<table class="is-active">
|
|
||||||
<colgroup>
|
|
||||||
<col />
|
|
||||||
<col style="width: 5.5rem" />
|
|
||||||
<col style="width: 8.5rem" />
|
|
||||||
</colgroup>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th class="highlighted indicator up" style="text-align:right">Size</th>
|
|
||||||
<th>Modified</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>..</td><td style="text-align:right">—</td><td>—</td></tr>
|
|
||||||
<tr class="highlighted"><td>Documents</td><td style="text-align:right">—</td><td>3/29/2007 12:00 AM</td></tr>
|
|
||||||
<tr class="highlighted"><td>report.pdf</td><td style="text-align:right">27 KB</td><td>3/23/2007 12:00 AM</td></tr>
|
|
||||||
<tr class="highlighted is-cursor"><td>notes.txt</td><td style="text-align:right">4 KB</td><td>3/13/2007 12:00 AM</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<!-- Unfocused pane: same rows, faint inactive wash, no dots. -->
|
|
||||||
<table style="margin-top:10px">
|
|
||||||
<colgroup>
|
|
||||||
<col />
|
|
||||||
<col style="width: 5.5rem" />
|
|
||||||
<col style="width: 8.5rem" />
|
|
||||||
</colgroup>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th style="text-align:right">Size</th>
|
|
||||||
<th>Modified</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>..</td><td style="text-align:right">—</td><td>—</td></tr>
|
|
||||||
<tr class="highlighted"><td>Documents</td><td style="text-align:right">—</td><td>3/29/2007 12:00 AM</td></tr>
|
|
||||||
<tr><td>report.pdf</td><td style="text-align:right">27 KB</td><td>3/23/2007 12:00 AM</td></tr>
|
|
||||||
<tr class="highlighted"><td>notes.txt</td><td style="text-align:right">4 KB</td><td>3/13/2007 12:00 AM</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</figure>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>2b — Selection geometry (rows must NOT shift when selected)</h2>
|
|
||||||
<div class="pair">
|
|
||||||
<figure class="cell" style="margin:0">
|
|
||||||
<figcaption>no selection</figcaption>
|
|
||||||
<table class="is-active">
|
|
||||||
<colgroup><col /><col style="width:6rem" /></colgroup>
|
|
||||||
<thead><tr><th>Name</th><th>Size</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>aaa.txt</td><td>1 KB</td></tr>
|
|
||||||
<tr><td>bbb.txt</td><td>2 KB</td></tr>
|
|
||||||
<tr><td>ccc.txt</td><td>3 KB</td></tr>
|
|
||||||
<tr><td>ddd.txt</td><td>4 KB</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</figure>
|
|
||||||
<figure class="cell" style="margin:0">
|
|
||||||
<figcaption>middle row selected — baselines must still line up</figcaption>
|
|
||||||
<table class="is-active">
|
|
||||||
<colgroup><col /><col style="width:6rem" /></colgroup>
|
|
||||||
<thead><tr><th>Name</th><th>Size</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>aaa.txt</td><td>1 KB</td></tr>
|
|
||||||
<tr class="highlighted"><td>bbb.txt</td><td>2 KB</td></tr>
|
|
||||||
<tr><td>ccc.txt</td><td>3 KB</td></tr>
|
|
||||||
<tr><td>ddd.txt</td><td>4 KB</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</figure>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>3 — Context menu</h2>
|
|
||||||
<div class="pair">
|
|
||||||
<figure class="cell" style="margin:0">
|
|
||||||
<figcaption>reference — ul[role=menu]</figcaption>
|
|
||||||
<ul role="menu" style="width:200px">
|
|
||||||
<li role="menuitem"><a href="#m">Send to Other Panel</a></li>
|
|
||||||
<li role="menuitem"><a href="#m">Open</a></li>
|
|
||||||
<li role="menuitem" class="has-divider"><a href="#m">Rename</a></li>
|
|
||||||
<li role="menuitem"><a href="#m">Copy Path</a></li>
|
|
||||||
<li role="menuitem" aria-disabled="true"><a href="#m">Permissions</a></li>
|
|
||||||
<li role="menuitem" class="has-divider"><a href="#m">Delete</a></li>
|
|
||||||
<li role="menuitem">
|
|
||||||
<input type="checkbox" id="h-col-size" checked />
|
|
||||||
<label for="h-col-size">Size</label>
|
|
||||||
</li>
|
|
||||||
<li role="menuitem">
|
|
||||||
<input type="checkbox" id="h-col-kind" />
|
|
||||||
<label for="h-col-kind">Kind</label>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</figure>
|
|
||||||
<figure class="cell" style="margin:0">
|
|
||||||
<figcaption>ours — context-menu.ts output (buttons, divider, accelerator, checkables)</figcaption>
|
|
||||||
<!-- w7-ctx is the app's marker class (flat highlight, no Aero afterglow).
|
|
||||||
It also carries a position:fixed restatement, which the app wants and a
|
|
||||||
review page in normal flow does not — hence the inline static. -->
|
|
||||||
<ul role="menu" class="w7-ctx" style="width:200px;position:static">
|
|
||||||
<li role="menuitem"><button type="button">Send to Other Panel</button></li>
|
|
||||||
<li role="menuitem"><button type="button">Open</button></li>
|
|
||||||
<li role="menuitem"><button type="button">Edit <span>F4</span></button></li>
|
|
||||||
<li role="menuitem" class="has-divider"><button type="button">Rename</button></li>
|
|
||||||
<li role="menuitem"><button type="button">Copy Path</button></li>
|
|
||||||
<li role="menuitem" class="has-divider"><button type="button">Delete</button></li>
|
|
||||||
<li role="menuitem">
|
|
||||||
<input type="checkbox" id="ours-col-size" checked tabindex="-1" />
|
|
||||||
<label for="ours-col-size">Size</label>
|
|
||||||
</li>
|
|
||||||
<li role="menuitem">
|
|
||||||
<input type="checkbox" id="ours-col-kind" tabindex="-1" />
|
|
||||||
<label for="ours-col-kind">Kind</label>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</figure>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>4 — Controls 7.css already owns (sanity check)</h2>
|
|
||||||
<div class="pair">
|
|
||||||
<figure class="cell" style="margin:0">
|
|
||||||
<figcaption>buttons / textbox / progress</figcaption>
|
|
||||||
<div style="display:flex;flex-direction:column;gap:10px">
|
|
||||||
<div style="display:flex;gap:6px"><button>OK</button><button>Cancel</button><button disabled>Disabled</button></div>
|
|
||||||
<input type="text" value="/home/supongo/code" style="width:100%" />
|
|
||||||
<div role="progressbar" style="width:100%"><div style="width:42%"></div></div>
|
|
||||||
<div role="progressbar" class="marquee" style="width:100%"></div>
|
|
||||||
</div>
|
|
||||||
</figure>
|
|
||||||
<figure class="cell" style="margin:0">
|
|
||||||
<figcaption>reference — menu[role=tablist]</figcaption>
|
|
||||||
<menu role="tablist">
|
|
||||||
<button role="tab" aria-selected="true">local</button>
|
|
||||||
<button role="tab">server-a</button>
|
|
||||||
<button role="tab">server-b</button>
|
|
||||||
</menu>
|
|
||||||
<div role="tabpanel" style="padding:10px">Tab body</div>
|
|
||||||
</figure>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>5 — Workspace tab strip (ours, as tab-bar.ts renders it)</h2>
|
|
||||||
<!--
|
|
||||||
The live app only ever has ONE workspace at startup and there is no way to
|
|
||||||
click "+" from a headless capture, so multi-tab state can only be checked
|
|
||||||
here. This mirrors tab-bar.ts exactly: the tabs sit in a menu[role=tablist]
|
|
||||||
inside a .w7-toolbar row, and the "+" / right-hand tools sit OUTSIDE the
|
|
||||||
menu so 7.css's descendant selector does not style them as tabs.
|
|
||||||
-->
|
|
||||||
<div class="pair">
|
|
||||||
<figure class="cell" style="margin:0">
|
|
||||||
<figcaption>ours — 3 workspaces, 2nd active</figcaption>
|
|
||||||
<div class="w7-toolbar relative flex h-9 items-center gap-0.5 border-b border-border bg-chrome pt-1 pr-1.5 pl-1.5">
|
|
||||||
<menu role="tablist" class="flex min-w-0 items-center overflow-x-auto">
|
|
||||||
<button role="tab" class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5">
|
|
||||||
<span class="truncate">Local</span>
|
|
||||||
</button>
|
|
||||||
<button role="tab" aria-selected="true" class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5">
|
|
||||||
<span class="truncate">build-server</span>
|
|
||||||
</button>
|
|
||||||
<button role="tab" class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5">
|
|
||||||
<span class="truncate">a-very-long-workspace-name-that-truncates</span>
|
|
||||||
</button>
|
|
||||||
</menu>
|
|
||||||
<button class="ml-0.5 grid size-6 place-items-center rounded-md">+</button>
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
<button class="grid h-6 px-2 py-1 place-items-center rounded-md text-[11px]">Connections</button>
|
|
||||||
</div>
|
|
||||||
<div style="height:60px;background:var(--w7-tab-bg,#fff);border:1px solid #8e8f8f;border-top:0"></div>
|
|
||||||
</figure>
|
|
||||||
<figure class="cell" style="margin:0">
|
|
||||||
<figcaption>ours — single workspace (the live startup state)</figcaption>
|
|
||||||
<div class="w7-toolbar relative flex h-9 items-center gap-0.5 border-b border-border bg-chrome pt-1 pr-1.5 pl-1.5">
|
|
||||||
<menu role="tablist" class="flex min-w-0 items-center overflow-x-auto">
|
|
||||||
<button role="tab" aria-selected="true" class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5">
|
|
||||||
<span class="truncate">Local</span>
|
|
||||||
</button>
|
|
||||||
</menu>
|
|
||||||
<button class="ml-0.5 grid size-6 place-items-center rounded-md">+</button>
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
<button class="grid h-6 px-2 py-1 place-items-center rounded-md text-[11px]">Connections</button>
|
|
||||||
</div>
|
|
||||||
<div style="height:60px;background:var(--w7-tab-bg,#fff);border:1px solid #8e8f8f;border-top:0"></div>
|
|
||||||
</figure>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
+1
-2
@@ -9,13 +9,12 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "bun@1.3.13",
|
"packageManager": "bun@1.3.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7.css": "^0.21.1",
|
|
||||||
"@angular/common": "^22.0.0",
|
"@angular/common": "^22.0.0",
|
||||||
"@angular/compiler": "^22.0.0",
|
"@angular/compiler": "^22.0.0",
|
||||||
"@angular/core": "^22.0.0",
|
"@angular/core": "^22.0.0",
|
||||||
"@angular/platform-browser": "^22.0.0",
|
"@angular/platform-browser": "^22.0.0",
|
||||||
"@ng-icons/core": "^34.0.0",
|
"@ng-icons/core": "^34.0.0",
|
||||||
"@ng-icons/mynaui": "^34.0.0",
|
"@ng-icons/tdesign-icons": "^34.0.0",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,30 +1,51 @@
|
|||||||
import { Injectable, inject, signal } from '@angular/core';
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
import type { ColumnKey, EntryType } from '@shared/types';
|
import type { ColumnKey, EntryType } from '@shared/types';
|
||||||
|
import type { FileCommand } from './file-actions.service';
|
||||||
|
import type { Icon14 } from './icons';
|
||||||
import { I18nService } from './i18n.service';
|
import { I18nService } from './i18n.service';
|
||||||
import { IS_MAC, IS_WINDOWS, MOD_LABEL } from './platform';
|
import { stepIndex } from './menu-nav';
|
||||||
|
import type { Side } from './panels.service';
|
||||||
|
import { IS_MAC, IS_WINDOWS } from './platform';
|
||||||
import { RpcService } from './rpc.service';
|
import { RpcService } from './rpc.service';
|
||||||
|
import type { ShortcutId } from './shortcuts.service';
|
||||||
|
|
||||||
/** Optional list columns, in display order (Name is fixed). */
|
/** Optional list columns, in display order (Name is fixed). */
|
||||||
const COLUMN_KEYS: ColumnKey[] = ['size', 'mtime', 'kind', 'rights', 'owner', 'group'];
|
export const COLUMN_KEYS: ColumnKey[] = ['size', 'mtime', 'kind', 'rights', 'owner', 'group'];
|
||||||
|
|
||||||
export interface MenuItem {
|
export interface MenuItem {
|
||||||
type?: 'separator';
|
/** separator: a rule; header: a non-pickable section title (the source picker). */
|
||||||
|
type?: 'separator' | 'header';
|
||||||
label?: string;
|
label?: string;
|
||||||
/** Emitted verbatim to the action dispatcher (`ctx-*` / `col:*` / `tab:*`). */
|
/** Handed to the request's onPick, else emitted to the action dispatcher
|
||||||
|
* (`col:*` / `tab:*`). */
|
||||||
action?: string;
|
action?: string;
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
/** Shortcut hint, rendered right-aligned. */
|
/** The keymap command the item runs: its first key shows as the soft hint,
|
||||||
accelerator?: string;
|
* and pressing that key while the menu is open picks the item. */
|
||||||
/** Greyed and unclickable. Win7 menus disable rather than hide, so the set
|
shortcut?: ShortcutId;
|
||||||
* of commands stays in the same place from one right-click to the next. */
|
/** A 14px icon (core/icons.ts), drawn in the check column. */
|
||||||
|
icon?: Icon14;
|
||||||
|
/** Greyed and unclickable. Menus disable rather than hide, so the set of
|
||||||
|
* commands stays in the same place from one right-click to the next. */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Anything with a viewport point: a MouseEvent, or a row's rect for a
|
||||||
|
* keyboard-opened menu. */
|
||||||
|
export interface MenuPoint {
|
||||||
|
clientX: number;
|
||||||
|
clientY: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MenuRequest {
|
export interface MenuRequest {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
items: MenuItem[];
|
items: MenuItem[];
|
||||||
|
/** Highlighted on open: the first item when the keyboard opened the menu. */
|
||||||
|
initialIndex: number;
|
||||||
|
/** Where a pick goes instead of the shared dispatcher (App.handleMenuAction). */
|
||||||
|
onPick?: (action: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TabMenuContext {
|
export interface TabMenuContext {
|
||||||
@@ -38,25 +59,27 @@ export interface TabMenuContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RowMenuContext {
|
export interface RowMenuContext {
|
||||||
path: string;
|
side: Side;
|
||||||
name: string;
|
name: string;
|
||||||
type: EntryType;
|
type: EntryType;
|
||||||
isLocal: boolean;
|
isLocal: boolean;
|
||||||
hasEntry: boolean; // false = right-click on empty panel space
|
hasEntry: boolean; // false = right-click on empty panel space
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Items the highlight can land on. */
|
||||||
|
export const isSelectable = (item: MenuItem): boolean => !item.type && !item.disabled;
|
||||||
|
|
||||||
// PORTABILITY: shell-integration wording per OS (resolved at build time for locale)
|
// PORTABILITY: shell-integration wording per OS (resolved at build time for locale)
|
||||||
const revealKey = (): string =>
|
const revealKey = (): string =>
|
||||||
IS_MAC ? 'ctx.revealFinder' : IS_WINDOWS ? 'ctx.revealExplorer' : 'ctx.revealFileManager';
|
IS_MAC ? 'ctx.revealFinder' : IS_WINDOWS ? 'ctx.revealExplorer' : 'ctx.revealFileManager';
|
||||||
const trashKey = (): string => (IS_WINDOWS ? 'ctx.moveToRecycleBin' : 'ctx.moveToTrash');
|
const trashKey = (): string => (IS_WINDOWS ? 'ctx.moveToRecycleBin' : 'ctx.moveToTrash');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds and opens the panel right-click menus. This used to be a native
|
* Builds and opens the HTML context menus. This used to be a native
|
||||||
* electrobun ContextMenu on the bun side, but `showContextMenu` is a documented
|
* electrobun ContextMenu on the bun side, but `showContextMenu` is a documented
|
||||||
* no-op on Linux (electrobun's own dist/api/bun/proc/linux.md), which left the
|
* no-op on Linux (electrobun's own .hutch/devkit/api/sdks/main/proc/linux.md),
|
||||||
* whole right-click surface dead there. The menu is plain HTML now — one code
|
* which left the whole right-click surface dead there. The menu is plain HTML
|
||||||
* path on every platform — and still emits the exact same action strings, so
|
* now — one code path on every platform.
|
||||||
* the dispatcher in app.ts is unchanged.
|
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ContextMenuService {
|
export class ContextMenuService {
|
||||||
@@ -65,76 +88,91 @@ export class ContextMenuService {
|
|||||||
|
|
||||||
readonly request = signal<MenuRequest | null>(null);
|
readonly request = signal<MenuRequest | null>(null);
|
||||||
|
|
||||||
open(event: MouseEvent, items: MenuItem[]): void {
|
open(
|
||||||
this.request.set({ x: event.clientX, y: event.clientY, items });
|
point: MenuPoint,
|
||||||
|
items: MenuItem[],
|
||||||
|
opts: { keyboard?: boolean; onPick?: (action: string) => void } = {},
|
||||||
|
): void {
|
||||||
|
const initialIndex = opts.keyboard ? stepIndex(items.map(isSelectable), -1, 1) : -1;
|
||||||
|
this.request.set({ x: point.clientX, y: point.clientY, items, initialIndex, onPick: opts.onPick });
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
this.request.set(null);
|
this.request.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A picked item goes through the same dispatcher the app menubar uses. */
|
/** A pick without its own handler goes through the dispatcher the app
|
||||||
onPick(action: string): void {
|
* menubar uses. */
|
||||||
|
emit(action: string): void {
|
||||||
this.rpc.emitMenuAction(action);
|
this.rpc.emitMenuAction(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Row / empty-space menu for a file panel. */
|
/**
|
||||||
|
* Row / empty-space menu for a file panel. Actions are FileCommand ids,
|
||||||
|
* picked through FileActionsService.run. Icons only on the everyday verbs —
|
||||||
|
* the same glyphs as their toolbar buttons.
|
||||||
|
*/
|
||||||
rowItems(ctx: RowMenuContext): MenuItem[] {
|
rowItems(ctx: RowMenuContext): MenuItem[] {
|
||||||
const t = (key: string) => this.i18n.t(key);
|
const t = (key: string) => this.i18n.t(key);
|
||||||
|
const cmd = (id: FileCommand, label: string, extra: Partial<MenuItem> = {}): MenuItem => ({
|
||||||
|
label,
|
||||||
|
action: id,
|
||||||
|
shortcut: id,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
const items: MenuItem[] = [];
|
const items: MenuItem[] = [];
|
||||||
|
|
||||||
if (!ctx.hasEntry || ctx.name === '..') {
|
if (!ctx.hasEntry || ctx.name === '..') {
|
||||||
// empty space (or the parent row): folder-level actions
|
// empty space (or the parent row): folder-level actions
|
||||||
items.push({ label: t('ctx.newFolder'), action: 'ctx-new-folder' });
|
items.push(cmd('new-folder', t('ctx.newFolder'), { icon: 'folderAdd14' }));
|
||||||
items.push({ label: t('ctx.newFile'), action: 'ctx-new-file' });
|
items.push(cmd('new-file', t('ctx.newFile')));
|
||||||
items.push({ type: 'separator' });
|
items.push({ type: 'separator' });
|
||||||
items.push({ label: t('ctx.refresh'), action: 'ctx-refresh' });
|
items.push(cmd('refresh', t('ctx.refresh')));
|
||||||
if (ctx.isLocal) {
|
if (ctx.isLocal) {
|
||||||
items.push({ type: 'separator' });
|
items.push({ type: 'separator' });
|
||||||
items.push({ label: t(revealKey()), action: 'ctx-reveal' });
|
items.push(cmd('reveal', t(revealKey())));
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
items.push({ label: t('ctx.copyToOther'), action: 'ctx-copy' });
|
// the arrow points at the other panel, as on the toolbar's Send button
|
||||||
|
const sendIcon = ctx.side === 'left' ? 'arrowRight14' : 'arrowLeft14';
|
||||||
|
items.push(cmd('send', t('ctx.copyToOther'), { icon: sendIcon }));
|
||||||
if (ctx.type === 'dir') {
|
if (ctx.type === 'dir') {
|
||||||
items.push({ label: t('ctx.open'), action: 'ctx-open' });
|
items.push(cmd('open', t('ctx.open')));
|
||||||
} else {
|
} else {
|
||||||
if (ctx.isLocal) {
|
if (ctx.isLocal) {
|
||||||
items.push({ label: t('ctx.open'), action: 'ctx-open-default', tooltip: t('ctx.openDefault') });
|
items.push(cmd('open-default', t('ctx.open'), { tooltip: t('ctx.openDefault') }));
|
||||||
}
|
}
|
||||||
items.push({ label: t('ctx.edit'), action: 'ctx-edit', accelerator: 'F4' });
|
items.push(cmd('edit', t('ctx.edit'), { icon: 'fileText14' }));
|
||||||
}
|
}
|
||||||
items.push({ label: t('ctx.rename'), action: 'ctx-rename' });
|
items.push(cmd('rename', t('ctx.rename'), { icon: 'edit14' }));
|
||||||
// chmod on Windows local files only flips the read-only bit — hide the lie
|
// chmod on Windows local files only flips the read-only bit — hide the lie
|
||||||
if (!(ctx.isLocal && IS_WINDOWS)) {
|
if (!(ctx.isLocal && IS_WINDOWS)) {
|
||||||
items.push({ label: t('ctx.permissions'), action: 'ctx-chmod' });
|
items.push(cmd('chmod', t('ctx.permissions')));
|
||||||
}
|
}
|
||||||
items.push({ type: 'separator' });
|
items.push({ type: 'separator' });
|
||||||
items.push({ label: t('ctx.copyPath'), action: 'ctx-copy-path' });
|
items.push(cmd('copy-path', t('ctx.copyPath')));
|
||||||
if (ctx.isLocal) {
|
if (ctx.isLocal) {
|
||||||
items.push({ label: t(revealKey()), action: 'ctx-reveal' });
|
items.push(cmd('reveal', t(revealKey())));
|
||||||
}
|
}
|
||||||
items.push({ type: 'separator' });
|
items.push({ type: 'separator' });
|
||||||
if (ctx.isLocal) {
|
if (ctx.isLocal) {
|
||||||
items.push({
|
items.push(cmd('trash', t(trashKey()), IS_MAC ? { tooltip: t('ctx.trashTooltip') } : {}));
|
||||||
label: t(trashKey()),
|
|
||||||
action: 'ctx-trash',
|
|
||||||
...(IS_MAC ? { tooltip: t('ctx.trashTooltip') } : {}),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
items.push({ label: t('ctx.delete'), action: 'ctx-delete' });
|
items.push(cmd('delete', t('ctx.delete'), { icon: 'delete14' }));
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The workspace-tab menu. Actions carry the target tab's id, because the
|
* 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
|
* right-clicked tab is not necessarily the active one — nothing here may
|
||||||
* assume `activeWorkspace`.
|
* assume `activeWorkspace`. (The hints name the keys, which act on the
|
||||||
|
* ACTIVE tab; picking by key while this menu is open acts on THIS one.)
|
||||||
*
|
*
|
||||||
* Everything illegal is DISABLED, not omitted: a menu whose items move
|
* Everything illegal is DISABLED, not omitted: a menu whose items move
|
||||||
* between right-clicks is a Win7 sin, and "Close Tab" greyed out explains
|
* between right-clicks is worse than a dead row, and "Close Tab" greyed out
|
||||||
* why the last tab cannot be closed better than its absence does.
|
* explains why the last tab cannot be closed better than its absence does.
|
||||||
*
|
*
|
||||||
* Duplicate/Rename/Save-session are deliberately absent until tabs have an
|
* Duplicate/Rename/Save-session are deliberately absent until tabs have an
|
||||||
* identity to duplicate (a stored connection id) — the sessions round.
|
* identity to duplicate (a stored connection id) — the sessions round.
|
||||||
@@ -143,24 +181,32 @@ export class ContextMenuService {
|
|||||||
const t = (key: string) => this.i18n.t(key);
|
const t = (key: string) => this.i18n.t(key);
|
||||||
const last = ctx.tabCount <= 1;
|
const last = ctx.tabCount <= 1;
|
||||||
return [
|
return [
|
||||||
{ label: t('ctx.tabNew'), action: 'tab:new', accelerator: `${MOD_LABEL}T` },
|
{ label: t('ctx.tabNew'), action: 'tab:new', shortcut: 'new-tab', icon: 'add14' },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
label: t('ctx.tabClose'),
|
label: t('ctx.tabClose'),
|
||||||
action: `tab:close:${ctx.id}`,
|
action: `tab:close:${ctx.id}`,
|
||||||
accelerator: `${MOD_LABEL}W`,
|
shortcut: 'close-tab',
|
||||||
|
icon: 'close14',
|
||||||
|
disabled: last,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('ctx.tabCloseOthers'),
|
||||||
|
action: `tab:close-others:${ctx.id}`,
|
||||||
|
shortcut: 'close-other-tabs',
|
||||||
disabled: last,
|
disabled: last,
|
||||||
},
|
},
|
||||||
{ label: t('ctx.tabCloseOthers'), action: `tab:close-others:${ctx.id}`, disabled: last },
|
|
||||||
{
|
{
|
||||||
label: t('ctx.tabCloseRight'),
|
label: t('ctx.tabCloseRight'),
|
||||||
action: `tab:close-right:${ctx.id}`,
|
action: `tab:close-right:${ctx.id}`,
|
||||||
|
shortcut: 'close-tabs-right',
|
||||||
disabled: ctx.index >= ctx.tabCount - 1,
|
disabled: ctx.index >= ctx.tabCount - 1,
|
||||||
},
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
label: t('ctx.tabDisconnect'),
|
label: t('ctx.tabDisconnect'),
|
||||||
action: `tab:disconnect:${ctx.id}`,
|
action: `tab:disconnect:${ctx.id}`,
|
||||||
|
shortcut: 'disconnect-tab',
|
||||||
disabled: !ctx.isRemote,
|
disabled: !ctx.isRemote,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -47,8 +47,10 @@ export class DialogsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Any dialog up — the overwrite question included. Reads signals, so an
|
||||||
|
* effect or computed that asks re-runs when a dialog opens or closes. */
|
||||||
get anyOpen(): boolean {
|
get anyOpen(): boolean {
|
||||||
return !!(this.prompt() || this.confirm() || this.chmod());
|
return !!(this.prompt() || this.confirm() || this.chmod() || this.overwrite());
|
||||||
}
|
}
|
||||||
|
|
||||||
closeAll(): void {
|
closeAll(): void {
|
||||||
|
|||||||
@@ -1,25 +1,50 @@
|
|||||||
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 { ContextMenuService, type MenuPoint } 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, type SortKey } from './panels.service';
|
||||||
import { IS_WINDOWS } from './platform';
|
import { IS_WINDOWS } from './platform';
|
||||||
import { RpcService } from './rpc.service';
|
import { RpcService } from './rpc.service';
|
||||||
import { SettingsService } from './settings.service';
|
import { SettingsService } from './settings.service';
|
||||||
|
import type { ShortcutId } from './shortcuts.service';
|
||||||
import { I18nService } from './i18n.service';
|
import { I18nService } from './i18n.service';
|
||||||
import { ToastService } from './toast.service';
|
import { ToastService } from './toast.service';
|
||||||
|
import { TransfersService } from './transfers.service';
|
||||||
|
|
||||||
/** The row (or empty space) the user last right-clicked; ctx-* actions apply here. */
|
/** What a file command acts on: a panel, and the row it was invoked on (the
|
||||||
export interface MenuTarget {
|
* cursor row from the keyboard, the right-clicked row from a menu; null =
|
||||||
|
* the folder itself). */
|
||||||
|
export interface FileTarget {
|
||||||
side: Side;
|
side: Side;
|
||||||
panel: PanelState;
|
panel: PanelState;
|
||||||
entry: FileEntry | null;
|
entry: FileEntry | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The keymap commands that act on files — everything the keyboard, the
|
||||||
|
* right-click menu, the macOS menu bar and the panel toolbar run, all through
|
||||||
|
* run() so the four entry points can't drift apart. */
|
||||||
|
export type FileCommand = Extract<
|
||||||
|
ShortcutId,
|
||||||
|
| 'open'
|
||||||
|
| 'open-default'
|
||||||
|
| 'send'
|
||||||
|
| 'edit'
|
||||||
|
| 'rename'
|
||||||
|
| 'new-folder'
|
||||||
|
| 'new-file'
|
||||||
|
| 'chmod'
|
||||||
|
| 'delete'
|
||||||
|
| 'trash'
|
||||||
|
| 'copy-path'
|
||||||
|
| 'reveal'
|
||||||
|
| 'refresh'
|
||||||
|
| `sort-${string}`
|
||||||
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* File operations invoked from a panel's toolbar / keyboard. They act on the
|
* File operations, always against an explicit panel: on its selection if there
|
||||||
* GIVEN panel: on the selection if there is one, else on the cursor row.
|
* is one, else on the target row.
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class FileActionsService {
|
export class FileActionsService {
|
||||||
@@ -29,29 +54,101 @@ export class FileActionsService {
|
|||||||
private readonly dialogs = inject(DialogsService);
|
private readonly dialogs = inject(DialogsService);
|
||||||
private readonly menu = inject(ContextMenuService);
|
private readonly menu = inject(ContextMenuService);
|
||||||
private readonly settings = inject(SettingsService);
|
private readonly settings = inject(SettingsService);
|
||||||
|
private readonly transfers = inject(TransfersService);
|
||||||
|
|
||||||
menuTarget: MenuTarget | null = null;
|
/** Run a file command. One switch for every entry point. */
|
||||||
|
run(id: FileCommand, target: FileTarget): void {
|
||||||
|
const { side, panel } = target;
|
||||||
|
const local = panel.sessionId() === 'local';
|
||||||
|
// the ".." row stands for the folder itself, as on the empty-space menu
|
||||||
|
const entry = target.entry && target.entry.name !== '..' ? target.entry : null;
|
||||||
|
switch (id) {
|
||||||
|
case 'open':
|
||||||
|
case 'open-default':
|
||||||
|
// folders, symlinks and ".." navigate; a LOCAL file opens in its default
|
||||||
|
// app — from the menu's Open always, from Enter / double-click only
|
||||||
|
// when Settings → File list says so
|
||||||
|
if (!target.entry) return;
|
||||||
|
if (target.entry.type !== 'file') panel.open(target.entry);
|
||||||
|
else if (local && (id === 'open-default' || this.settings.openFilesOnEnter())) {
|
||||||
|
this.shell('open', target.entry.path);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'send':
|
||||||
|
void this.transfers.copyToOtherPanel(side);
|
||||||
|
return;
|
||||||
|
case 'edit':
|
||||||
|
void this.edit(panel);
|
||||||
|
return;
|
||||||
|
case 'rename':
|
||||||
|
this.rename(panel);
|
||||||
|
return;
|
||||||
|
case 'new-folder':
|
||||||
|
this.mkdir(panel);
|
||||||
|
return;
|
||||||
|
case 'new-file':
|
||||||
|
this.createFile(panel);
|
||||||
|
return;
|
||||||
|
case 'chmod':
|
||||||
|
this.chmod(panel);
|
||||||
|
return;
|
||||||
|
case 'delete':
|
||||||
|
this.delete(panel);
|
||||||
|
return;
|
||||||
|
case 'trash':
|
||||||
|
if (local) void this.trash(panel);
|
||||||
|
return;
|
||||||
|
case 'copy-path':
|
||||||
|
this.shell('copyPath', entry?.path ?? panel.cwd());
|
||||||
|
return;
|
||||||
|
case 'reveal':
|
||||||
|
if (local) this.shell('reveal', entry?.path ?? panel.cwd());
|
||||||
|
return;
|
||||||
|
case 'refresh':
|
||||||
|
void panel.refresh();
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
// sort-name, sort-size…: again = reverse
|
||||||
|
if (id.startsWith('sort-')) panel.toggleSort(id.slice('sort-'.length) as SortKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Right-click entry point: remember the target, then open the menu on it. */
|
/** Open the row / empty-space menu on `target`; its picks run here. */
|
||||||
openContextMenu(event: MouseEvent, target: MenuTarget): void {
|
openContextMenu(point: MenuPoint, target: FileTarget, keyboard = false): void {
|
||||||
this.menuTarget = target;
|
const { side, panel, entry } = target;
|
||||||
const { panel, entry } = target;
|
|
||||||
this.menu.open(
|
this.menu.open(
|
||||||
event,
|
point,
|
||||||
this.menu.rowItems({
|
this.menu.rowItems({
|
||||||
path: entry?.path ?? panel.cwd(),
|
side,
|
||||||
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,
|
||||||
}),
|
}),
|
||||||
|
{ keyboard, onPick: (action) => this.run(action as FileCommand, target) },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async trash(panel: PanelState, entry: FileEntry): Promise<void> {
|
/** Local shell integration — only bun can reach the OS shell / clipboard. */
|
||||||
const res = await this.rpc.request.moveToTrash({ path: entry.path });
|
private shell(action: 'open' | 'reveal' | 'copyPath', path: string): void {
|
||||||
if (!res.ok) this.toast.error(res.error);
|
void this.rpc.request.shellItemAction({ action, path });
|
||||||
else this.toast.info(this.i18n.t('toasts.movedToTrash', { name: entry.name }));
|
}
|
||||||
|
|
||||||
|
/** Move the selection (else the target row) to the Trash, one item at a time. */
|
||||||
|
async trash(panel: PanelState): Promise<void> {
|
||||||
|
const targets = this.targets(panel);
|
||||||
|
if (!targets.length) return;
|
||||||
|
let moved = 0;
|
||||||
|
for (const entry of targets) {
|
||||||
|
const res = await this.rpc.request.moveToTrash({ path: entry.path });
|
||||||
|
if (!res.ok) {
|
||||||
|
this.toast.error(res.error);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
moved++;
|
||||||
|
}
|
||||||
|
if (moved === 1) this.toast.info(this.i18n.t('toasts.movedToTrash', { name: targets[0].name }));
|
||||||
|
else if (moved > 1) this.toast.info(this.i18n.t('toasts.movedManyToTrash', { count: moved }));
|
||||||
await panel.refresh();
|
await panel.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,40 @@
|
|||||||
// File-list glyphs + per-category colors ("Multi" icon mode). Category colors
|
// File-list glyphs + per-category colors. Category colors
|
||||||
// are fixed tokens (never follow the accent) so a folder is always folder-blue.
|
// are fixed tokens (never follow the accent) so a folder is always folder-blue.
|
||||||
// Only the icon glyph is colored; row text stays neutral. Docs and unknown
|
// Only the icon glyph is colored; row text stays neutral. Docs and unknown
|
||||||
// files stay muted on purpose — sparing color is what keeps it scannable.
|
// files stay muted on purpose — sparing color is what keeps it scannable.
|
||||||
import type { FileEntry } from '@shared/types';
|
import type { FileEntry } from '@shared/types';
|
||||||
|
import type { Icon16 } from './icons';
|
||||||
|
|
||||||
type Category = 'image' | 'audio' | 'video' | 'archive' | 'code' | 'doc';
|
type Category = 'image' | 'audio' | 'video' | 'archive' | 'code' | 'doc';
|
||||||
|
|
||||||
// Class strings are literals so the Tailwind scanner picks them up.
|
// Class strings are literals so the Tailwind scanner picks them up.
|
||||||
const MUTED = 'shrink-0 text-muted-foreground';
|
const MUTED = 'shrink-0 text-muted-foreground';
|
||||||
const PRIMARY = 'shrink-0 text-primary';
|
|
||||||
const FOLDER = 'shrink-0 text-icon-folder';
|
const FOLDER = 'shrink-0 text-icon-folder';
|
||||||
const LINK = 'shrink-0 text-icon-link';
|
const LINK = 'shrink-0 text-icon-link';
|
||||||
|
|
||||||
const CATEGORIES: Record<Category, { glyph: string; class: string; extensions: string[] }> = {
|
const CATEGORIES: Record<Category, { glyph: Icon16; class: string; extensions: string[] }> = {
|
||||||
image: {
|
image: {
|
||||||
glyph: 'mynaImage',
|
glyph: 'image16',
|
||||||
class: 'shrink-0 text-icon-image',
|
class: 'shrink-0 text-icon-image',
|
||||||
extensions: 'png jpg jpeg gif webp avif svg ico icns bmp tiff tif heic heif raw'.split(' '),
|
extensions: 'png jpg jpeg gif webp avif svg ico icns bmp tiff tif heic heif raw'.split(' '),
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
glyph: 'mynaMusic',
|
glyph: 'music16',
|
||||||
class: 'shrink-0 text-icon-media',
|
class: 'shrink-0 text-icon-media',
|
||||||
extensions: 'mp3 wav flac aac ogg oga m4a opus wma aiff aif mid midi'.split(' '),
|
extensions: 'mp3 wav flac aac ogg oga m4a opus wma aiff aif mid midi'.split(' '),
|
||||||
},
|
},
|
||||||
video: {
|
video: {
|
||||||
glyph: 'mynaVideo',
|
glyph: 'video16',
|
||||||
class: 'shrink-0 text-icon-media',
|
class: 'shrink-0 text-icon-media',
|
||||||
extensions: 'mp4 mkv mov avi webm m4v mpg mpeg wmv flv 3gp'.split(' '),
|
extensions: 'mp4 mkv mov avi webm m4v mpg mpeg wmv flv 3gp'.split(' '),
|
||||||
},
|
},
|
||||||
archive: {
|
archive: {
|
||||||
glyph: 'mynaArchive',
|
glyph: 'fileZip16',
|
||||||
class: 'shrink-0 text-icon-archive',
|
class: 'shrink-0 text-icon-archive',
|
||||||
extensions: 'zip tar gz tgz bz2 xz zst 7z rar dmg iso jar deb rpm pkg apk'.split(' '),
|
extensions: 'zip tar gz tgz bz2 xz zst 7z rar dmg iso jar deb rpm pkg apk'.split(' '),
|
||||||
},
|
},
|
||||||
code: {
|
code: {
|
||||||
glyph: 'mynaCode',
|
glyph: 'code16',
|
||||||
class: 'shrink-0 text-icon-code',
|
class: 'shrink-0 text-icon-code',
|
||||||
// ".ts" is TypeScript here, not an MPEG transport stream (deliberate)
|
// ".ts" is TypeScript here, not an MPEG transport stream (deliberate)
|
||||||
extensions: (
|
extensions: (
|
||||||
@@ -46,7 +46,7 @@ const CATEGORIES: Record<Category, { glyph: string; class: string; extensions: s
|
|||||||
).split(' '),
|
).split(' '),
|
||||||
},
|
},
|
||||||
doc: {
|
doc: {
|
||||||
glyph: 'mynaFileText',
|
glyph: 'fileText16',
|
||||||
class: MUTED, // docs keep the neutral color; only the glyph changes
|
class: MUTED, // docs keep the neutral color; only the glyph changes
|
||||||
extensions: 'md markdown txt rtf pdf doc docx xls xlsx ppt pptx csv tsv log odt ods odp epub'.split(' '),
|
extensions: 'md markdown txt rtf pdf doc docx xls xlsx ppt pptx csv tsv log odt ods odp epub'.split(' '),
|
||||||
},
|
},
|
||||||
@@ -85,18 +85,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): Icon16 {
|
||||||
if (entry.name === '..') return 'mynaArrowUp';
|
if (entry.name === '..') return 'arrowUp16';
|
||||||
if (entry.type === 'dir') return 'mynaFolder';
|
if (entry.type === 'dir') return 'folder16';
|
||||||
if (!multi) return 'mynaFile';
|
if (entry.type === 'symlink') return 'link16';
|
||||||
if (entry.type === 'symlink') return 'mynaLink';
|
|
||||||
const category = categoryOf(entry.name);
|
const category = categoryOf(entry.name);
|
||||||
return category ? CATEGORIES[category].glyph : 'mynaFile';
|
return category ? CATEGORIES[category].glyph : 'file16';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fileIconClass(entry: FileEntry, multi: boolean): string {
|
export function fileIconClass(entry: FileEntry): string {
|
||||||
// plain mode: exactly the pre-color look ('..' has type 'dir' -> primary)
|
|
||||||
if (!multi) return entry.type === 'dir' ? PRIMARY : MUTED;
|
|
||||||
if (entry.name === '..') return MUTED;
|
if (entry.name === '..') return MUTED;
|
||||||
if (entry.type === 'dir') return FOLDER;
|
if (entry.type === 'dir') return FOLDER;
|
||||||
if (entry.type === 'symlink') return LINK;
|
if (entry.type === 'symlink') return LINK;
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/** What Tab can land on. Tab order is DOM order: the app sets no positive tabindex. */
|
||||||
|
const FOCUSABLE = 'button, input, select, textarea, [tabindex]';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tab / Shift+Tab through the focusable controls under `root`, in DOM order,
|
||||||
|
* wrapping. Done by hand because WKWebView (macOS) skips buttons, checkboxes
|
||||||
|
* and radios on Tab unless the user turned on System Settings → Keyboard →
|
||||||
|
* Keyboard navigation, which left dialogs, Settings and the connection manager
|
||||||
|
* mouse-only there. It is also the focus trap the modal dialogs lacked. A radio
|
||||||
|
* group is one stop (its checked radio), as natively; arrows move inside it.
|
||||||
|
* Returns true when it took the key.
|
||||||
|
*/
|
||||||
|
export function cycleFocus(root: Element, event: KeyboardEvent): boolean {
|
||||||
|
if (event.key !== 'Tab' || event.metaKey || event.ctrlKey || event.altKey) return false;
|
||||||
|
const stops = [...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(isTabStop);
|
||||||
|
if (!stops.length) return false;
|
||||||
|
event.preventDefault();
|
||||||
|
const at = stops.indexOf(document.activeElement as HTMLElement);
|
||||||
|
const step = event.shiftKey ? -1 : 1;
|
||||||
|
const next = at < 0 ? (step > 0 ? 0 : stops.length - 1) : (at + step + stops.length) % stops.length;
|
||||||
|
stops[next].focus();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTabStop(el: HTMLElement): boolean {
|
||||||
|
if (el.tabIndex < 0 || el.matches(':disabled')) return false;
|
||||||
|
if (!el.getClientRects().length) return false; // display:none, or inside a hidden parent
|
||||||
|
if (el instanceof HTMLInputElement && el.type === 'radio' && el.name) {
|
||||||
|
const group = [
|
||||||
|
...document.querySelectorAll<HTMLInputElement>(`input[type=radio][name="${CSS.escape(el.name)}"]`),
|
||||||
|
];
|
||||||
|
const checked = group.find((radio) => radio.checked);
|
||||||
|
return el === (checked ?? group[0]);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { expect, test } from 'bun:test';
|
||||||
|
import { APP_ICONS } from './icons';
|
||||||
|
import { PIXEL_ICONS } from './pixel-icons';
|
||||||
|
|
||||||
|
// The px grid of each size token (styles.css); lg only carries the spinner.
|
||||||
|
const GRID: Record<string, string> = { sm: '14', md: '16', lg: '20', xl: '24' };
|
||||||
|
const SKINS = `${import.meta.dir}/../skins`;
|
||||||
|
|
||||||
|
// Templates can't type-check icon names, so read them: every <ng-icon> has a
|
||||||
|
// size token, and every literal name (both sides of a [name] ternary too) is
|
||||||
|
// registered and drawn for that token's grid. Names computed in TS are typed.
|
||||||
|
test('every template icon is registered and drawn for the size it renders at', async () => {
|
||||||
|
const problems: string[] = [];
|
||||||
|
for (const file of new Bun.Glob('**/*.{html,ts}').scanSync(SKINS)) {
|
||||||
|
const src = (await Bun.file(`${SKINS}/${file}`).text()).replace(/<!--[\s\S]*?-->/g, '');
|
||||||
|
for (const [tag] of src.matchAll(/<ng-icon\b[^>]*>/g)) {
|
||||||
|
const token = tag.match(/size="var\(--icon-size-(\w+)\)"/)?.[1];
|
||||||
|
if (!token || !GRID[token]) {
|
||||||
|
problems.push(`${file}: no size token in ${tag}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const bound = tag.match(/\[name\]="([^"]*)"/)?.[1] ?? '';
|
||||||
|
// a ternary's results, not its condition ('local' in `id === 'local' ? …`)
|
||||||
|
const names = [...tag.matchAll(/\bname="(\w+)"/g), ...bound.matchAll(/[?:]\s*'(\w+)'/g)].map((m) => m[1]);
|
||||||
|
for (const name of names) {
|
||||||
|
if (!Object.hasOwn(APP_ICONS, name)) problems.push(`${file}: ${name} is not registered`);
|
||||||
|
else if (name !== 'tdesignLoading' && !name.endsWith(GRID[token])) {
|
||||||
|
problems.push(`${file}: ${name} renders at ${token} (${GRID[token]}px)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(problems).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A grid name's glyph is drawn on that grid (a TDesign glyph registered as
|
||||||
|
// foo14 would be a 24-unit viewBox), and the redrawn ones use plain attributes
|
||||||
|
// only: TDesign's stroke-width var or any style would bypass the grid.
|
||||||
|
test('every glyph is drawn on the grid its name says', () => {
|
||||||
|
const problems: string[] = [];
|
||||||
|
for (const [name, svg] of Object.entries(APP_ICONS)) {
|
||||||
|
const grid = name.match(/(\d+)$/)?.[1];
|
||||||
|
if (grid && !svg.includes(`viewBox="0 0 ${grid} ${grid}"`)) problems.push(`${name}: not drawn on a ${grid}px grid`);
|
||||||
|
}
|
||||||
|
for (const [name, svg] of Object.entries(PIXEL_ICONS)) {
|
||||||
|
if (/var\(|style=/.test(svg)) problems.push(`${name}: uses a CSS var or a style`);
|
||||||
|
}
|
||||||
|
expect(problems).toEqual([]);
|
||||||
|
});
|
||||||
+15
-72
@@ -1,76 +1,19 @@
|
|||||||
// Single icon registry for the whole app, registered once in app.config.ts.
|
// 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
|
// A name is <icon><px>: the pixel grid the glyph is drawn for, which must match
|
||||||
// (or /solid, where names get a `Solid` suffix).
|
// the size token where it renders (pixel-icons.ts). Templates can't type-check
|
||||||
import {
|
// names and an unregistered one renders nothing (a console warning only), so
|
||||||
mynaArchive,
|
// icons.spec.ts checks every template name; TS code takes the Icon14/Icon16 types.
|
||||||
mynaArrowLeft,
|
import { tdesignCloseCircle, tdesignLoading } from "@ng-icons/tdesign-icons";
|
||||||
mynaArrowLeftRight,
|
import { PIXEL_ICONS } from "./pixel-icons";
|
||||||
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 = {
|
export const APP_ICONS = {
|
||||||
mynaArchive,
|
...PIXEL_ICONS,
|
||||||
mynaArrowLeft,
|
// TDesign's own 24px grid: as sharp as a circle and its diagonals can be
|
||||||
mynaArrowLeftRight,
|
closeCircle24: tdesignCloseCircle,
|
||||||
mynaArrowRight,
|
// spins, so no pixel grid holds; any size
|
||||||
mynaArrowUp,
|
tdesignLoading,
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type IconName = keyof typeof APP_ICONS;
|
||||||
|
export type Icon14 = Extract<IconName, `${string}14`>;
|
||||||
|
export type Icon16 = Extract<IconName, `${string}16`>;
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { expect, test } from 'bun:test';
|
||||||
|
import { stepIndex } from './menu-nav';
|
||||||
|
|
||||||
|
// item, separator, item, disabled, item — the shape of a real row menu
|
||||||
|
const menu = [true, false, true, false, true];
|
||||||
|
|
||||||
|
test('a fresh menu: down lands on the first entry, up on the last', () => {
|
||||||
|
expect(stepIndex(menu, -1, 1)).toBe(0);
|
||||||
|
expect(stepIndex(menu, -1, -1)).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('separators and disabled entries are skipped', () => {
|
||||||
|
expect(stepIndex(menu, 0, 1)).toBe(2);
|
||||||
|
expect(stepIndex(menu, 2, 1)).toBe(4);
|
||||||
|
expect(stepIndex(menu, 4, -1)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wraps both ways', () => {
|
||||||
|
expect(stepIndex(menu, 4, 1)).toBe(0);
|
||||||
|
expect(stepIndex(menu, 0, -1)).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nothing selectable leaves the highlight alone', () => {
|
||||||
|
expect(stepIndex([false, false], -1, 1)).toBe(-1);
|
||||||
|
expect(stepIndex([false, false], -1, -1)).toBe(-1);
|
||||||
|
expect(stepIndex([], -1, -1)).toBe(-1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Next highlighted menu index: skips unselectable entries and wraps. From -1
|
||||||
|
* (nothing highlighted yet), down lands on the first selectable entry and up
|
||||||
|
* on the LAST — stepping up from -1 directly would land one short of it.
|
||||||
|
* Returns `from` unchanged when nothing is selectable.
|
||||||
|
*/
|
||||||
|
export function stepIndex(selectable: boolean[], from: number, delta: 1 | -1): number {
|
||||||
|
const count = selectable.length;
|
||||||
|
let index = from < 0 && delta < 0 ? 0 : from;
|
||||||
|
for (let hops = 0; hops < count; hops++) {
|
||||||
|
index = (index + delta + count) % count;
|
||||||
|
if (selectable[index]) return index;
|
||||||
|
}
|
||||||
|
return from;
|
||||||
|
}
|
||||||
@@ -352,6 +352,19 @@ export class PanelsService {
|
|||||||
this.focus.bump();
|
this.focus.bump();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Each file list's key handler. A key that lands on <body> — a mouse click
|
||||||
|
* dropped focus (WebKit doesn't focus clicked buttons) — still reaches the
|
||||||
|
* active list through forwardListKey instead of dying until the next click. */
|
||||||
|
private readonly listKeys = new Map<Side, (event: KeyboardEvent) => void>();
|
||||||
|
|
||||||
|
registerListKeys(side: Side, handler: (event: KeyboardEvent) => void): void {
|
||||||
|
this.listKeys.set(side, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
forwardListKey(event: KeyboardEvent): void {
|
||||||
|
this.listKeys.get(this.activeSide())?.(event);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- workspace (tab) management ----
|
// ---- workspace (tab) management ----
|
||||||
|
|
||||||
newWorkspace(): void {
|
newWorkspace(): void {
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Icons drawn on the pixel grid they render at. A TDesign glyph is a 24-unit
|
||||||
|
// SVG with 2-unit strokes on whole units, so at 14 or 16px every straight line
|
||||||
|
// lands between pixels and smears over two at half ink on a 1x screen. Each
|
||||||
|
// glyph here is drawn on its own grid (viewBox 0 0 N N for an N px icon, one
|
||||||
|
// unit = one CSS pixel at 100% scale) with 1px strokes, so straight lines fill
|
||||||
|
// whole pixels at 1x and 2x. The name's suffix is that grid and must match the
|
||||||
|
// size token where it renders: 14 = --icon-size-sm, 16 = -md, 24 = -xl
|
||||||
|
// (icons.spec.ts checks). Drawing rules: docs/gotchas.md.
|
||||||
|
|
||||||
|
/** One glyph on an n-px grid: 1px strokes in the text colour. */
|
||||||
|
const svg = (n: number, body: string) =>
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${n} ${n}" fill="none" stroke="currentColor">${body}</svg>`;
|
||||||
|
|
||||||
|
// Straight lines are runs of whole pixels, in pixel coordinates (inclusive),
|
||||||
|
// and end exactly on pixel edges. Nothing relies on caps or joins: WebKit
|
||||||
|
// drops square caps on a path of several subpaths, and Skia (WebView2,
|
||||||
|
// WebKitGTK) strokes 1px lines as hairlines that leave a joined corner pixel
|
||||||
|
// three-quarters inked.
|
||||||
|
/** Pixels x0..x1 of row y. */
|
||||||
|
const h = (y: number, x0: number, x1: number) => `M${x0} ${y + 0.5}H${x1 + 1}`;
|
||||||
|
/** Pixels y0..y1 of column x. */
|
||||||
|
const v = (x: number, y0: number, y1: number) => `M${x + 0.5} ${y0}V${y1 + 1}`;
|
||||||
|
/** The outline of the box from pixel (x0, y0) to (x1, y1). */
|
||||||
|
const box = (x0: number, y0: number, x1: number, y1: number) => h(y0, x0, x1) + h(y1, x0, x1) + v(x0, y0, y1) + v(x1, y0, y1);
|
||||||
|
/** Filled shapes in the text colour. */
|
||||||
|
const fill = (...d: string[]) => `<path fill="currentColor" stroke="none" d="${d.join('')}"/>`;
|
||||||
|
/** Filled pixels (indicator dots, zipper teeth), size × size each. */
|
||||||
|
const dots = (at: [number, number][], size = 1) => fill(...at.map(([x, y]) => `M${x} ${y}h${size}v${size}h-${size}z`));
|
||||||
|
const path = (...d: string[]) => `<path d="${d.join('')}"/>`;
|
||||||
|
/** Slanted and curved strokes: anti-aliased wherever they sit, so 1.25px to read
|
||||||
|
* as heavy as the crisp 1px runs. Their ends and vertices sit on pixel centres. */
|
||||||
|
const slant = (...d: string[]) => `<path stroke-width="1.25" d="${d.join('')}"/>`;
|
||||||
|
/** A circle as path data, for slant() or fill(). A whole-pixel centre with a half-pixel
|
||||||
|
* radius puts its top, bottom and sides on pixel centres. */
|
||||||
|
const circle = (cx: number, cy: number, r: number) => `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 ${-2 * r} 0`;
|
||||||
|
|
||||||
|
/** A page with its top-right corner folded: the outline, the fold and the cut. */
|
||||||
|
const FILE14 = path(h(1, 2, 9), v(2, 1, 12), h(12, 2, 11), v(11, 3, 12), v(8, 1, 4), h(4, 8, 11)) + slant('M9.5 1.5l2 2');
|
||||||
|
const FILE16 = path(h(1, 2, 10), v(2, 1, 14), h(14, 2, 13), v(13, 4, 14), v(9, 1, 5), h(5, 9, 13)) + slant('M10.5 1.5l3 3');
|
||||||
|
|
||||||
|
export const PIXEL_ICONS = {
|
||||||
|
add14: svg(14, path(v(7, 3, 11), h(7, 3, 11))),
|
||||||
|
|
||||||
|
// ---- boxes ----
|
||||||
|
server14: svg(14, path(box(1, 1, 12, 5), box(1, 8, 12, 12)) + dots([[3, 3], [3, 10]])),
|
||||||
|
server16: svg(16, path(box(1, 1, 14, 6), box(1, 9, 14, 14)) + dots([[4, 3], [4, 11]])),
|
||||||
|
// TDesign's own geometry, already on the 24px grid; only the dots move onto whole pixels
|
||||||
|
server24: svg(24, '<g stroke-width="2"><rect x="3" y="3" width="18" height="7"/><rect x="3" y="14" width="18" height="7"/></g>' + dots([[5, 5], [5, 16]], 2)),
|
||||||
|
hardDrive14: svg(14, path(box(1, 1, 12, 12), h(8, 1, 12)) + dots([[3, 10], [5, 10]])),
|
||||||
|
file14: svg(14, FILE14),
|
||||||
|
file16: svg(16, FILE16),
|
||||||
|
fileText14: svg(14, FILE14 + path(h(7, 4, 9), h(9, 4, 9))),
|
||||||
|
fileText16: svg(16, FILE16 + path(h(8, 5, 10), h(11, 5, 10))),
|
||||||
|
fileAdd14: svg(14, path(h(1, 2, 9), v(2, 1, 12), h(12, 2, 6), v(11, 3, 6), v(8, 1, 4), h(4, 8, 11), v(11, 9, 13), h(11, 9, 13)) + slant('M9.5 1.5l2 2')),
|
||||||
|
fileZip16: svg(16, FILE16 + dots([[6, 2], [5, 3], [6, 4], [5, 5], [6, 6], [5, 7], [6, 8], [5, 9]])),
|
||||||
|
folder14: svg(14, path(h(2, 1, 5), h(4, 7, 12), v(1, 2, 11), v(12, 4, 11), h(11, 1, 12)) + slant('M5.5 2.5l2 2')),
|
||||||
|
folder16: svg(16, path(h(2, 1, 6), h(4, 8, 14), v(1, 2, 13), v(14, 4, 13), h(13, 1, 14)) + slant('M6.5 2.5l2 2')),
|
||||||
|
folderAdd14: svg(14, path(h(2, 1, 5), h(4, 7, 12), v(12, 4, 6), v(1, 2, 11), h(11, 1, 7), v(11, 8, 12), h(10, 9, 13)) + slant('M5.5 2.5l2 2')),
|
||||||
|
delete14: svg(14, path(h(3, 2, 12), v(3, 3, 12), h(12, 3, 11), v(11, 3, 12), v(5, 1, 3), h(1, 5, 9), v(9, 1, 3), v(7, 5, 10))),
|
||||||
|
lockOn14: svg(14, path(box(2, 6, 11, 12), v(4, 4, 5), v(9, 4, 5), h(9, 5, 8)) + slant('M4.5 4a2.5 2.5 0 0 1 5 0')),
|
||||||
|
video16: svg(16, path(box(1, 1, 14, 14), v(6, 4, 11)) + slant('M6.5 4.5L12 8L6.5 11.5')),
|
||||||
|
|
||||||
|
// ---- 45° ----
|
||||||
|
chevronDown14: svg(14, slant('M3.5 5.5l3 3 3-3')),
|
||||||
|
chevronUp14: svg(14, slant('M3.5 8.5l3-3 3 3')),
|
||||||
|
chevronRight14: svg(14, slant('M5.5 3.5l3 3-3 3')),
|
||||||
|
arrowRight14: svg(14, path(h(7, 2, 10)) + slant('M7.5 4.5l3 3-3 3')),
|
||||||
|
arrowLeft14: svg(14, path(h(7, 3, 11)) + slant('M6.5 4.5l-3 3 3 3')),
|
||||||
|
arrowUp16: svg(16, path(v(7, 3, 13)) + slant('M3.5 6.5l4-4 4 4')),
|
||||||
|
close14: svg(14, slant('M4 4l6 6M10 4l-6 6')),
|
||||||
|
check14: svg(14, slant('M3.5 6.5l3 3 5-5')),
|
||||||
|
download14: svg(14, path(v(7, 2, 8), v(2, 8, 11), v(12, 8, 11), h(11, 2, 12)) + slant('M4.5 5.5l3 3 3-3')),
|
||||||
|
code16: svg(16, slant('M4 5L1 8l3 3M12 5l3 3-3 3M9.5 2.5l-3 11')),
|
||||||
|
|
||||||
|
// ---- circles and arcs ----
|
||||||
|
checkCircle14: svg(14, slant(circle(7, 7, 5.5), 'M4.5 6.5l2 2 3-3')),
|
||||||
|
closeCircle14: svg(14, slant(circle(7, 7, 5.5), 'M5 5l4 4M9 5l-4 4')),
|
||||||
|
search14: svg(14, slant(circle(6, 6, 4.5), 'M9.5 9.5l3 3')),
|
||||||
|
setting14: svg(14, path(v(2, 4, 9), v(11, 4, 9)) + slant('M2.5 4.5L7 1.5l4.5 3M2.5 9.5L7 12.5l4.5-3', circle(7, 7, 2.5))),
|
||||||
|
setting16: svg(16, path(v(2, 4, 11), v(13, 4, 11)) + slant('M2.5 4.5L8 1.5l5.5 3M2.5 11.5L8 14.5l5.5-3', circle(8, 8, 2.5))),
|
||||||
|
refresh14: svg(14, path(v(1, 9, 12), h(9, 1, 3), v(12, 1, 4), h(4, 10, 12)) + slant('M12.47 7.58A5.5 5.5 0 0 1 2.1 9.5M1.53 6.42A5.5 5.5 0 0 1 11.9 4.5')),
|
||||||
|
refresh16: svg(16, path(v(1, 10, 13), h(10, 1, 4), v(14, 2, 5), h(5, 11, 14)) + slant('M14.46 8.68A6.5 6.5 0 0 1 2 10.5M1.54 7.32A6.5 6.5 0 0 1 14 5.5')),
|
||||||
|
|
||||||
|
// ---- the rest ----
|
||||||
|
link16: svg(16, slant('M7.7 4.8L9.23 3.23A2.5 2.5 0 0 1 12.77 6.77L11.2 8.3M8.3 11.2L6.77 12.77A2.5 2.5 0 0 1 3.23 9.23L4.8 7.7M9.5 6.5l-3 3')),
|
||||||
|
music16: svg(16, path(h(2, 4, 12), h(5, 4, 12), v(4, 2, 12), v(12, 2, 12)) + fill(circle(3, 13, 2), circle(11, 13, 2))),
|
||||||
|
image16: svg(16, path(box(1, 1, 14, 14)) + slant('M1.5 10.5l4-4 8 8', circle(10.5, 5.5, 1))),
|
||||||
|
edit14: svg(14, path(v(2, 9, 11), h(11, 2, 4)) + slant('M2.5 8.5l7-7 2 2-7 7M7.5 3.5l2 2')),
|
||||||
|
};
|
||||||
+14
-26
@@ -7,10 +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';
|
||||||
/** Which skin tree this window boots. Baked into the preload because
|
|
||||||
* settings resolve too late to decide layout without a flash. */
|
|
||||||
__APP_SKIN?: 'modern' | 'win7';
|
|
||||||
/** Window-kind specific context (EditorOpenContext for editor windows). */
|
/** Window-kind specific context (EditorOpenContext for editor windows). */
|
||||||
__APP_CTX?: unknown;
|
__APP_CTX?: unknown;
|
||||||
}
|
}
|
||||||
@@ -24,36 +21,27 @@ export const IS_WINDOWS = PLATFORM === 'win32';
|
|||||||
|
|
||||||
export const IS_LINUX = PLATFORM === 'linux';
|
export const IS_LINUX = PLATFORM === 'linux';
|
||||||
|
|
||||||
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. Keyed on the real
|
export const APP_MODE: 'main' | 'editor' | 'manager' | 'settings' =
|
||||||
* platform — the keyboard does not change with the window chrome. */
|
|
||||||
export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+';
|
|
||||||
export const ALT_LABEL = IS_MAC ? '⌥' : 'Alt+';
|
|
||||||
|
|
||||||
export const APP_MODE: 'main' | 'editor' | 'manager' =
|
|
||||||
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
|
||||||
* Which skin tree this window booted. Read from the preload rather than from
|
* hiddenInset layout, see chromeOptions), which reserves `pl-24` for them. */
|
||||||
* SettingsService because both are needed before first paint — main.ts picks a
|
export const IS_MAC_CHROME = IS_MAC;
|
||||||
* root component from it, and it must not change under a running window (the
|
|
||||||
* native frame is fixed at BrowserWindow construction).
|
|
||||||
*/
|
|
||||||
export const APP_SKIN: 'modern' | 'win7' =
|
|
||||||
typeof window !== 'undefined' ? (window.__APP_SKIN ?? 'modern') : 'modern';
|
|
||||||
|
|
||||||
export const IS_WIN7_CHROME = APP_SKIN === 'win7';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when macOS traffic lights overlay the app's own header row — the fused
|
* WebView2 (Windows) reloads the whole page on F5 / Ctrl+R / Ctrl+F5 /
|
||||||
* `hiddenInset` layout, which reserves `pl-24` for them. False under the win7
|
* Ctrl+Shift+R unless the page claims the key — dropping every session and any
|
||||||
* skin even on macOS: `titleBarStyle: 'hidden'` removes the traffic lights
|
* unsaved editor text. Every window root calls this for keys none of its
|
||||||
* altogether, so reserving space would leave a gap. Only the shadlike tree
|
* bindings took. Known ceiling: unverified whether Electrobun already turns
|
||||||
* reads this; 7like draws its own title bar and never defers to native chrome.
|
* WebView2's browser keys off; harmless if it does.
|
||||||
*/
|
*/
|
||||||
export const IS_MAC_CHROME = IS_MAC && !IS_WIN7_CHROME;
|
export function blockReloadKeys(event: KeyboardEvent): void {
|
||||||
|
if (!IS_WINDOWS || event.defaultPrevented) return;
|
||||||
|
if (event.key === 'F5' || (event.ctrlKey && event.code === 'KeyR')) event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kill the webview's default right-click menu (Reload, Inspect Element…)
|
* Kill the webview's default right-click menu (Reload, Inspect Element…)
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export class RpcService {
|
|||||||
private managerStateListeners = new Set<(open: boolean) => void>();
|
private managerStateListeners = new Set<(open: boolean) => void>();
|
||||||
private connectionsListeners = new Set<() => void>();
|
private connectionsListeners = new Set<() => void>();
|
||||||
private updateStatusListeners = new Set<(status: UpdateStatus) => void>();
|
private updateStatusListeners = new Set<(status: UpdateStatus) => void>();
|
||||||
private windowStateListeners = new Set<(maximized: boolean) => void>();
|
|
||||||
|
|
||||||
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
|
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
|
||||||
private ready: Promise<void> = Promise.resolve();
|
private ready: Promise<void> = Promise.resolve();
|
||||||
@@ -72,17 +71,15 @@ export class RpcService {
|
|||||||
updateStatus: (status) => {
|
updateStatus: (status) => {
|
||||||
for (const listener of this.updateStatusListeners) listener(status);
|
for (const listener of this.updateStatusListeners) listener(status);
|
||||||
},
|
},
|
||||||
windowState: ({ maximized }) => {
|
|
||||||
for (const listener of this.windowStateListeners) listener(maximized);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
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 });
|
||||||
@@ -136,12 +133,6 @@ export class RpcService {
|
|||||||
this.updateStatusListeners.add(listener);
|
this.updateStatusListeners.add(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** win7 skin: the frameless window's maximize state changed (derived from
|
|
||||||
* the native resize event, so Aero Snap and Win+Up land here too). */
|
|
||||||
onWindowState(listener: (maximized: boolean) => void): void {
|
|
||||||
this.windowStateListeners.add(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */
|
/** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */
|
||||||
get request() {
|
get request() {
|
||||||
const rpc = this.rpc;
|
const rpc = this.rpc;
|
||||||
|
|||||||
@@ -7,11 +7,8 @@ import { RpcService } from './rpc.service';
|
|||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class SettingsService {
|
export class SettingsService {
|
||||||
readonly locale = signal<AppSettings['locale']>(DEFAULT_SETTINGS.locale);
|
readonly locale = signal<AppSettings['locale']>(DEFAULT_SETTINGS.locale);
|
||||||
readonly skin = signal<AppSettings['skin']>(DEFAULT_SETTINGS.skin);
|
|
||||||
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 uiScale = signal<number>(DEFAULT_SETTINGS.uiScale);
|
readonly uiScale = signal<number>(DEFAULT_SETTINGS.uiScale);
|
||||||
@@ -20,6 +17,9 @@ export class SettingsService {
|
|||||||
readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted);
|
readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted);
|
||||||
readonly editor = signal<AppSettings['editor']>(DEFAULT_SETTINGS.editor);
|
readonly editor = signal<AppSettings['editor']>(DEFAULT_SETTINGS.editor);
|
||||||
readonly editorApp = signal(DEFAULT_SETTINGS.editorApp);
|
readonly editorApp = signal(DEFAULT_SETTINGS.editorApp);
|
||||||
|
/** Settings → Keyboard overrides; ShortcutsService applies them to the keymap. */
|
||||||
|
readonly keybindings = signal<AppSettings['keybindings']>(DEFAULT_SETTINGS.keybindings);
|
||||||
|
readonly openFilesOnEnter = signal(DEFAULT_SETTINGS.openFilesOnEnter);
|
||||||
// Auto-refresh is always on for now (see panels.service). To make it
|
// Auto-refresh is always on for now (see panels.service). To make it
|
||||||
// user-controllable: uncomment the field in types.ts, then here add
|
// user-controllable: uncomment the field in types.ts, then here add
|
||||||
// readonly autoRefresh = signal(DEFAULT_SETTINGS.autoRefresh);
|
// readonly autoRefresh = signal(DEFAULT_SETTINGS.autoRefresh);
|
||||||
@@ -28,6 +28,10 @@ export class SettingsService {
|
|||||||
|
|
||||||
private readonly rpc = inject(RpcService);
|
private readonly rpc = inject(RpcService);
|
||||||
private readonly i18n = inject(I18nService);
|
private readonly i18n = inject(I18nService);
|
||||||
|
/** Saves are whole-object: one sent before the first getSettings lands would
|
||||||
|
* write these defaults over the user's settings (a ⌘+ pressed as a window
|
||||||
|
* opens would wipe every keyboard remap), so nothing persists until then. */
|
||||||
|
private loaded = false;
|
||||||
|
|
||||||
/** Whether the app is actually rendering dark right now (drives the Tone row). */
|
/** Whether the app is actually rendering dark right now (drives the Tone row). */
|
||||||
readonly effectiveDark = computed(
|
readonly effectiveDark = computed(
|
||||||
@@ -39,8 +43,6 @@ export class SettingsService {
|
|||||||
window
|
window
|
||||||
.matchMedia('(prefers-color-scheme: dark)')
|
.matchMedia('(prefers-color-scheme: dark)')
|
||||||
.addEventListener('change', (e) => this.systemDark.set(e.matches));
|
.addEventListener('change', (e) => this.systemDark.set(e.matches));
|
||||||
// the default accent is not the CSS default — apply before first paint
|
|
||||||
this.applyAccent();
|
|
||||||
if (!this.rpc.available) return;
|
if (!this.rpc.available) return;
|
||||||
void this.rpc.request.getSettings({}).then((res) => {
|
void this.rpc.request.getSettings({}).then((res) => {
|
||||||
if (res.ok) this.applyAll(res.settings);
|
if (res.ok) this.applyAll(res.settings);
|
||||||
@@ -50,12 +52,10 @@ export class SettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applyAll(settings: AppSettings): void {
|
private applyAll(settings: AppSettings): void {
|
||||||
|
this.loaded = true;
|
||||||
this.locale.set(settings.locale);
|
this.locale.set(settings.locale);
|
||||||
this.skin.set(settings.skin);
|
|
||||||
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.uiScale.set(settings.uiScale);
|
this.uiScale.set(settings.uiScale);
|
||||||
@@ -64,12 +64,10 @@ export class SettingsService {
|
|||||||
this.autoClearCompleted.set(settings.autoClearCompleted);
|
this.autoClearCompleted.set(settings.autoClearCompleted);
|
||||||
this.editor.set(settings.editor);
|
this.editor.set(settings.editor);
|
||||||
this.editorApp.set(settings.editorApp);
|
this.editorApp.set(settings.editorApp);
|
||||||
// NO applySkin here, deliberately — see setSkin. applyAll also runs on the
|
this.keybindings.set(settings.keybindings);
|
||||||
// settingsChanged push, so flipping the class here would break every OPEN
|
this.openFilesOnEnter.set(settings.openFilesOnEnter);
|
||||||
// editor and manager window too, not just the one that changed the setting.
|
|
||||||
this.applyTheme();
|
this.applyTheme();
|
||||||
this.applyDarkTone();
|
this.applyDarkTone();
|
||||||
this.applyAccent();
|
|
||||||
this.applyUiScale();
|
this.applyUiScale();
|
||||||
this.applyLocale();
|
this.applyLocale();
|
||||||
}
|
}
|
||||||
@@ -80,30 +78,6 @@ export class SettingsService {
|
|||||||
void this.persist();
|
void this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves the preference and NOTHING else — the skin is a launch-time choice.
|
|
||||||
*
|
|
||||||
* It deliberately does not touch the `.win7` class, which is the whole bug
|
|
||||||
* this replaced: each skin's CSS is written against its OWN component tree's
|
|
||||||
* markup, and the tree is fixed at bootstrap from the preload-baked
|
|
||||||
* __APP_SKIN. Flipping the class live therefore painted 7.css onto shadlike
|
|
||||||
* markup that has no .window.glass, no .title-bar, no menu[role=tablist] and
|
|
||||||
* no <table> ListView — a broken window, not a reskinned one.
|
|
||||||
*
|
|
||||||
* A reload would not have helped either: the preload string is frozen at
|
|
||||||
* webview construction (a persistent WebKitUserScript), and titleBarStyle /
|
|
||||||
* transparent have no setters, so the native frame cannot change on a live
|
|
||||||
* window. win7 -> modern without a reframe is the dangerous direction — the
|
|
||||||
* modern tree draws no title bar, so it would land in a frameless window
|
|
||||||
* with no way to move, minimize or close it.
|
|
||||||
*
|
|
||||||
* The options row shows the saved value as pending until the next start.
|
|
||||||
*/
|
|
||||||
setSkin(skin: AppSettings['skin']): void {
|
|
||||||
this.skin.set(skin);
|
|
||||||
void this.persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
setTheme(theme: AppSettings['theme']): void {
|
setTheme(theme: AppSettings['theme']): void {
|
||||||
this.theme.set(theme);
|
this.theme.set(theme);
|
||||||
this.applyTheme();
|
this.applyTheme();
|
||||||
@@ -116,18 +90,6 @@ export class SettingsService {
|
|||||||
void this.persist();
|
void this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
setAccent(tone: AppSettings['accent']): void {
|
|
||||||
this.accent.set(tone);
|
|
||||||
this.applyAccent();
|
|
||||||
void this.persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
setIconColors(mode: AppSettings['iconColors']): void {
|
|
||||||
// no apply step — the file panels read the signal reactively
|
|
||||||
this.iconColors.set(mode);
|
|
||||||
void this.persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleColumn(key: ColumnKey): void {
|
toggleColumn(key: ColumnKey): void {
|
||||||
this.columns.update((list) =>
|
this.columns.update((list) =>
|
||||||
list.includes(key) ? list.filter((c) => c !== key) : [...list, key],
|
list.includes(key) ? list.filter((c) => c !== key) : [...list, key],
|
||||||
@@ -171,11 +133,15 @@ export class SettingsService {
|
|||||||
void this.persist();
|
void this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
// There is deliberately no applySkin(). The `.win7` class is written once by
|
setKeybindings(keybindings: AppSettings['keybindings']): void {
|
||||||
// buildPreload at DOCUMENT_START for the skin this window actually booted,
|
this.keybindings.set(keybindings);
|
||||||
// and must never change afterwards — see setSkin for why. Every other apply*
|
void this.persist();
|
||||||
// below is safe to run live because it only re-points tokens, and both skins
|
}
|
||||||
// consume the same token system.
|
|
||||||
|
setOpenFilesOnEnter(open: boolean): void {
|
||||||
|
this.openFilesOnEnter.set(open);
|
||||||
|
void this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
private applyTheme(): void {
|
private applyTheme(): void {
|
||||||
const theme = this.theme();
|
const theme = this.theme();
|
||||||
@@ -195,17 +161,8 @@ export class SettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyAccent(): void {
|
|
||||||
const tone = this.accent();
|
|
||||||
if (tone === 'graphite') {
|
|
||||||
delete document.documentElement.dataset['accent'];
|
|
||||||
} else {
|
|
||||||
document.documentElement.dataset['accent'] = tone;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The whole interface scale is ONE declaration: every size in the app is
|
/** The whole interface scale is ONE declaration: every size in the app is
|
||||||
* rem, so moving the root font-size moves both skins in lockstep. 1 = the
|
* rem, so moving the root font-size moves the whole UI. 1 = the
|
||||||
* browser-default 16px root; delete-when-default keeps the pristine state
|
* browser-default 16px root; delete-when-default keeps the pristine state
|
||||||
* attribute-free like the other axes. buildPreload mirrors this for a
|
* attribute-free like the other axes. buildPreload mirrors this for a
|
||||||
* flash-free first paint. */
|
* flash-free first paint. */
|
||||||
@@ -225,11 +182,8 @@ export class SettingsService {
|
|||||||
protected settings(): AppSettings {
|
protected settings(): AppSettings {
|
||||||
return {
|
return {
|
||||||
locale: this.locale(),
|
locale: this.locale(),
|
||||||
skin: this.skin(),
|
|
||||||
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(),
|
||||||
uiScale: this.uiScale(),
|
uiScale: this.uiScale(),
|
||||||
@@ -238,10 +192,13 @@ export class SettingsService {
|
|||||||
autoClearCompleted: this.autoClearCompleted(),
|
autoClearCompleted: this.autoClearCompleted(),
|
||||||
editor: this.editor(),
|
editor: this.editor(),
|
||||||
editorApp: this.editorApp(),
|
editorApp: this.editorApp(),
|
||||||
|
keybindings: this.keybindings(),
|
||||||
|
openFilesOnEnter: this.openFilesOnEnter(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async persist(): Promise<void> {
|
private async persist(): Promise<void> {
|
||||||
|
if (!this.loaded) return;
|
||||||
await this.rpc.request.saveSettings({ settings: this.settings() });
|
await this.rpc.request.saveSettings({ settings: this.settings() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,111 +1,166 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, computed, inject } from '@angular/core';
|
||||||
|
import {
|
||||||
|
type Combo,
|
||||||
|
EN_KEY_NAMES,
|
||||||
|
effectiveKeymap,
|
||||||
|
firesInTextField,
|
||||||
|
formatCombo,
|
||||||
|
isRepeatable,
|
||||||
|
type KeyEventLike,
|
||||||
|
type KeyNames,
|
||||||
|
matchesEvent,
|
||||||
|
parseCombo,
|
||||||
|
type ResolvedBinding,
|
||||||
|
type Scope,
|
||||||
|
sameCombo,
|
||||||
|
serializeCombo,
|
||||||
|
type ShortcutId,
|
||||||
|
} from '@shared/keymap';
|
||||||
|
import { I18nService } from './i18n.service';
|
||||||
|
import { IS_MAC } from './platform';
|
||||||
|
import { SettingsService } from './settings.service';
|
||||||
|
|
||||||
|
export type { ShortcutId } from '@shared/keymap';
|
||||||
|
|
||||||
|
/** A keydown as resolve() reads it: the matching fields plus where it came from. */
|
||||||
|
type KeyInput = KeyEventLike & { target?: EventTarget | null; isComposing?: boolean; keyCode?: number };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every window-level shortcut the app answers to, by name. Components switch
|
* The keyboard layer over the shared keymap (src/shared/keymap.ts): resolves
|
||||||
* on these ids instead of key-matching inline, so the whole keymap is ONE
|
* keydowns against the EFFECTIVE keymap — defaults plus the Settings → Keyboard
|
||||||
* table — the precondition for the vim-style bindings planned next, which
|
* remaps, live in every window — and formats the hints menus and tooltips show.
|
||||||
* 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
|
|
||||||
// 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' answers 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';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The keyboard layer: modifier helpers + one declarative keymap.
|
|
||||||
*
|
*
|
||||||
* House rules encoded here rather than re-derived per component (they were
|
* - Matching is exact: ⌃F is not ⌘F, Ctrl+Tab is not Tab. `Mod` is ⌘ on
|
||||||
* previously copy-pasted inline in six places, drifting apart):
|
* macOS and Ctrl elsewhere.
|
||||||
* - `mod` is metaKey OR ctrlKey — ⌘ on macOS, Ctrl elsewhere. The keyboard
|
* - On macOS the PAGE sees ⌘-keys first: WKWebView hands key equivalents to the
|
||||||
* does not change with the window chrome (platform.ts says the same about
|
* page before the app menu (WebKit's WebViewImpl::performKeyEquivalent), so a
|
||||||
* MOD_LABEL).
|
* key a window claims here (claim() → preventDefault) never reaches the menu
|
||||||
* - Layout-dependent keys match on `event.code`, not `event.key`: digits
|
* bar, whose accelerators bun builds from the same table. Keys nobody claims
|
||||||
* (AZERTY's digit row shifts), +/- (key varies wildly across layouts), and
|
* fall through to it.
|
||||||
* letter chords with Alt (macOS Option produces Ω for ⌥Z). Plain letter
|
* - From a text field only firesInTextField() combos count (⌘S yes, Delete no).
|
||||||
* chords match on `key` so they follow the user's layout.
|
* - Escape, the arrows inside menus and dialog Enter stay component-local:
|
||||||
* - isTypingTarget guards shortcuts that would be hostile mid-typing. It is
|
* they are fixed, so nobody can remap themselves out of a dialog.
|
||||||
* 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' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ShortcutsService {
|
export class ShortcutsService {
|
||||||
/** The platform command modifier: ⌘ on macOS, Ctrl elsewhere. Mouse events
|
private readonly settings = inject(SettingsService);
|
||||||
* qualify too — Mod+click toggle-select in the panels uses the same rule. */
|
private readonly i18n = inject(I18nService);
|
||||||
isMod(event: KeyboardEvent | MouseEvent): boolean {
|
|
||||||
return event.metaKey || event.ctrlKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when the event originates from something the user types into. */
|
readonly keymap = computed(() => effectiveKeymap(this.settings.keybindings(), IS_MAC));
|
||||||
isTypingTarget(event: KeyboardEvent): boolean {
|
private readonly byId = computed(() => new Map(this.keymap().map((b) => [b.id, b])));
|
||||||
const target = event.target as HTMLElement | null;
|
/** Localized key names for the non-mac spelling ("Strg+Entf"); reading t()
|
||||||
return !!target?.closest('input, textarea, select, [contenteditable="true"]');
|
* makes every hint follow a language switch. */
|
||||||
|
private readonly names = computed<KeyNames>(() => {
|
||||||
|
const names = { ...EN_KEY_NAMES };
|
||||||
|
for (const key of Object.keys(names) as (keyof KeyNames)[]) {
|
||||||
|
names[key] = this.i18n.t(`keys.names.${key}`);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The command a keydown triggers in `scope`, or null. Window scopes include
|
||||||
|
* `any`; `panel` doesn't (the window root runs those). */
|
||||||
|
resolve(event: KeyInput, scope: Scope): ShortcutId | null {
|
||||||
|
// an IME's Enter commits a candidate, it is never a shortcut
|
||||||
|
if (event.isComposing || event.keyCode === 229) return null;
|
||||||
|
const typing = this.isTypingTarget(event);
|
||||||
|
for (const binding of this.keymap()) {
|
||||||
|
if (!binding.scopes.includes(scope) && (scope === 'panel' || !binding.scopes.includes('any'))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const combo of binding.combos) {
|
||||||
|
if (typing && !firesInTextField(combo, IS_MAC)) continue;
|
||||||
|
if (matchesEvent(combo, event, IS_MAC)) return binding.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Match an event against the scope's keymap. Returns null for anything the
|
* resolve() + take the key: a match is preventDefault-ed, so the macOS menu
|
||||||
* table does not claim — callers must then leave the event alone.
|
* never runs it a second time. A held key repeats only movement-like
|
||||||
|
* commands; any other repeat is swallowed (holding ⌘W closes one tab).
|
||||||
*/
|
*/
|
||||||
resolve(event: KeyboardEvent, scope: ShortcutScope): ShortcutMatch | null {
|
claim(event: KeyboardEvent, scope: Scope): ShortcutId | null {
|
||||||
const mod = this.isMod(event);
|
const id = this.resolve(event, scope);
|
||||||
const key = event.key.toLowerCase();
|
if (!id) return null;
|
||||||
|
event.preventDefault();
|
||||||
|
return event.repeat && !isRepeatable(id) ? null : id;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- any window: interface scale ----
|
/** Whether a keydown is one of `id`'s combos (an open menu matching its items). */
|
||||||
// event.code, not key: '+' needs Shift on many layouts and '-' moves
|
matches(id: ShortcutId, event: KeyEventLike): boolean {
|
||||||
// around; the physical Equal/Minus keys (and the numpad pair) are stable.
|
return !!this.byId().get(id)?.combos.some((c) => matchesEvent(c, event, IS_MAC));
|
||||||
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' };
|
binding(id: ShortcutId): ResolvedBinding | undefined {
|
||||||
if (event.code === 'Digit0' || event.code === 'Numpad0') return { id: 'scale-reset' };
|
return this.byId().get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
format(combo: Combo): string {
|
||||||
|
return formatCombo(combo, IS_MAC, this.names());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The hint menus show: the command's first key, or '' when unbound. */
|
||||||
|
label(id: ShortcutId): string {
|
||||||
|
const first = this.byId().get(id)?.combos[0];
|
||||||
|
return first ? this.format(first) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
labels(id: ShortcutId): string[] {
|
||||||
|
return (this.byId().get(id)?.combos ?? []).map((c) => this.format(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tooltip with the command's keys: "Delete (F8 / Del)"; bare when unbound. */
|
||||||
|
tip(text: string, id: ShortcutId): string {
|
||||||
|
const keys = this.labels(id);
|
||||||
|
return keys.length ? `${text} (${keys.join(' / ')})` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A FIXED key — one no remap moves, such as Enter in the find box — in
|
||||||
|
* the platform's spelling (⇧↩ / Shift+Enter). */
|
||||||
|
fixedKey(combo: string): string {
|
||||||
|
const parsed = parseCombo(combo, IS_MAC);
|
||||||
|
return parsed ? this.format(parsed) : combo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** tip() for a fixed key. */
|
||||||
|
tipFixed(text: string, combo: string): string {
|
||||||
|
return `${text} (${this.fixedKey(combo)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store new combos for commands (Settings → Keyboard). Only rows that differ
|
||||||
|
* from their defaults are kept, so a reset — or a remap back to the
|
||||||
|
* default — leaves nothing behind.
|
||||||
|
*/
|
||||||
|
rebind(changes: Partial<Record<ShortcutId, Combo[]>>): void {
|
||||||
|
const next = { ...this.settings.keybindings() };
|
||||||
|
for (const [id, combos] of Object.entries(changes) as [ShortcutId, Combo[]][]) {
|
||||||
|
const defaults = this.byId().get(id)?.defaults ?? [];
|
||||||
|
const isDefault =
|
||||||
|
combos.length === defaults.length && combos.every((c, i) => sameCombo(c, defaults[i]));
|
||||||
|
if (isDefault) delete next[id];
|
||||||
|
else next[id] = combos.map(serializeCombo);
|
||||||
}
|
}
|
||||||
|
this.settings.setKeybindings(next);
|
||||||
|
}
|
||||||
|
|
||||||
if (scope === 'manager') return null; // any-window shortcuts only
|
resetAll(): void {
|
||||||
|
this.settings.setKeybindings({});
|
||||||
|
}
|
||||||
|
|
||||||
if (scope === 'main') {
|
/** The platform command modifier: ⌘ on macOS, Ctrl elsewhere — exactly one.
|
||||||
if (mod && key === 't') return { id: 'new-tab' };
|
* Mouse events too: Mod+click toggle-selects in the panels. */
|
||||||
if (mod && key === 'w') return { id: 'close-tab' };
|
isMod(event: KeyboardEvent | MouseEvent): boolean {
|
||||||
// ctrlKey only, never meta: ⌘Tab is the macOS app switcher
|
return IS_MAC ? event.metaKey : event.ctrlKey;
|
||||||
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'
|
/** True when the event comes from something the user types into. */
|
||||||
if (mod && key === 's') return { id: 'save' };
|
isTypingTarget(event: { target?: EventTarget | null }): boolean {
|
||||||
if (mod && key === 'f') return { id: 'find' };
|
const target = event.target as HTMLElement | null;
|
||||||
// on macOS the app menu intercepts ⌘W before this ever fires; this arm is
|
return !!target?.closest?.(
|
||||||
// the Windows/Linux path
|
'textarea, select, [contenteditable="true"], input:not([type=checkbox]):not([type=radio]):not([type=button])',
|
||||||
if (mod && key === 'w') return { id: 'close-window' };
|
);
|
||||||
if (event.altKey && event.code === 'KeyZ') return { id: 'toggle-wrap' };
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,14 @@ export class UpdaterService {
|
|||||||
void this.rpc.request.applyUpdate({}); // quits + relaunches
|
void this.rpc.request.applyUpdate({}); // quits + relaunches
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The pill's one action, also the "Install update" key: download when
|
||||||
|
* available, apply (relaunch) once ready; nothing otherwise. */
|
||||||
|
advance(): void {
|
||||||
|
const state = this.state();
|
||||||
|
if (state === 'available') void this.download();
|
||||||
|
else if (state === 'ready') this.apply();
|
||||||
|
}
|
||||||
|
|
||||||
/** Manual re-check (menu item). */
|
/** Manual re-check (menu item). */
|
||||||
async check(): Promise<void> {
|
async check(): Promise<void> {
|
||||||
if (!this.rpc.available) return;
|
if (!this.rpc.available) return;
|
||||||
|
|||||||
@@ -1,840 +0,0 @@
|
|||||||
/* The SCOPED 7.css build: postcss-prefix-selector rewrote every selector to
|
|
||||||
`.win7` (root rules) or `.win7 <sel>` (the rest), so the whole framework is
|
|
||||||
inert until the `.win7` class is on <html> — written once by buildPreload
|
|
||||||
for the skin this window booted, and never changed after. All 61
|
|
||||||
icons are data URIs, so nothing needs copying into assets — and NOT the
|
|
||||||
`7.inline.css` build, which flattens away the --w7-* vars the dark palette
|
|
||||||
below depends on. The deep path is required: 7.css ships no `exports` map
|
|
||||||
and Tailwind's resolver only reads `style`/`mainFields`, so a bare
|
|
||||||
`@import "7.css"` finds no entry. */
|
|
||||||
@import "7.css/dist/7.scoped.css" layer(skin);
|
|
||||||
|
|
||||||
/* ==========================================================================
|
|
||||||
Windows 7 skin — buildPreload puts `.win7` on <html> at first paint
|
|
||||||
==========================================================================
|
|
||||||
|
|
||||||
Three tiers, and the split matters:
|
|
||||||
|
|
||||||
- `@layer skin` (the 7.css import at the top of this file) owns the
|
|
||||||
CONTROLS: buttons, inputs, tabs, progress bars, tree views, the Aero
|
|
||||||
title bar. It sits ABOVE `utilities`, so a bare `button` rule in 7.css
|
|
||||||
beats `.rounded-md` on that same button. That inversion is the whole
|
|
||||||
trick — it restyles ~1900 inline utility classes with no template edits.
|
|
||||||
- This unlayered section owns the BRIDGE: it re-points the app's own
|
|
||||||
semantic tokens at Win7 values, so utilities Tailwind still controls
|
|
||||||
(bg-card, border-border, text-muted-foreground) also come out
|
|
||||||
Win7-correct. It adds NO new tokens on purpose — a new colour token
|
|
||||||
would need a matching `--color-*` line in `@theme inline`.
|
|
||||||
- `@layer skinfix` (last, above `skin`) hands individual properties BACK to
|
|
||||||
the utilities layer with `revert-layer`, for the places where 7.css's
|
|
||||||
Win7-dialog metrics are wrong for this app.
|
|
||||||
|
|
||||||
WARNING for future edits: because `skin` outranks `utilities`, any Tailwind
|
|
||||||
utility added later to a <button>, <input>, <select>, <textarea> or <table>
|
|
||||||
silently loses under this skin. `skinfix` is the only place to fix that.
|
|
||||||
|
|
||||||
Specificity notes — the blocks below must beat the token blocks above:
|
|
||||||
:root.win7 (0,2,0) beats base :root (0,1,0)
|
|
||||||
:root.win7[data-theme='dark'] (0,3,0) beats :root[data-theme=…] (0,2,0)
|
|
||||||
and ties the darker-tone block (0,3,0),
|
|
||||||
winning on source order (it is last).
|
|
||||||
The accent formula blocks are deliberately NOT overridden: they only set
|
|
||||||
--primary/--ring/--cursor-tint, and letting them through is what makes the
|
|
||||||
accent picker recolour the Aero chrome — authentic, Win7 shipped exactly
|
|
||||||
that ("Window Color" personalization). */
|
|
||||||
|
|
||||||
/* Segoe UI is not on macOS/Linux and is not redistributable. Noto Sans is the
|
|
||||||
substitute 7.css itself names in its --w7-font fallback stack. Vendored
|
|
||||||
(OFL-1.1) from @fontsource/noto-sans@5.3.0, files/noto-sans-latin-{400,700}
|
|
||||||
-normal.woff2 -> ./fonts/, i.e. INSIDE this skin: the font exists only for
|
|
||||||
the Win7 look, and keeping it here means the url() below stays relative to
|
|
||||||
this file wherever the skin folder moves. (It used to sit at ui/src/fonts/
|
|
||||||
next to the old single stylesheet — splitting this file out rebased the
|
|
||||||
url() three directories deeper and would have silently 404'd the font.)
|
|
||||||
Vendored rather than @imported from node_modules because Tailwind inlines
|
|
||||||
imported CSS, which would leave @fontsource's relative url() rebased against
|
|
||||||
the wrong directory. They live under src/ (not public/) so esbuild emits them
|
|
||||||
as hashed assets and rewrites the url() — a public/ path is not resolvable at
|
|
||||||
build time. */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Noto Sans App';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: block;
|
|
||||||
src: url('./fonts/noto-sans-latin-400-normal.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Noto Sans App';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: block;
|
|
||||||
src: url('./fonts/noto-sans-latin-700-normal.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.win7 {
|
|
||||||
/* Real Segoe first (Windows), bundled substitute everywhere else. */
|
|
||||||
--w7-family: 'Segoe UI', 'SegoeUI', 'Noto Sans App', system-ui, sans-serif;
|
|
||||||
/* ~7.css's 9pt, in REM so it follows the interface scale (the root
|
|
||||||
font-size lever) like every other size in the app — 0.75rem = 12px at the
|
|
||||||
16px root. Restating the `font` SHORTHAND (not just the family) is
|
|
||||||
deliberate: 7.css applies `font: var(--w7-font)` on 15 component rules,
|
|
||||||
and the shorthand also resets weight and line-height — pinning 400/1.4
|
|
||||||
here stops those rules from flattening the app's `font-medium` / leading. */
|
|
||||||
--w7-fs: 0.75rem;
|
|
||||||
--w7-font: 400 var(--w7-fs) / 1.4 var(--w7-family);
|
|
||||||
|
|
||||||
/* Surface ramp, Win7 Explorer. This INVERTS the modern ramp's direction:
|
|
||||||
there --chrome (frame strips) sits below --background (content panes);
|
|
||||||
here the content pane is the brightest thing on screen and the frame is
|
|
||||||
the #f0f0f0 window surface. Deliberate — that is what Explorer was. */
|
|
||||||
--background: #ffffff;
|
|
||||||
--chrome: #f0f0f0;
|
|
||||||
--card: #f0f0f0;
|
|
||||||
--foreground: #000000;
|
|
||||||
--card-foreground: #000000;
|
|
||||||
--muted-foreground: #6d6d6d;
|
|
||||||
--secondary: #e8e8e8;
|
|
||||||
--secondary-foreground: #000000;
|
|
||||||
--muted: #e8e8e8;
|
|
||||||
/* Win7's hover is a pale blue wash, not a grey — matches --w7-el-grad-h */
|
|
||||||
--accent: #eaf6fd;
|
|
||||||
--accent-foreground: #000000;
|
|
||||||
--border: #d5d5d5;
|
|
||||||
--input: #abadb3; /* 7.css's own text-input border */
|
|
||||||
--destructive: #c62828;
|
|
||||||
--destructive-foreground: #ffffff;
|
|
||||||
--success: #06b025; /* the Win7 progress-bar green */
|
|
||||||
|
|
||||||
/* 0.3125rem = 5px at the 16px root — NOT 3px: `rounded-md` is 74 of the 112
|
|
||||||
radius utilities in the templates and compiles to
|
|
||||||
calc(var(--radius) - 0.125rem), so 5 lands it exactly on --w7-el-bdr (3px).
|
|
||||||
--radius itself feeds `rounded-lg` (12 uses) at 5px, near the 6px window
|
|
||||||
radius. Bare `rounded` (26 uses) is a Tailwind literal 0.25rem. Rem so the
|
|
||||||
corners follow the interface scale with everything else. */
|
|
||||||
--radius: 0.3125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Light values for every 7.css literal the skinfix rules below restate through
|
|
||||||
vars (7.css hardcodes these outside its var system). The values are 7.css's
|
|
||||||
own, verbatim — light must render exactly as stock. Dark overrides live in
|
|
||||||
the duplicated dark blocks above, which is the house pattern: rules are
|
|
||||||
written ONCE and theme through tokens. (The previous attribute-keyed dark
|
|
||||||
rules silently missed SYSTEM-dark — the media-query path sets no attribute.) */
|
|
||||||
:root.win7 {
|
|
||||||
/* ListView (listview.css) */
|
|
||||||
--w7-lv-bg: #fff;
|
|
||||||
--w7-lv-bd: #c0c1cd;
|
|
||||||
--w7-lv-c: #000;
|
|
||||||
--w7-lv-head-grad: linear-gradient(180deg, #fff 45%, #fafafa 0, #f0f0f0);
|
|
||||||
--w7-lv-head-bd: #d7d7d7;
|
|
||||||
--w7-lv-cell-bd: #eee;
|
|
||||||
/* Selected row. Explorer painted a FLAT blue, not a gradient — 7.css reuses
|
|
||||||
its Aero element-hover gradient for the band, which is what made the list
|
|
||||||
look glossy. These are dedicated tokens on purpose: --w7-el-grad-h is
|
|
||||||
consumed by buttons and tabs and must keep its gradient.
|
|
||||||
`-idle` is the unfocused pane's band. Explorer greyed the selection out
|
|
||||||
when the list lost focus rather than dropping it, which is exactly the
|
|
||||||
commander convention this app already follows (see the pane-focus model
|
|
||||||
below): the band never disappears, only its strength changes. */
|
|
||||||
--w7-sel-bg: #cbe8f6;
|
|
||||||
--w7-sel-bg-idle: #dedede;
|
|
||||||
/* Tabs (tabs.css). 7.css hardcodes `color: #222` on every tablist button,
|
|
||||||
outside its own var system, which is unreadable on the dark surface. */
|
|
||||||
--w7-tab-c: #222;
|
|
||||||
/* Menu (menu.css) */
|
|
||||||
--w7-mn-c: #000;
|
|
||||||
--w7-mn-bd: #0006;
|
|
||||||
--w7-mn-stripe: inset 1px 0 #00000026, inset -1px 0 #fff;
|
|
||||||
--w7-mn-divider: inset 0 1px #00000026, inset 0 -1px #fff;
|
|
||||||
--w7-mn-check-ring: #b3d3f9;
|
|
||||||
--w7-mn-check-c: #0c12a1;
|
|
||||||
/* Glass caption streaks (window.css .window.glass>.title-bar): a literal.
|
|
||||||
The two #fff5 corner shines wash a DARK caption silver, so dark subdues
|
|
||||||
every white in the stack while keeping the streak structure. */
|
|
||||||
--w7-glass-title: linear-gradient(135deg, #fff5 70px, transparent 100px), linear-gradient(225deg, #fff5 70px, transparent 100px), linear-gradient(54deg, #0002 0 4%, #6661 6% 6%, #0002 8% 10%, #0002 15% 16%, #aaa1 17% 18%, #0002 23% 24%, #bbb2 25% 26%, #0002 31% 33%, #0002 34% 34.5%, #bbb2 36% 40%, #0002 41% 41.5%, #bbb2 44% 45%, #bbb2 46% 47%, #0002 48% 49%, #0002 50% 50.5%, #0002 56% 56.5%, #bbb2 57% 63%, #0002 67% 69%, #bbb2 69.5% 70%, #0002 73.5% 74%, #bbb2 74.5% 79%, #0002 80% 84%, #aaa2 85% 86%, #0002 87%, #bbb1 90%) 0 / 100vw 100vh no-repeat fixed;
|
|
||||||
/* Caption text (window.css .title-bar-text: black + 8-fold white halo) */
|
|
||||||
--w7-cap-c: #000;
|
|
||||||
--w7-cap-glow: 0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff,
|
|
||||||
0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hand-authored dark Win7. Microsoft never shipped one — this exists so the
|
|
||||||
theme and tone settings stay live under the skin. Only the colour-carrying
|
|
||||||
--w7-* vars are listed: --w7-el-grad and --w7-w-grad are DERIVED from the
|
|
||||||
values below and recolour for free, while --w7-el-grad-h/-a and
|
|
||||||
--w7-li-bg-hl are literal hex in 7.css and so must be restated. The ~25
|
|
||||||
geometry vars and ~20 base64 icon vars are theme-independent.
|
|
||||||
DUPLICATED for forced-dark and system-dark, per the rule the token blocks
|
|
||||||
above set: every dark value goes in BOTH. */
|
|
||||||
:root.win7[data-theme='dark'] {
|
|
||||||
--w7-surface: #2b2b2b;
|
|
||||||
--w7-el-bg: #3a3a3a;
|
|
||||||
--w7-el-bg-d: #333333;
|
|
||||||
--w7-el-bg-s-1: #333333;
|
|
||||||
--w7-el-bg-s-2: #262626;
|
|
||||||
--w7-el-bd: #565656;
|
|
||||||
--w7-el-bd-h: #4a8cbe;
|
|
||||||
--w7-el-bd-a: #3f6c8c;
|
|
||||||
--w7-el-bd-d: #3f3f3f;
|
|
||||||
--w7-el-c: #f0f0f0;
|
|
||||||
--w7-el-c-d: #6f6f6f;
|
|
||||||
--w7-el-sd: inset 0 0 0 1px #ffffff14;
|
|
||||||
--w7-el-grad-h: linear-gradient(#3c5a6b 45%, #315062 45%, #2b4557);
|
|
||||||
--w7-el-grad-a: linear-gradient(#2c4657, #24384a 30% 50%, #1d2f3f 50%, #16242f);
|
|
||||||
--w7-li-bd-hl: #3f6c8c;
|
|
||||||
--w7-li-bg-hl: linear-gradient(#ffffff14, #4a6b8433 90%, #ffffff1a);
|
|
||||||
--w7-w-bd: #000000e6;
|
|
||||||
/* Ares-style black glass: near-black ground; 7.css's own alpha streaks and
|
|
||||||
highlight gradients become the gloss. Blue lives in accents (selection,
|
|
||||||
active tab), not in the glass itself. */
|
|
||||||
--w7-w-bg: #101013;
|
|
||||||
--w7-tab-bg: #1c1c1e;
|
|
||||||
--w7-title-c: #f5f5f5;
|
|
||||||
--w7-link-c: #6fb3ff;
|
|
||||||
/* literal restatements consumed by the skinfix rules (see :root.win7) */
|
|
||||||
--w7-lv-bg: var(--w7-el-bg-s-2);
|
|
||||||
--w7-lv-bd: var(--w7-el-bd-d);
|
|
||||||
--w7-lv-c: var(--w7-el-c);
|
|
||||||
--w7-lv-head-grad: linear-gradient(180deg, var(--w7-el-bg) 45%, var(--w7-el-bg-d) 0, var(--w7-el-bg-s-1));
|
|
||||||
--w7-lv-head-bd: var(--w7-el-bd-d);
|
|
||||||
--w7-lv-cell-bd: var(--w7-el-bd-d);
|
|
||||||
--w7-sel-bg: #2a4a63;
|
|
||||||
--w7-sel-bg-idle: #3a3a3a;
|
|
||||||
--w7-tab-c: var(--w7-el-c);
|
|
||||||
--w7-mn-c: var(--w7-el-c);
|
|
||||||
--w7-mn-bd: var(--w7-el-bd-d);
|
|
||||||
--w7-mn-stripe: inset 1px 0 #00000059, inset -1px 0 #ffffff14;
|
|
||||||
--w7-mn-divider: inset 0 1px #00000059, inset 0 -1px #ffffff14;
|
|
||||||
--w7-mn-check-ring: var(--w7-li-bd-hl);
|
|
||||||
--w7-mn-check-c: var(--w7-el-c);
|
|
||||||
--w7-glass-title: linear-gradient(135deg, #ffffff1a 70px, transparent 100px), linear-gradient(225deg, #ffffff1a 70px, transparent 100px), linear-gradient(54deg, #0003 0 4%, #ffffff08 6% 6%, #0003 8% 10%, #0003 15% 16%, #ffffff0d 17% 18%, #0003 23% 24%, #ffffff10 25% 26%, #0003 31% 33%, #0003 34% 34.5%, #ffffff10 36% 40%, #0003 41% 41.5%, #ffffff10 44% 45%, #ffffff10 46% 47%, #0003 48% 49%, #0003 50% 50.5%, #0003 56% 56.5%, #ffffff10 57% 63%, #0003 67% 69%, #ffffff10 69.5% 70%, #0003 73.5% 74%, #ffffff10 74.5% 79%, #0003 80% 84%, #ffffff0d 85% 86%, #0003 87%, #ffffff0a 90%) 0 / 100vw 100vh no-repeat fixed;
|
|
||||||
/* Ares: white caption on black glass, dark halo instead of white */
|
|
||||||
--w7-cap-c: var(--w7-title-c);
|
|
||||||
--w7-cap-glow: 0 0 8px #000c, 0 0 8px #000c, 0 1px 2px #000;
|
|
||||||
--w7-link-c-h: #9acbff;
|
|
||||||
|
|
||||||
--background: #1f1f1f;
|
|
||||||
--chrome: #2b2b2b;
|
|
||||||
--card: #2b2b2b;
|
|
||||||
--foreground: #f0f0f0;
|
|
||||||
--card-foreground: #f0f0f0;
|
|
||||||
--muted-foreground: #a0a0a0;
|
|
||||||
--secondary: #383838;
|
|
||||||
--secondary-foreground: #f0f0f0;
|
|
||||||
--muted: #383838;
|
|
||||||
--accent: #2f4a5c;
|
|
||||||
--accent-foreground: #f0f0f0;
|
|
||||||
--border: #3c3c3c;
|
|
||||||
--input: #55585c;
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root.win7:not([data-theme='light']) {
|
|
||||||
--w7-surface: #2b2b2b;
|
|
||||||
--w7-el-bg: #3a3a3a;
|
|
||||||
--w7-el-bg-d: #333333;
|
|
||||||
--w7-el-bg-s-1: #333333;
|
|
||||||
--w7-el-bg-s-2: #262626;
|
|
||||||
--w7-el-bd: #565656;
|
|
||||||
--w7-el-bd-h: #4a8cbe;
|
|
||||||
--w7-el-bd-a: #3f6c8c;
|
|
||||||
--w7-el-bd-d: #3f3f3f;
|
|
||||||
--w7-el-c: #f0f0f0;
|
|
||||||
--w7-el-c-d: #6f6f6f;
|
|
||||||
--w7-el-sd: inset 0 0 0 1px #ffffff14;
|
|
||||||
--w7-el-grad-h: linear-gradient(#3c5a6b 45%, #315062 45%, #2b4557);
|
|
||||||
--w7-el-grad-a: linear-gradient(#2c4657, #24384a 30% 50%, #1d2f3f 50%, #16242f);
|
|
||||||
--w7-li-bd-hl: #3f6c8c;
|
|
||||||
--w7-li-bg-hl: linear-gradient(#ffffff14, #4a6b8433 90%, #ffffff1a);
|
|
||||||
--w7-w-bd: #000000e6;
|
|
||||||
--w7-w-bg: #101013;
|
|
||||||
--w7-tab-bg: #1c1c1e;
|
|
||||||
--w7-title-c: #f5f5f5;
|
|
||||||
--w7-link-c: #6fb3ff;
|
|
||||||
/* literal restatements consumed by the skinfix rules (see :root.win7) */
|
|
||||||
--w7-lv-bg: var(--w7-el-bg-s-2);
|
|
||||||
--w7-lv-bd: var(--w7-el-bd-d);
|
|
||||||
--w7-lv-c: var(--w7-el-c);
|
|
||||||
--w7-lv-head-grad: linear-gradient(180deg, var(--w7-el-bg) 45%, var(--w7-el-bg-d) 0, var(--w7-el-bg-s-1));
|
|
||||||
--w7-lv-head-bd: var(--w7-el-bd-d);
|
|
||||||
--w7-lv-cell-bd: var(--w7-el-bd-d);
|
|
||||||
--w7-sel-bg: #2a4a63;
|
|
||||||
--w7-sel-bg-idle: #3a3a3a;
|
|
||||||
--w7-tab-c: var(--w7-el-c);
|
|
||||||
--w7-mn-c: var(--w7-el-c);
|
|
||||||
--w7-mn-bd: var(--w7-el-bd-d);
|
|
||||||
--w7-mn-stripe: inset 1px 0 #00000059, inset -1px 0 #ffffff14;
|
|
||||||
--w7-mn-divider: inset 0 1px #00000059, inset 0 -1px #ffffff14;
|
|
||||||
--w7-mn-check-ring: var(--w7-li-bd-hl);
|
|
||||||
--w7-mn-check-c: var(--w7-el-c);
|
|
||||||
--w7-glass-title: linear-gradient(135deg, #ffffff1a 70px, transparent 100px), linear-gradient(225deg, #ffffff1a 70px, transparent 100px), linear-gradient(54deg, #0003 0 4%, #ffffff08 6% 6%, #0003 8% 10%, #0003 15% 16%, #ffffff0d 17% 18%, #0003 23% 24%, #ffffff10 25% 26%, #0003 31% 33%, #0003 34% 34.5%, #ffffff10 36% 40%, #0003 41% 41.5%, #ffffff10 44% 45%, #ffffff10 46% 47%, #0003 48% 49%, #0003 50% 50.5%, #0003 56% 56.5%, #ffffff10 57% 63%, #0003 67% 69%, #ffffff10 69.5% 70%, #0003 73.5% 74%, #ffffff10 74.5% 79%, #0003 80% 84%, #ffffff0d 85% 86%, #0003 87%, #ffffff0a 90%) 0 / 100vw 100vh no-repeat fixed;
|
|
||||||
/* Ares: white caption on black glass, dark halo instead of white */
|
|
||||||
--w7-cap-c: var(--w7-title-c);
|
|
||||||
--w7-cap-glow: 0 0 8px #000c, 0 0 8px #000c, 0 1px 2px #000;
|
|
||||||
--w7-link-c-h: #9acbff;
|
|
||||||
|
|
||||||
--background: #1f1f1f;
|
|
||||||
--chrome: #2b2b2b;
|
|
||||||
--card: #2b2b2b;
|
|
||||||
--foreground: #f0f0f0;
|
|
||||||
--card-foreground: #f0f0f0;
|
|
||||||
--muted-foreground: #a0a0a0;
|
|
||||||
--secondary: #383838;
|
|
||||||
--secondary-foreground: #f0f0f0;
|
|
||||||
--muted: #383838;
|
|
||||||
--accent: #2f4a5c;
|
|
||||||
--accent-foreground: #f0f0f0;
|
|
||||||
--border: #3c3c3c;
|
|
||||||
--input: #55585c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark tone "darker": Aero has no meaningful true black, so rather than leave
|
|
||||||
the Tone row a dead control the skin just goes a step deeper. Duplicated
|
|
||||||
forced + system like everything else. */
|
|
||||||
:root.win7[data-theme='dark'][data-dark-tone='darker'] {
|
|
||||||
--w7-surface: #1a1a1a;
|
|
||||||
--w7-el-bg: #262626;
|
|
||||||
--w7-w-bg: #060607;
|
|
||||||
--background: #121212;
|
|
||||||
--chrome: #1a1a1a;
|
|
||||||
--card: #1a1a1a;
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root.win7:not([data-theme='light'])[data-dark-tone='darker'] {
|
|
||||||
--w7-surface: #1a1a1a;
|
|
||||||
--w7-el-bg: #262626;
|
|
||||||
--w7-w-bg: #060607;
|
|
||||||
--background: #121212;
|
|
||||||
--chrome: #1a1a1a;
|
|
||||||
--card: #1a1a1a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Accent -> Aero window colour, so the existing accent picker keeps meaning
|
|
||||||
under the skin. --w7-w-bg is the one colour 7.css documents as settable.
|
|
||||||
LIGHT ONLY: in dark the glass is Ares black regardless of accent (the accent
|
|
||||||
keeps tinting selection etc. through --primary). These arms out-rank the
|
|
||||||
accent rule below on specificity — without them it wins the tie on source
|
|
||||||
order and dark glass turns accent-coloured. */
|
|
||||||
:root.win7[data-accent] {
|
|
||||||
--w7-w-bg: var(--primary);
|
|
||||||
}
|
|
||||||
:root.win7[data-theme='dark'][data-accent] {
|
|
||||||
--w7-w-bg: #101013;
|
|
||||||
}
|
|
||||||
:root.win7[data-theme='dark'][data-dark-tone='darker'][data-accent] {
|
|
||||||
--w7-w-bg: #060607;
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root.win7:not([data-theme='light'])[data-accent] {
|
|
||||||
--w7-w-bg: #101013;
|
|
||||||
}
|
|
||||||
:root.win7:not([data-theme='light'])[data-dark-tone='darker'][data-accent] {
|
|
||||||
--w7-w-bg: #060607;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Unlayered on purpose: the html/body rule above sets font-family unlayered,
|
|
||||||
so a layered override would lose. Family only — NOT font-size: the app's
|
|
||||||
rem-based utilities are root-relative, and shifting the root would move
|
|
||||||
every line box for no gain. */
|
|
||||||
.win7 body {
|
|
||||||
font-family: var(--w7-family);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The win7 window is a FRAMELESS, natively transparent window whose only
|
|
||||||
opaque surface is meant to be `.window` — that is the whole premise of the
|
|
||||||
6px rounded frame, since the area outside the radius must let the desktop
|
|
||||||
through. The unlayered `html, body { background: var(--background) }` above
|
|
||||||
broke that premise silently: it painted an opaque rectangle behind the
|
|
||||||
frame, so the rounded corners had an opaque ground filling them in and the
|
|
||||||
window read as a rectangle with a drawn-on curve.
|
|
||||||
Unlayered on purpose, same reason as the font-family rule: the html/body
|
|
||||||
background it overrides is itself unlayered, and unlayered declarations beat
|
|
||||||
every @layer regardless of specificity — a `skinfix` rule could not touch
|
|
||||||
it. Scoped to .win7 so the modern skin keeps its opaque ground. */
|
|
||||||
html.win7,
|
|
||||||
html.win7 body {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Neutralizers live in `skin`, NOT `skinfix`, and this is load-bearing:
|
|
||||||
`revert-layer` rolls a property back to the previous LAYER, so from inside
|
|
||||||
`skin` it lands on `utilities` (the Tailwind classes already in the
|
|
||||||
templates) — which is the whole point. From `skinfix` it would land back on
|
|
||||||
7.css and do nothing. Within `skin` these beat the imported rules on equal
|
|
||||||
specificity by source order, since the import is at the top of this file. */
|
|
||||||
@layer skin {
|
|
||||||
/* 7.css sizes every <button> like a Win7 dialog push button
|
|
||||||
(min-width 75px, min-height 23px, padding 0 12px). Most of this app's 84
|
|
||||||
buttons are ~24px icon squares sized by utilities, so without this the tab
|
|
||||||
bar wraps and the options popover overflows. Hand the BOX MODEL back to
|
|
||||||
the utilities layer and keep 7.css's paint. The title bar's own controls
|
|
||||||
are matched by a MORE specific 7.css selector (.title-bar-controls button)
|
|
||||||
and so keep their Aero glyph metrics untouched. */
|
|
||||||
.win7 button {
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
padding: revert-layer;
|
|
||||||
text-align: revert-layer;
|
|
||||||
color: revert-layer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 7.css hardcodes #fff / #abadb3 / #222 on form controls and tables, outside
|
|
||||||
its own var system. Reverting routes them back through the bridged tokens
|
|
||||||
above, which is what makes dark mode nearly free. */
|
|
||||||
.win7 input[type='text'],
|
|
||||||
.win7 input[type='password'],
|
|
||||||
.win7 input[type='number'],
|
|
||||||
.win7 input[type='search'],
|
|
||||||
.win7 textarea {
|
|
||||||
background-color: revert-layer;
|
|
||||||
border-color: revert-layer;
|
|
||||||
}
|
|
||||||
.win7 table {
|
|
||||||
background-color: revert-layer;
|
|
||||||
color: revert-layer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Win7 toolbar buttons are FLAT with a hover glow, not Aero slabs —
|
|
||||||
Explorer's command bar worked exactly that way. `w7-toolbar` marks the
|
|
||||||
three header rows; a plain `.title-bar` sibling is deliberately NOT one of
|
|
||||||
them, so its controls keep the real Aero treatment. `w7-flat` is the
|
|
||||||
per-button escape hatch. Both classes are inert under the modern skin.
|
|
||||||
`:not([role='tab'])` is load-bearing: the workspace strip IS a w7-toolbar,
|
|
||||||
so without the exemption this rule would strip the border, gradient and
|
|
||||||
both pseudo-elements off every tab and leave 7.css's tab component drawing
|
|
||||||
nothing. Tabs want the full Aero slab; the +/Connections/options buttons
|
|
||||||
beside them want the flat treatment.
|
|
||||||
Only `background` stays here — it is the one property that needs to land
|
|
||||||
on `utilities` via revert-layer. The literals that suppress the Aero slab
|
|
||||||
moved to `skinfix`, because 7.css restates border-color at
|
|
||||||
`.win7 button:not(:disabled):not(:hover)` = (0,3,1), which outranks
|
|
||||||
`.win7 .w7-flat` = (0,2,0). The toolbar arm only ever won that fight by
|
|
||||||
tying on specificity and landing later in the same layer — an accident,
|
|
||||||
and one w7-flat did not share: it rendered a bordered slab regardless. */
|
|
||||||
.win7 .w7-toolbar button:not([role='tab']),
|
|
||||||
.win7 .w7-flat {
|
|
||||||
background: revert-layer;
|
|
||||||
}
|
|
||||||
.win7 .w7-toolbar button:not([role='tab'])::before,
|
|
||||||
.win7 .w7-toolbar button:not([role='tab'])::after,
|
|
||||||
.win7 .w7-flat::before,
|
|
||||||
.win7 .w7-flat::after {
|
|
||||||
content: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Above `skin`, for rules that must state a value rather than defer to a
|
|
||||||
neighbouring layer. */
|
|
||||||
@layer skinfix {
|
|
||||||
/* ---- Scale bridge: re-express 7.css's px metrics in rem ----
|
|
||||||
7.css sizes everything in px (370 of them, zero rem) and its --w7-font
|
|
||||||
shorthand reaches only 15 rules' TYPOGRAPHY — the boxes stay frozen. Under
|
|
||||||
the interface scale (root font-size lever) win7 text would grow inside
|
|
||||||
23px buttons, 14px table rows and a 15px-line-height caption. Every value
|
|
||||||
below is 7.css's own, converted at the 16px root (23px -> 1.4375rem …),
|
|
||||||
so scale 1.0 renders pixel-identical to stock and every other scale keeps
|
|
||||||
the boxes proportional to the text.
|
|
||||||
Deliberately NOT converted: 1px hairlines and focus rects (the tr border
|
|
||||||
that stops selection shifting rows, .is-cursor's 1px dotted outline, menu
|
|
||||||
bevels, the frame's inset glass ring) — those stay crisp at any scale. */
|
|
||||||
|
|
||||||
/* 7.css's own size vars — defined on .win7 in px; rem lands on the same
|
|
||||||
pixels at scale 1. Covers checkbox/radio/slider/tooltip/treeview/menu
|
|
||||||
gutters and both window metrics in one shot. */
|
|
||||||
.win7 {
|
|
||||||
--w7-cb-size: 0.875rem; /* 14px */
|
|
||||||
--w7-rd-size: 0.875rem;
|
|
||||||
--w7-rdd-size: 0.5rem; /* 8px */
|
|
||||||
--w7-rdl-space: 0.375rem; /* 6px */
|
|
||||||
--w7-sdt-w: 0.625rem; /* 10px */
|
|
||||||
--w7-sdt-h: 1.125rem; /* 18px */
|
|
||||||
--w7-blt-size: 1.125rem;
|
|
||||||
--w7-tvb-size: 0.5rem;
|
|
||||||
--w7-tv-left: 1.25rem; /* 20px */
|
|
||||||
--w7-clc-size: 0.3125rem; /* 5px */
|
|
||||||
--w7-lvc-size: 0.3125rem;
|
|
||||||
--w7-mn-left: 1.75rem; /* 28px */
|
|
||||||
--w7-el-bdr: 0.1875rem; /* 3px */
|
|
||||||
--w7-w-space: 0.375rem; /* 6px */
|
|
||||||
--w7-w-bdr: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The hardcoded box metrics the vars do not reach, restated in rem; skinfix
|
|
||||||
outranks skin by layer, so these win over 7.css.
|
|
||||||
NO blanket `.win7 button` rule here, deliberately: the `skin`-layer
|
|
||||||
neutralizer strips 7.css's 75x23 metrics off every button (most are icon
|
|
||||||
squares sized by utilities), and a skinfix restatement would outrank that
|
|
||||||
neutralizer by LAYER and re-inflate all of them. The only buttons that
|
|
||||||
legitimately wear Win7 push-button metrics are .w7-btn (opted in below)
|
|
||||||
and the title-bar controls (which beat the neutralizer on specificity
|
|
||||||
inside `skin` and are restated here). */
|
|
||||||
.win7 input[type='email'],
|
|
||||||
.win7 input[type='number'],
|
|
||||||
.win7 input[type='password'],
|
|
||||||
.win7 input[type='search'],
|
|
||||||
.win7 input[type='text'],
|
|
||||||
.win7 input[type='url'] {
|
|
||||||
height: 1.4375rem;
|
|
||||||
}
|
|
||||||
.win7 input[type='email'],
|
|
||||||
.win7 input[type='number'],
|
|
||||||
.win7 input[type='password'],
|
|
||||||
.win7 input[type='search'],
|
|
||||||
.win7 input[type='text'],
|
|
||||||
.win7 input[type='url'],
|
|
||||||
.win7 textarea {
|
|
||||||
padding: 0.1875rem 0.25rem 0.3125rem; /* 3px 4px 5px */
|
|
||||||
}
|
|
||||||
.win7 table > thead > tr > * {
|
|
||||||
height: 1.375rem; /* 22px */
|
|
||||||
padding: 0 0.5rem; /* 0 8px */
|
|
||||||
}
|
|
||||||
.win7 table > tbody > tr > * {
|
|
||||||
height: 0.875rem; /* 14px */
|
|
||||||
padding: 0.125rem 0.5rem; /* 2px 8px */
|
|
||||||
}
|
|
||||||
.win7 .title-bar-text {
|
|
||||||
line-height: 0.9375rem; /* 15px */
|
|
||||||
}
|
|
||||||
.win7 .title-bar-controls button {
|
|
||||||
min-height: 1.1875rem; /* 19px */
|
|
||||||
min-width: 1.8125rem; /* 29px */
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
/* BOTH of 7.css's wide-close selectors, not just .is-close: the app's title
|
|
||||||
bars key the buttons on aria-label, and the base rule above outranks the
|
|
||||||
`skin`-layer variant BY LAYER — restating only one arm shrank the close
|
|
||||||
button from 48px to 29px (caught by the harness pixel diff). */
|
|
||||||
.win7 .title-bar-controls button.is-close,
|
|
||||||
.win7 .title-bar-controls button[aria-label='Close'] {
|
|
||||||
min-width: 3rem; /* 48px */
|
|
||||||
}
|
|
||||||
.win7 ul[role='menu'] {
|
|
||||||
min-width: 9.375rem; /* 150px */
|
|
||||||
padding: 0.125rem; /* 2px */
|
|
||||||
}
|
|
||||||
.win7 ul[role='menu'] > [role='menuitem'] > a,
|
|
||||||
.win7 ul[role='menu'] > [role='menuitem'] > button,
|
|
||||||
.win7 ul[role='menu'] > [role='menuitem'] > label {
|
|
||||||
padding: 0.25rem 0.625rem 0.25rem 2rem; /* 4px 10px 4px 32px */
|
|
||||||
}
|
|
||||||
.win7 menu[role='tablist'] button {
|
|
||||||
padding: 0.125rem 0.375rem; /* 2px 6px */
|
|
||||||
}
|
|
||||||
/* The raised active tab: 7.css gives it padding-bottom 4px at (0,2,2) inside
|
|
||||||
`skin`, but the shorthand above outranks that BY LAYER and would flatten
|
|
||||||
the raise — so the variant is restated here too. */
|
|
||||||
.win7 menu[role='tablist'] button[aria-selected='true'] {
|
|
||||||
padding-bottom: 0.25rem; /* 4px */
|
|
||||||
}
|
|
||||||
/* The 10pt checkmark glyph in menu checkables — one of five pt sizes in
|
|
||||||
7.css that bypass --w7-font entirely. 10pt = 13.333px ≈ 0.8333rem. */
|
|
||||||
.win7 ul [role='menuitem'] > input[type='checkbox']:checked + label:after {
|
|
||||||
font-size: 0.8333rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Opt back IN to real Win7 push-button metrics, for the dialog and options
|
|
||||||
buttons where they are right. Same rem values as the button rule above —
|
|
||||||
this class exists to undo the `skin`-layer neutralizer, not to differ. */
|
|
||||||
.win7 .w7-btn {
|
|
||||||
min-width: 4.6875rem;
|
|
||||||
min-height: 1.4375rem;
|
|
||||||
padding: 0 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- ListView (file panels) ----
|
|
||||||
7.css's listview.css hardcodes light literals (#fff, #c0c1cd, #eee and the
|
|
||||||
white header gradient) instead of --w7-* vars, so unlike the rest of the
|
|
||||||
skin it does NOT follow the hand-authored dark palette — the header labels
|
|
||||||
came out invisible, dark on dark. These restate the same rules in dark
|
|
||||||
terms; light keeps 7.css's own values untouched.
|
|
||||||
|
|
||||||
CRITICAL: the base surface goes on `table`, NEVER on `tbody > tr > *`.
|
|
||||||
7.css paints the selection band on the ROW, and a cell background paints
|
|
||||||
over a row background — setting one erased the selection highlight
|
|
||||||
entirely in dark mode. */
|
|
||||||
.win7 table {
|
|
||||||
background-color: var(--w7-lv-bg);
|
|
||||||
border-color: var(--w7-lv-bd);
|
|
||||||
color: var(--w7-lv-c);
|
|
||||||
}
|
|
||||||
.win7 table > thead > tr > * {
|
|
||||||
background: var(--w7-lv-head-grad);
|
|
||||||
border-color: var(--w7-lv-head-bd);
|
|
||||||
color: var(--w7-lv-c);
|
|
||||||
}
|
|
||||||
.win7 table > tbody > tr > :not(:last-child) {
|
|
||||||
border-right-color: var(--w7-lv-cell-bd);
|
|
||||||
}
|
|
||||||
/* 7.css drops the cell separators inside a selected row; the rule above is
|
|
||||||
more specific than its, so restore that behaviour explicitly. */
|
|
||||||
.win7 table > tbody > tr.highlighted > :not(:last-child) {
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
.win7 table > tbody > tr.highlighted {
|
|
||||||
background: var(--w7-sel-bg-idle);
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Window frame ----
|
|
||||||
The native window is transparent (chromeOptions in src/bun/windows.ts), and
|
|
||||||
nothing but .window may paint an opaque surface — that is what lets the 6px
|
|
||||||
corner radius actually be transparent at the corners. That invariant is NOT
|
|
||||||
free: html/body paint an opaque background unlayered, and had to be reset
|
|
||||||
to transparent for .win7 (see the rule above @layer skin). Anything else
|
|
||||||
that paints full-bleed will fill the corners the same way.
|
|
||||||
NO drop shadow, by request: 7.css's `2px 2px 10px 1px #000000b3` needed a
|
|
||||||
margin gutter to fall into and still read heavy against a real desktop, so
|
|
||||||
the frame keeps only the inset 1px glass highlight from that shorthand
|
|
||||||
(dropping the whole box-shadow would lose the highlight too — it lives in
|
|
||||||
the same declaration). No shadow -> no gutter -> full-bleed 100vh.
|
|
||||||
Keyed on `w7-frame` — the app hook the three window ROOTS carry — never on
|
|
||||||
bare .window: on any other .window (dialogs, demos) this sizing is
|
|
||||||
nonsense and 7.css's own shadow is fine. */
|
|
||||||
.win7 .window.w7-frame {
|
|
||||||
margin: 0;
|
|
||||||
height: 100vh;
|
|
||||||
box-shadow: inset 0 0 0 1px #fffa;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A maximized Win7 window has SQUARE corners — the radius only exists to let
|
|
||||||
the desktop show past a floating frame, and a rounded maximized window
|
|
||||||
leaves four notches of desktop in the screen corners.
|
|
||||||
Read off the title bar's own `is-maximized` (title-bar.ts publishes it on
|
|
||||||
its host) with :has(), so the frame reacts without a second windowState
|
|
||||||
subscription. .title-bar is a DIRECT child of .window — app-title-bar
|
|
||||||
carries the class on its host element, which is why `>` holds here.
|
|
||||||
The radius is declared three times in 7.css — on .window and on both the
|
|
||||||
::before tint and the ::after glass layer — so all three must be zeroed or
|
|
||||||
a rounded pseudo-element still cuts the corner off the square frame. */
|
|
||||||
.win7 .window.w7-frame:has(> .title-bar.is-maximized),
|
|
||||||
.win7 .window.w7-frame:has(> .title-bar.is-maximized)::before,
|
|
||||||
.win7 .window.w7-frame:has(> .title-bar.is-maximized)::after {
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* STOPGAP — the synthesized resize handles (window-resize/window-resize.ts,
|
|
||||||
which carries the removal checklist). A maximized window has no edges to
|
|
||||||
grab, so the overlay goes away entirely while it is maximized, and comes
|
|
||||||
back on restore.
|
|
||||||
Read off the title bar's own `is-maximized` with :has(), exactly like the
|
|
||||||
corner-squaring rule above — that is what keeps this component free of a
|
|
||||||
second `windowState` subscription (RpcService's push registries have no
|
|
||||||
off(), so every subscription is permanent). */
|
|
||||||
.win7 .window.w7-frame:has(> .title-bar.is-maximized) .w7-resize {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hovering the close button paints 7.css's red glow ::before
|
|
||||||
(box-shadow: 0 0 7px 3px #e68e75), and box-shadows of CHILDREN are not
|
|
||||||
clipped by the parent's border or radius — the caption buttons sit flush
|
|
||||||
against the window's top edge (border-top: 0), so the glow spilled ABOVE
|
|
||||||
the frame: a red tint across the top border and a red halo floating on the
|
|
||||||
desktop past it (scripts/hover-probe.ts renders the forced-hover state and
|
|
||||||
pixel-scans for exactly this; it caught 100 red pixels above the edge).
|
|
||||||
Clip the title bar's children to its own box on top/left/right — the glow
|
|
||||||
now dies at the frame border, which is where the real Win7 DWM stopped it
|
|
||||||
too — while the bottom stays open (-1rem) so the glow's bleed toward the
|
|
||||||
caption below survives; that part is authentic.
|
|
||||||
clip-path also clips HIT-TESTING, but only for content outside the region:
|
|
||||||
the title-bar's own box (the drag region) is entirely inside it.
|
|
||||||
Scoped to w7-frame: demo/harness windows keep stock 7.css behaviour. */
|
|
||||||
.win7 .window.w7-frame > .title-bar {
|
|
||||||
clip-path: inset(0 0 -1rem 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 7.css's glass layer is `backdrop-filter: blur(4px)` on .window::after —
|
|
||||||
but CSS cannot sample the DESKTOP behind a transparent native window
|
|
||||||
(there is no backdrop to read), and WebKitGTK paints the unsampleable
|
|
||||||
layer BLACK: every see-through part of the glass — the frame ring, the
|
|
||||||
area around the title-bar buttons — composited over black instead of over
|
|
||||||
the desktop. With it off, the real desktop shows through the glass tint,
|
|
||||||
which is closer to Aero than a blur we cannot have. (True blur-behind
|
|
||||||
needs compositor support; GNOME offers none to X11/XWayland clients.) */
|
|
||||||
.win7 .window.glass::after {
|
|
||||||
-webkit-backdrop-filter: none;
|
|
||||||
backdrop-filter: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Constant row geometry. 7.css gives ONLY tr.highlighted a 1px border, and
|
|
||||||
the table is border-collapse: collapse — so a selected row physically grew
|
|
||||||
1px at top and bottom and shoved every row below it down by 2px as the
|
|
||||||
cursor moved. Every row reserves that border instead, transparent when not
|
|
||||||
selected, so selecting changes colour and nothing else.
|
|
||||||
The border stays TRANSPARENT even when selected: it exists purely to
|
|
||||||
reserve space. 7.css outlines the selected row (#aaddfa) on top of its
|
|
||||||
fill, which stacked a second edge inside the band and, with the cursor
|
|
||||||
row's dotted rect, drew three concentric outlines around one row. The fill
|
|
||||||
paints under the transparent border (background-clip defaults to
|
|
||||||
border-box), so the band still covers the full row. */
|
|
||||||
.win7 table > tbody > tr {
|
|
||||||
border: 1px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pane-focus model (commander convention, matching Win7 Explorer):
|
|
||||||
the CURSOR ROW always carries the selection band in BOTH panels —
|
|
||||||
tr.highlighted covers "selected via Space" AND "cursor row" (the template
|
|
||||||
ORs them). What changes with pane focus is the band's strength, never
|
|
||||||
whether it exists:
|
|
||||||
- focused pane -> the flat blue selection (below)
|
|
||||||
- unfocused pane -> the grey idle band, which is what Explorer drew
|
|
||||||
when the list lost focus — faint by design, not a bug.
|
|
||||||
The dotted focus rectangle is NOT the focus signal; it is layered on top
|
|
||||||
of the band in the focused pane only, the way Win7 drew the keyboard
|
|
||||||
focus rect over the selected item.
|
|
||||||
FLAT, and only a fill: 7.css bands the row with --w7-el-grad-h, its Aero
|
|
||||||
element-hover ramp, which reads as a glossy button rather than a file
|
|
||||||
selection, and outlines it as well. One solid colour, no edge — the
|
|
||||||
dotted cursor rect is then the only line on the row, which is what makes
|
|
||||||
it legible as the cursor. */
|
|
||||||
.win7 table.is-active > tbody > tr.highlighted {
|
|
||||||
background: var(--w7-sel-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* One dotted rectangle around the ROW; per cell (tr > *) draws a dotted box
|
|
||||||
around every cell instead — visibly wrong. */
|
|
||||||
.win7 table > tbody > tr.is-cursor {
|
|
||||||
outline: 1px dotted var(--w7-el-c);
|
|
||||||
outline-offset: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Menu (context menu) ----
|
|
||||||
The menu itself is pure 7.css — ul[role=menu] > li[role=menuitem], see
|
|
||||||
ui/src/app/context-menu.ts for the exact contract. (A previous iteration
|
|
||||||
claimed "7.css has no menu component" and hand-built a lookalike here;
|
|
||||||
false — the markup was one level off and its selectors never matched.)
|
|
||||||
Only menu.css's hardcoded LIGHT literals need restating for the dark
|
|
||||||
palette, same as the ListView above: the black `color:initial`, the
|
|
||||||
white bevel halves of the gutter stripe and dividers, and the checked-box
|
|
||||||
ring (#b3d3f9). */
|
|
||||||
.win7 ul[role='menu'] {
|
|
||||||
color: var(--w7-mn-c);
|
|
||||||
border-color: var(--w7-mn-bd);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* THE POSITIONING ONE — this is why right-click looked dead under the skin.
|
|
||||||
menu.css declares `position: relative` on ul[role=menu] (it needs it for
|
|
||||||
the gutter stripe's ::before and for submenu anchoring), and `skin`
|
|
||||||
outranks `utilities`, so Tailwind's `.fixed` on the app's menu lost. Its
|
|
||||||
[style.left/top.px] then became FLOW offsets from the menu's static
|
|
||||||
position — the last child of an h-screen flex column — and every menu
|
|
||||||
rendered below the viewport, unreachable behind `overflow: hidden`. The
|
|
||||||
backdrop is a plain <div>, which 7.css does not match, so it stayed truly
|
|
||||||
fixed and swallowed the next click: "right click does nothing, then the
|
|
||||||
app ignores me once".
|
|
||||||
Scoped to the app menu's own marker class, never blanket: 7.css's own
|
|
||||||
relative positioning must survive for any other ul[role=menu].
|
|
||||||
Regression test: scripts/menu-probe.ts (a resting pixel diff cannot see a
|
|
||||||
positioning bug — nothing about the idle window changes). */
|
|
||||||
.win7 ul[role='menu'].w7-ctx {
|
|
||||||
position: fixed;
|
|
||||||
}
|
|
||||||
.win7 ul[role='menu']:before {
|
|
||||||
box-shadow: var(--w7-mn-stripe);
|
|
||||||
}
|
|
||||||
.win7 ul [role='menuitem'].has-divider:after {
|
|
||||||
box-shadow: var(--w7-mn-divider);
|
|
||||||
}
|
|
||||||
.win7 ul [role='menuitem'] > input[type] + label:before {
|
|
||||||
box-shadow: 0 0 0 1px var(--w7-mn-check-ring);
|
|
||||||
}
|
|
||||||
.win7 ul [role='menuitem'] > input[type='checkbox']:checked + label:after {
|
|
||||||
color: var(--w7-mn-check-c);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Menu items: flat highlight, and no Aero afterglow trailing the pointer.
|
|
||||||
Menu items ARE <button>s, so button.css reaches them with three effects
|
|
||||||
that do not belong on a menu:
|
|
||||||
(1) the highlight is 7.css's translucent wash
|
|
||||||
`linear-gradient(#fff9, #e6ecf5cc 90%, #fffc)` + an #aaddfa edge — the
|
|
||||||
same gradient the ListView selection above just dropped;
|
|
||||||
(2) `button::before` is a blue glow overlay whose opacity fades out over
|
|
||||||
a FULL SECOND (`button:not(:hover):before { transition: opacity 1s
|
|
||||||
linear }`). menu.css sets `content: none` on the :HOVER arm only, so
|
|
||||||
the pseudo reappears the instant you leave an item and then fades 1→0
|
|
||||||
— a ghost dragging behind the pointer. Killing `content` in EVERY
|
|
||||||
state is what removes it; suppressing the transition alone would not,
|
|
||||||
because the glow itself is legitimate on real push buttons;
|
|
||||||
(3) `button:focus` runs `pulse-anim` infinitely, and this menu moves REAL
|
|
||||||
focus between items for keyboard nav — so a focused item would breathe.
|
|
||||||
Scoped to the app's own menu (w7-ctx): 7.css's demo menus keep stock Aero. */
|
|
||||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button,
|
|
||||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > label {
|
|
||||||
transition: none;
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button::before,
|
|
||||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button::after {
|
|
||||||
content: none;
|
|
||||||
}
|
|
||||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button:hover,
|
|
||||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button:focus,
|
|
||||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button:focus-visible,
|
|
||||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > label:hover,
|
|
||||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > label:focus-visible {
|
|
||||||
background: var(--w7-sel-bg);
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Flat buttons (toolbars + the w7-flat escape hatch) ----
|
|
||||||
The paint-suppression half of the rule in `skin` (see there for the split).
|
|
||||||
It lives in `skinfix` because 7.css restates border-color at (0,3,1) via
|
|
||||||
`.win7 button:not(:disabled):not(:hover)` and again on :hover, which no
|
|
||||||
reasonable `.w7-flat` selector outranks — layer order settles it instead
|
|
||||||
of a specificity arms race. */
|
|
||||||
.win7 .w7-toolbar button:not([role='tab']),
|
|
||||||
.win7 .w7-flat {
|
|
||||||
border-color: transparent;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Tabs (workspace strip) ----
|
|
||||||
7.css owns the tab CHROME; these restore the tab CONTENT layout it breaks.
|
|
||||||
`menu[role=tablist] button` sets `display: block` at (0,2,1), which
|
|
||||||
outranks Tailwind's `.flex` in `utilities` — so the icon and the label
|
|
||||||
rendered jammed together with no gap and no truncation. Restate the flex
|
|
||||||
box here (skinfix outranks skin) and leave every painted property alone.
|
|
||||||
The gap matches the gap-1.5 the template asks for. */
|
|
||||||
.win7 menu[role='tablist'] button[role='tab'] {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* tabs.css hardcodes `color: #222` on every tablist button — a light-mode
|
|
||||||
literal outside 7.css's var system, which left the labels near-invisible
|
|
||||||
on the dark surface. Same failure and same remedy as the ListView and menu
|
|
||||||
literals above: route it through a token that both dark blocks override. */
|
|
||||||
.win7 menu[role='tablist'] button {
|
|
||||||
color: var(--w7-tab-c);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The workspace strip scrolls horizontally when tabs overflow. Setting
|
|
||||||
`overflow-x: auto` forces the computed `overflow-y` from visible to auto
|
|
||||||
as well (CSS will not pair visible with a non-visible axis), so the strip
|
|
||||||
clips vertically — and 7.css raises the ACTIVE tab with `margin-top: -2px`,
|
|
||||||
which put its top border outside the scroll box and silently deleted it.
|
|
||||||
Reserve those 2px as padding so the pulled-up tab lands inside the box.
|
|
||||||
7.css already reserves the matching 3px on the left (padding-left: 3px)
|
|
||||||
for the same tab's -3px left margin; it just never anticipated a scroller.
|
|
||||||
Verified by pixel scan: the active tab's top edge must be #8e8f8f, not a
|
|
||||||
straight jump from the toolbar grey to the tab fill. */
|
|
||||||
.win7 .w7-toolbar menu[role='tablist'] {
|
|
||||||
padding-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.win7 .window.glass > .title-bar {
|
|
||||||
background: var(--w7-glass-title);
|
|
||||||
background-attachment: fixed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Caption text: 7.css hardcodes black + a white halo; in dark that becomes
|
|
||||||
the Ares treatment (white caption, dark halo) through the same vars. */
|
|
||||||
.win7 .title-bar-text {
|
|
||||||
color: var(--w7-cap-c);
|
|
||||||
text-shadow: var(--w7-cap-glow);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<!-- .window.glass is 7.css's Aero frame (radius, 1px border, inset highlight,
|
|
||||||
drop shadow, backdrop blur); .active is what un-greys the title bar and its
|
|
||||||
control glyphs — every .title-bar-controls rule is keyed on it. -->
|
|
||||||
<div class="window glass w7-frame flex h-screen flex-col" [class.active]="windowFocused()">
|
|
||||||
@if (!rpc.available) {
|
|
||||||
<div class="flex flex-1 items-center justify-center">
|
|
||||||
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm">
|
|
||||||
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
|
|
||||||
<p class="mt-2 text-sm font-medium">{{ i18n.t('app.notConnectedTitle') }}</p>
|
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
|
||||||
{{ i18n.t('app.notConnectedBody') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<!-- the window is frameless, so this IS the chrome -->
|
|
||||||
<app-title-bar [title]="appName" (closeRequested)="closeWindow()" />
|
|
||||||
|
|
||||||
<!-- 7.css's .window-body: the recessed inner panel, with the 6px inset and
|
|
||||||
its own border that give a Win7 window its depth. -->
|
|
||||||
<div class="window-body flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
||||||
<app-tab-bar />
|
|
||||||
|
|
||||||
<main class="flex min-h-0 flex-1 divide-x divide-border">
|
|
||||||
<app-file-panel side="left" />
|
|
||||||
<app-file-panel side="right" />
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<app-transfer-bar />
|
|
||||||
<app-status-bar />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- modal block: the connection-manager window owns the app until closed -->
|
|
||||||
@if (managerOpen()) {
|
|
||||||
<div
|
|
||||||
class="fixed inset-0 z-60 flex items-center justify-center bg-black/40"
|
|
||||||
(mousedown)="focusManager()"
|
|
||||||
>
|
|
||||||
<div class="rounded-lg border border-border bg-card px-5 py-4 text-center shadow-xl">
|
|
||||||
<ng-icon name="mynaServers" size="1.25rem" class="text-muted-foreground" />
|
|
||||||
<p class="mt-1.5 text-xs text-muted-foreground">{{ i18n.t('app.managerOpen') }}</p>
|
|
||||||
<button
|
|
||||||
class="mt-3 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
{{ i18n.t('app.showConnections') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- dialogs -->
|
|
||||||
@if (dialogs.prompt(); as req) {
|
|
||||||
<app-prompt-dialog [request]="req" />
|
|
||||||
}
|
|
||||||
@if (dialogs.confirm(); as req) {
|
|
||||||
<app-confirm-dialog [request]="req" />
|
|
||||||
}
|
|
||||||
@if (dialogs.chmod(); as req) {
|
|
||||||
<app-chmod-dialog [request]="req" />
|
|
||||||
}
|
|
||||||
@if (dialogs.overwrite(); as req) {
|
|
||||||
<app-overwrite-dialog [request]="req" />
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- STOPGAP: the frameless window has no WM resize borders, so we draw our
|
|
||||||
own handles. Delete with the rest of the hack — checklist in
|
|
||||||
window-resize/window-resize.ts. -->
|
|
||||||
<app-window-resize />
|
|
||||||
|
|
||||||
<app-context-menu />
|
|
||||||
<app-toasts />
|
|
||||||
</div>
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
|
||||||
import { NgIcon } from '@ng-icons/core';
|
|
||||||
import { I18nService } from '@core/i18n.service';
|
|
||||||
import { BRANDING } from '@shared/branding';
|
|
||||||
import type { ColumnKey } from '@shared/types';
|
|
||||||
import { ConnectionsService } from '@core/connections.service';
|
|
||||||
import { ContextMenu } from './context-menu';
|
|
||||||
import { ChmodDialog } from './dialogs/chmod-dialog';
|
|
||||||
import { ConfirmDialog } from './dialogs/confirm-dialog';
|
|
||||||
import { OverwriteDialog } from './dialogs/overwrite-dialog';
|
|
||||||
import { PromptDialog } from './dialogs/prompt-dialog';
|
|
||||||
import { DialogsService } from '@core/dialogs.service';
|
|
||||||
import { FileActionsService } from '@core/file-actions.service';
|
|
||||||
import { FilePanel } from './file-panel/file-panel';
|
|
||||||
import { PanelsService } from '@core/panels.service';
|
|
||||||
import { blockNativeContextMenu } from '@core/platform';
|
|
||||||
import { RpcService } from '@core/rpc.service';
|
|
||||||
import { SettingsService } from '@core/settings.service';
|
|
||||||
import { ShortcutsService } from '@core/shortcuts.service';
|
|
||||||
import { StatusBar } from './status-bar/status-bar';
|
|
||||||
import { TabBar } from './tab-bar/tab-bar';
|
|
||||||
import { TitleBar } from './title-bar/title-bar';
|
|
||||||
import { ToastService } from '@core/toast.service';
|
|
||||||
import { Toasts } from './toasts/toasts';
|
|
||||||
import { TransferBar } from './transfer-bar/transfer-bar';
|
|
||||||
import { WindowResize } from './window-resize/window-resize';
|
|
||||||
import { TransfersService } from '@core/transfers.service';
|
|
||||||
import { UpdaterService } from '@core/updater.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-root',
|
|
||||||
imports: [
|
|
||||||
NgIcon,
|
|
||||||
FilePanel,
|
|
||||||
TabBar,
|
|
||||||
StatusBar,
|
|
||||||
PromptDialog,
|
|
||||||
ConfirmDialog,
|
|
||||||
ChmodDialog,
|
|
||||||
OverwriteDialog,
|
|
||||||
Toasts,
|
|
||||||
TransferBar,
|
|
||||||
TitleBar,
|
|
||||||
ContextMenu,
|
|
||||||
WindowResize,
|
|
||||||
],
|
|
||||||
templateUrl: './app.html',
|
|
||||||
host: {
|
|
||||||
'(window:keydown)': 'onWindowKeydown($event)',
|
|
||||||
'(window:contextmenu)': 'blockContextMenu($event)',
|
|
||||||
// Aero greys an inactive window's title bar and controls. The webview gets
|
|
||||||
// DOM focus/blur when the native window's focus changes, so this needs no
|
|
||||||
// RPC — see the `active` binding on .window in app.html.
|
|
||||||
'(window:focus)': 'windowFocused.set(true)',
|
|
||||||
'(window:blur)': 'windowFocused.set(false)',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export class App {
|
|
||||||
readonly i18n = inject(I18nService);
|
|
||||||
readonly rpc = inject(RpcService);
|
|
||||||
readonly toast = inject(ToastService);
|
|
||||||
readonly dialogs = inject(DialogsService);
|
|
||||||
readonly panels = inject(PanelsService);
|
|
||||||
readonly actions = inject(FileActionsService);
|
|
||||||
readonly connections = inject(ConnectionsService);
|
|
||||||
private readonly transfers = inject(TransfersService);
|
|
||||||
private readonly settings = inject(SettingsService);
|
|
||||||
private readonly shortcuts = inject(ShortcutsService);
|
|
||||||
private readonly updater = inject(UpdaterService);
|
|
||||||
|
|
||||||
/** The connection-manager window is open — this window is modal-blocked. */
|
|
||||||
readonly managerOpen = signal(false);
|
|
||||||
|
|
||||||
/** Drives `.window.active`: Aero desaturates an unfocused window's chrome. */
|
|
||||||
readonly windowFocused = signal(document.hasFocus());
|
|
||||||
|
|
||||||
/** Shown in the win7 title bar (no native titlebar to carry it). */
|
|
||||||
readonly appName = BRANDING.appName;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* win7 title bar's X. Closing the main window quits, and the app-level
|
|
||||||
* before-quit guard (dirty editors + running transfers) still runs — it is
|
|
||||||
* the one close path electrobun lets us cancel.
|
|
||||||
*/
|
|
||||||
closeWindow(): void {
|
|
||||||
void this.rpc.request.windowClose({});
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
document.title = BRANDING.appName;
|
|
||||||
if (this.rpc.available) {
|
|
||||||
this.rpc.onMenuAction((action) => void this.handleMenuAction(action));
|
|
||||||
this.rpc.onManagerState((open) => this.managerOpen.set(open));
|
|
||||||
void this.promptForEditLeftovers();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
focusManager(): void {
|
|
||||||
void this.rpc.request.openConnectionManager({});
|
|
||||||
}
|
|
||||||
|
|
||||||
blockContextMenu(event: Event): void {
|
|
||||||
blockNativeContextMenu(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Temp files from external edits in a previous run (crash / quit mid-edit). */
|
|
||||||
private async promptForEditLeftovers(): Promise<void> {
|
|
||||||
const res = await this.rpc.request.getEditLeftovers({});
|
|
||||||
if (!res.ok || !res.names.length) return;
|
|
||||||
const shown = res.names.slice(0, 5).join(', ');
|
|
||||||
const more =
|
|
||||||
res.names.length > 5 ? this.i18n.t('toasts.leftoverMore', { count: res.names.length - 5 }) : '';
|
|
||||||
this.dialogs.confirm.set({
|
|
||||||
title: this.i18n.t('toasts.leftoverTitle'),
|
|
||||||
body: this.i18n.t('toasts.leftoverBody', { count: res.names.length, names: shown + more }),
|
|
||||||
confirmLabel: this.i18n.t('toasts.leftoverClear'),
|
|
||||||
destructive: true,
|
|
||||||
onConfirm: () => void this.rpc.request.clearEditLeftovers({}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A connection by id; reloads once if unseen (a manager-window save can
|
|
||||||
* race the connectionsChanged push). */
|
|
||||||
private async findConnection(id: string) {
|
|
||||||
const lookup = () => this.connections.connections().find((c) => c.id === id);
|
|
||||||
if (!lookup()) await this.connections.reload();
|
|
||||||
return lookup();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Native application-menu / manager-window actions forwarded from bun. */
|
|
||||||
private async handleMenuAction(action: string): Promise<void> {
|
|
||||||
if (action.startsWith('ctx-')) {
|
|
||||||
this.handleContextAction(action);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (action.startsWith('connect:')) {
|
|
||||||
const connection = await this.findConnection(action.slice('connect:'.length));
|
|
||||||
if (connection) {
|
|
||||||
// remote goes right, local stays left — the commander convention
|
|
||||||
void this.panels.connectPanel('right', connection, connection.name, connection.localDir);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (action.startsWith('col:')) {
|
|
||||||
this.settings.toggleColumn(action.slice('col:'.length) as ColumnKey);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (action.startsWith('tab:')) {
|
|
||||||
this.handleTabAction(action.slice('tab:'.length));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (action.startsWith('open-stack:')) {
|
|
||||||
const id = action.slice('open-stack:'.length);
|
|
||||||
let stackConnections = this.connections.connections().filter((c) => c.stackId === id);
|
|
||||||
if (!stackConnections.length) {
|
|
||||||
await this.connections.reload();
|
|
||||||
stackConnections = this.connections.connections().filter((c) => c.stackId === id);
|
|
||||||
}
|
|
||||||
if (stackConnections.length) this.panels.openStack(stackConnections);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
switch (action) {
|
|
||||||
case 'new-tab':
|
|
||||||
this.panels.newWorkspace();
|
|
||||||
break;
|
|
||||||
case 'close-tab':
|
|
||||||
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
|
|
||||||
break;
|
|
||||||
case 'new-folder':
|
|
||||||
this.actions.mkdir(this.panels.active());
|
|
||||||
break;
|
|
||||||
case 'new-file':
|
|
||||||
this.actions.createFile(this.panels.active());
|
|
||||||
break;
|
|
||||||
case 'edit-file':
|
|
||||||
void this.actions.edit(this.panels.active());
|
|
||||||
break;
|
|
||||||
case 'refresh-active':
|
|
||||||
void this.panels.active().refresh();
|
|
||||||
break;
|
|
||||||
case 'check-for-updates':
|
|
||||||
void this.updater.check();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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 {
|
|
||||||
if (this.managerOpen()) return; // modal-blocked behind the manager
|
|
||||||
const match = this.shortcuts.resolve(event, 'main');
|
|
||||||
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();
|
|
||||||
switch (match.id) {
|
|
||||||
case 'new-tab':
|
|
||||||
this.panels.newWorkspace();
|
|
||||||
break;
|
|
||||||
case 'close-tab':
|
|
||||||
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
|
|
||||||
break;
|
|
||||||
case 'cycle-tab':
|
|
||||||
this.panels.cycleWorkspace(1);
|
|
||||||
break;
|
|
||||||
case 'cycle-tab-back':
|
|
||||||
this.panels.cycleWorkspace(-1);
|
|
||||||
break;
|
|
||||||
case 'go-to-tab':
|
|
||||||
// ⌘1–⌘8 jump to that tab, ⌘9 to the last, like Firefox
|
|
||||||
this.panels.goToWorkspace(match.digit === 9 ? -1 : match.digit! - 1);
|
|
||||||
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. */
|
|
||||||
/**
|
|
||||||
* 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 {
|
|
||||||
const target = this.actions.menuTarget;
|
|
||||||
if (!target) return;
|
|
||||||
const { panel, side, entry } = target;
|
|
||||||
// the empty-space menu acts on the directory itself
|
|
||||||
const path = entry?.path ?? panel.cwd();
|
|
||||||
switch (action) {
|
|
||||||
case 'ctx-open':
|
|
||||||
if (entry) panel.open(entry);
|
|
||||||
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':
|
|
||||||
void this.actions.edit(panel);
|
|
||||||
break;
|
|
||||||
case 'ctx-copy':
|
|
||||||
void this.transfers.copyToOtherPanel(side);
|
|
||||||
break;
|
|
||||||
case 'ctx-rename':
|
|
||||||
this.actions.rename(panel);
|
|
||||||
break;
|
|
||||||
case 'ctx-chmod':
|
|
||||||
this.actions.chmod(panel);
|
|
||||||
break;
|
|
||||||
case 'ctx-delete':
|
|
||||||
this.actions.delete(panel);
|
|
||||||
break;
|
|
||||||
case 'ctx-trash':
|
|
||||||
if (entry) void this.actions.trash(panel, entry);
|
|
||||||
break;
|
|
||||||
case 'ctx-new-folder':
|
|
||||||
this.actions.mkdir(panel);
|
|
||||||
break;
|
|
||||||
case 'ctx-new-file':
|
|
||||||
this.actions.createFile(panel);
|
|
||||||
break;
|
|
||||||
case 'ctx-refresh':
|
|
||||||
void panel.refresh();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,390 +0,0 @@
|
|||||||
<div class="window glass w7-frame flex h-screen flex-col" [class.active]="windowFocused()">
|
|
||||||
<!-- Frameless window. Fixed-size modal, so Close only. -->
|
|
||||||
<app-title-bar
|
|
||||||
[title]="i18n.t('connMgr.title')"
|
|
||||||
[resizable]="false"
|
|
||||||
(closeRequested)="close()"
|
|
||||||
/>
|
|
||||||
<!-- Toolbar strip under the title bar. h-9/pl-3 are load-bearing: without a
|
|
||||||
height this row collapses onto the icon and the screen reads as broken. -->
|
|
||||||
<header
|
|
||||||
class="w7-toolbar flex h-9 shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2 pl-3"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaServers" size="0.95rem" class="shrink-0 text-muted-foreground" />
|
|
||||||
<h1 class="text-sm font-semibold">{{ i18n.t('connMgr.title') }}</h1>
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
<button
|
|
||||||
(click)="importWinScp()"
|
|
||||||
[disabled]="importing()"
|
|
||||||
[title]="i18n.t('connMgr.importWinscpHint')"
|
|
||||||
class="flex items-center gap-1.5 rounded-md 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()) {
|
|
||||||
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
|
|
||||||
} @else {
|
|
||||||
<!-- <ng-icon name="mynaArchive" size="0.85rem" /> -->
|
|
||||||
}
|
|
||||||
{{ i18n.t('connMgr.importWinscp') }}
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="flex min-h-0 flex-1">
|
|
||||||
<!-- stacks + connections list -->
|
|
||||||
<div class="flex w-72 shrink-0 flex-col border-r border-border bg-chrome">
|
|
||||||
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
|
||||||
<button
|
|
||||||
(click)="newConnection()"
|
|
||||||
[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"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaPlus" size="0.85rem" /> {{ i18n.t('connMgr.newConnection') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="newStack()"
|
|
||||||
[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"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaFolderPlus" size="0.85rem" /> {{ i18n.t('connMgr.newStack') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="has-scrollbar flex-1 overflow-y-auto py-1">
|
|
||||||
<!-- stacks -->
|
|
||||||
@for (group of store.grouped().stacks; track group.stack.id) {
|
|
||||||
<div
|
|
||||||
(dragover)="onDragOver($event, group.stack.id)"
|
|
||||||
(dragleave)="dropTarget() === group.stack.id && dropTarget.set(null)"
|
|
||||||
(drop)="onDrop($event, group.stack.id)"
|
|
||||||
class="rounded-md"
|
|
||||||
[class.bg-primary/10]="dropTarget() === group.stack.id"
|
|
||||||
[class.ring-1]="dropTarget() === group.stack.id"
|
|
||||||
[class.ring-inset]="dropTarget() === group.stack.id"
|
|
||||||
[class.ring-primary]="dropTarget() === group.stack.id"
|
|
||||||
>
|
|
||||||
<div class="group flex items-center gap-1 px-2 pt-2 pb-0.5">
|
|
||||||
<button
|
|
||||||
(click)="toggleStack(group.stack)"
|
|
||||||
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground hover:text-foreground"
|
|
||||||
[title]="group.stack.collapsed ? i18n.t('connMgr.expand') : i18n.t('connMgr.collapse')"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
[name]="group.stack.collapsed ? 'mynaChevronRight' : 'mynaChevronDown'"
|
|
||||||
size="0.8rem"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<span class="truncate text-[0.6875rem] font-medium text-muted-foreground uppercase">
|
|
||||||
{{ group.stack.name }}
|
|
||||||
</span>
|
|
||||||
<div class="min-w-0 flex-1"></div>
|
|
||||||
<!-- count and actions swap on hover: they never share the space,
|
|
||||||
so a long stack name can't collide with the buttons -->
|
|
||||||
<span
|
|
||||||
class="shrink-0 text-[0.625rem] text-muted-foreground/70 tabular-nums group-hover:hidden"
|
|
||||||
>
|
|
||||||
{{ group.connections.length }}
|
|
||||||
</span>
|
|
||||||
<div class="hidden shrink-0 items-center gap-0.5 group-hover:flex">
|
|
||||||
<button
|
|
||||||
(click)="renameStack(group.stack)"
|
|
||||||
[title]="i18n.t('connMgr.renameStack')"
|
|
||||||
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaEdit" size="0.75rem" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="deleteStack(group.stack)"
|
|
||||||
[title]="i18n.t('connMgr.deleteStack')"
|
|
||||||
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-destructive"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaTrash" size="0.75rem" />
|
|
||||||
</button>
|
|
||||||
@if (group.connections.length) {
|
|
||||||
<button
|
|
||||||
(click)="openStack(group.stack.id)"
|
|
||||||
[title]="i18n.t('connMgr.openStack')"
|
|
||||||
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-primary"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaArrowRight" size="0.8rem" />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@if (!group.stack.collapsed) {
|
|
||||||
@for (connection of group.connections; track connection.id) {
|
|
||||||
<button
|
|
||||||
(click)="edit(connection)"
|
|
||||||
draggable="true"
|
|
||||||
(dragstart)="onDragStart($event, connection)"
|
|
||||||
(dragend)="onDragEnd()"
|
|
||||||
class="flex w-full items-center gap-1.5 py-1.5 pr-3 pl-6 text-left text-xs hover:bg-accent"
|
|
||||||
[class.bg-accent]="draft()?.id === connection.id"
|
|
||||||
[class.opacity-50]="draggedId() === connection.id"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
|
||||||
<span class="truncate">{{ connection.name }}</span>
|
|
||||||
</button>
|
|
||||||
} @empty {
|
|
||||||
<div class="py-1 pl-6 text-[0.6875rem] text-muted-foreground italic">{{ i18n.t('connMgr.empty') }}</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- loose connections -->
|
|
||||||
<div
|
|
||||||
(dragover)="onDragOver($event, 'loose')"
|
|
||||||
(dragleave)="dropTarget() === 'loose' && dropTarget.set(null)"
|
|
||||||
(drop)="onDrop($event, null)"
|
|
||||||
class="min-h-8 rounded-md pb-1"
|
|
||||||
[class.bg-primary/10]="dropTarget() === 'loose'"
|
|
||||||
[class.ring-1]="dropTarget() === 'loose'"
|
|
||||||
[class.ring-inset]="dropTarget() === 'loose'"
|
|
||||||
[class.ring-primary]="dropTarget() === 'loose'"
|
|
||||||
>
|
|
||||||
@if (store.grouped().stacks.length && store.grouped().loose.length) {
|
|
||||||
<div class="px-2 pt-2 pb-0.5 text-[0.6875rem] font-medium text-muted-foreground uppercase">
|
|
||||||
{{ i18n.t('connMgr.connectionsHeader') }}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@for (connection of store.grouped().loose; track connection.id) {
|
|
||||||
<button
|
|
||||||
(click)="edit(connection)"
|
|
||||||
draggable="true"
|
|
||||||
(dragstart)="onDragStart($event, connection)"
|
|
||||||
(dragend)="onDragEnd()"
|
|
||||||
class="flex w-full items-center gap-1.5 px-3 py-1.5 text-left text-xs hover:bg-accent"
|
|
||||||
[class.bg-accent]="draft()?.id === connection.id"
|
|
||||||
[class.opacity-50]="draggedId() === connection.id"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
|
||||||
<span class="truncate">{{ connection.name }}</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (!store.connections().length && !store.stacks().length) {
|
|
||||||
<p class="px-3 py-2 text-[0.6875rem] text-muted-foreground">
|
|
||||||
{{ i18n.t('connMgr.noneTitle') }}
|
|
||||||
</p>
|
|
||||||
<p class="px-3 py-2 text-[0.6875rem] text-muted-foreground">
|
|
||||||
{{ i18n.t('connMgr.noneBody') }}
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- edit form -->
|
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
|
||||||
@if (draft(); as d) {
|
|
||||||
<div class="has-scrollbar grid flex-1 auto-rows-min grid-cols-2 gap-x-4 gap-y-3 overflow-y-auto p-5">
|
|
||||||
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.name') }}</span>
|
|
||||||
<input
|
|
||||||
[value]="d.name"
|
|
||||||
(input)="patch('name', $any($event.target).value)"
|
|
||||||
[placeholder]="i18n.t('connMgr.namePlaceholder')"
|
|
||||||
spellcheck="false"
|
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.host') }}</span>
|
|
||||||
<input
|
|
||||||
[value]="d.host"
|
|
||||||
(input)="patch('host', $any($event.target).value)"
|
|
||||||
placeholder="192.168.1.10"
|
|
||||||
spellcheck="false"
|
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
|
||||||
<label class="flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.port') }}</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
[value]="d.port"
|
|
||||||
(input)="patch('port', +$any($event.target).value)"
|
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.stack') }}</span>
|
|
||||||
<select
|
|
||||||
(change)="patch('stackId', $any($event.target).value || null)"
|
|
||||||
class="h-7 rounded-md border border-input bg-background px-1 outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
>
|
|
||||||
<!-- [selected] per option: binding [value] on the <select> runs
|
|
||||||
before the options render and silently shows "(none)" -->
|
|
||||||
<option value="" [selected]="!d.stackId">{{ i18n.t('connMgr.stackNone') }}</option>
|
|
||||||
@for (stack of store.stacks(); track stack.id) {
|
|
||||||
<option [value]="stack.id" [selected]="d.stackId === stack.id">
|
|
||||||
{{ stack.name }}
|
|
||||||
</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.username') }}</span>
|
|
||||||
<input
|
|
||||||
[value]="d.username"
|
|
||||||
(input)="patch('username', $any($event.target).value)"
|
|
||||||
spellcheck="false"
|
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.remotePath') }}</span>
|
|
||||||
<input
|
|
||||||
[value]="d.initialPath"
|
|
||||||
(input)="patch('initialPath', $any($event.target).value)"
|
|
||||||
[placeholder]="i18n.t('connMgr.remotePathPlaceholder')"
|
|
||||||
spellcheck="false"
|
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="col-span-2 flex items-center gap-4 text-xs">
|
|
||||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.auth') }}</span>
|
|
||||||
<label class="flex items-center gap-1.5">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="authKind"
|
|
||||||
[checked]="d.authKind === 'password'"
|
|
||||||
(change)="patch('authKind', 'password')"
|
|
||||||
class="accent-primary"
|
|
||||||
/>
|
|
||||||
{{ i18n.t('connMgr.authPassword') }}
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-1.5">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="authKind"
|
|
||||||
[checked]="d.authKind === 'key'"
|
|
||||||
(change)="patch('authKind', 'key')"
|
|
||||||
class="accent-primary"
|
|
||||||
/>
|
|
||||||
{{ i18n.t('connMgr.authKey') }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (d.authKind === 'password') {
|
|
||||||
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.password') }}</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
[value]="d.password"
|
|
||||||
(input)="patch('password', $any($event.target).value)"
|
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
} @else {
|
|
||||||
<label class="flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.privateKeyFile') }}</span>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
[value]="d.privateKeyPath"
|
|
||||||
(input)="patch('privateKeyPath', $any($event.target).value)"
|
|
||||||
placeholder="~/.ssh/id_ed25519"
|
|
||||||
spellcheck="false"
|
|
||||||
class="h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="pickKeyFile()"
|
|
||||||
class="h-7 shrink-0 rounded-md border border-border px-2 text-[0.6875rem] hover:bg-accent"
|
|
||||||
>
|
|
||||||
{{ i18n.t('common.choose') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label class="flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.passphrase') }}</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
[value]="d.passphrase"
|
|
||||||
(input)="patch('passphrase', $any($event.target).value)"
|
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (moreOpen()) {
|
|
||||||
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.localDir') }}</span>
|
|
||||||
<input
|
|
||||||
[value]="d.localDir"
|
|
||||||
(input)="patch('localDir', $any($event.target).value)"
|
|
||||||
[placeholder]="i18n.t('connMgr.localDirPlaceholder')"
|
|
||||||
spellcheck="false"
|
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
<span class="text-[0.625rem] text-muted-foreground/80">
|
|
||||||
{{ i18n.t('connMgr.localDirHint') }}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2 border-t border-border bg-chrome px-4 py-2.5">
|
|
||||||
<button
|
|
||||||
(click)="deleteCurrent()"
|
|
||||||
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-destructive"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaTrash" size="0.85rem" /> {{ i18n.t('common.delete') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="moreOpen.set(!moreOpen())"
|
|
||||||
class="rounded-md border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
{{ moreOpen() ? i18n.t('connMgr.less') : i18n.t('connMgr.more') }}
|
|
||||||
</button>
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
<button
|
|
||||||
(click)="save()"
|
|
||||||
[disabled]="!valid()"
|
|
||||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{{ i18n.t('common.save') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="saveAndConnect()"
|
|
||||||
[disabled]="!valid() || busy()"
|
|
||||||
class="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
@if (busy()) {
|
|
||||||
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
|
|
||||||
}
|
|
||||||
{{ i18n.t('common.connect') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="grid flex-1 place-items-center">
|
|
||||||
<div class="text-center">
|
|
||||||
<ng-icon name="mynaServers" size="1.75rem" class="text-muted-foreground" />
|
|
||||||
<p class="mt-2 text-xs text-muted-foreground">
|
|
||||||
{{ i18n.t('connMgr.selectPrompt') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- in-window dialogs (safe here: they depend only on FocusService) -->
|
|
||||||
@if (dialogs.prompt(); as req) {
|
|
||||||
<app-prompt-dialog [request]="req" />
|
|
||||||
}
|
|
||||||
@if (dialogs.confirm(); as req) {
|
|
||||||
<app-confirm-dialog [request]="req" />
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- STOPGAP: the frameless window has no WM resize borders, so we draw our
|
|
||||||
own handles. Delete with the rest of the hack — checklist in
|
|
||||||
window-resize/window-resize.ts. -->
|
|
||||||
<app-window-resize />
|
|
||||||
|
|
||||||
<app-toasts />
|
|
||||||
</div>
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
import { Component, computed, inject, signal } from '@angular/core';
|
|
||||||
import { NgIcon } from '@ng-icons/core';
|
|
||||||
import { I18nService } from '@core/i18n.service';
|
|
||||||
import type { SavedConnection, Stack } from '@shared/types';
|
|
||||||
import { ConnectionsService } from '@core/connections.service';
|
|
||||||
import { ConfirmDialog } from '../dialogs/confirm-dialog';
|
|
||||||
import { PromptDialog } from '../dialogs/prompt-dialog';
|
|
||||||
import { DialogsService } from '@core/dialogs.service';
|
|
||||||
import { blockNativeContextMenu } from '@core/platform';
|
|
||||||
import { RpcService } from '@core/rpc.service';
|
|
||||||
import { SettingsService } from '@core/settings.service';
|
|
||||||
import { ShortcutsService } from '@core/shortcuts.service';
|
|
||||||
import { TitleBar } from '../title-bar/title-bar';
|
|
||||||
import { Toasts } from '../toasts/toasts';
|
|
||||||
import { WindowResize } from '../window-resize/window-resize';
|
|
||||||
|
|
||||||
interface Draft {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
stackId: string | null;
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
username: string;
|
|
||||||
authKind: 'password' | 'key';
|
|
||||||
password: string;
|
|
||||||
privateKeyPath: string;
|
|
||||||
passphrase: string;
|
|
||||||
initialPath: string;
|
|
||||||
localDir: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toDraft(c: SavedConnection): Draft {
|
|
||||||
return {
|
|
||||||
id: c.id,
|
|
||||||
name: c.name,
|
|
||||||
stackId: c.stackId,
|
|
||||||
host: c.host,
|
|
||||||
port: c.port,
|
|
||||||
username: c.username,
|
|
||||||
// agent auth only exists on ~/.ssh/config hosts, which never reach this form
|
|
||||||
authKind: c.auth.kind === 'password' ? 'password' : 'key',
|
|
||||||
password: c.auth.kind === 'password' ? c.auth.password : '',
|
|
||||||
privateKeyPath: c.auth.kind === 'key' ? c.auth.privateKeyPath : '',
|
|
||||||
passphrase: c.auth.kind === 'key' ? (c.auth.passphrase ?? '') : '',
|
|
||||||
initialPath: c.initialPath ?? '',
|
|
||||||
localDir: c.localDir ?? '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function fromDraft(d: Draft): SavedConnection {
|
|
||||||
return {
|
|
||||||
id: d.id,
|
|
||||||
name: d.name.trim() || `${d.username}@${d.host}`,
|
|
||||||
stackId: d.stackId,
|
|
||||||
host: d.host.trim(),
|
|
||||||
port: d.port || 22,
|
|
||||||
username: d.username.trim(),
|
|
||||||
auth:
|
|
||||||
d.authKind === 'password'
|
|
||||||
? { kind: 'password', password: d.password }
|
|
||||||
: {
|
|
||||||
kind: 'key',
|
|
||||||
privateKeyPath: d.privateKeyPath.trim(),
|
|
||||||
...(d.passphrase ? { passphrase: d.passphrase } : {}),
|
|
||||||
},
|
|
||||||
initialPath: d.initialPath.trim() || null,
|
|
||||||
localDir: d.localDir.trim() || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Root component of the connection-manager window (__APP_MODE === 'manager').
|
|
||||||
* Modal to the main window (enforced bun-side); connect/open-stack requests
|
|
||||||
* are executed by the MAIN window — this window never touches panels.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'app-root',
|
|
||||||
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts, TitleBar, WindowResize],
|
|
||||||
templateUrl: './connection-manager.html',
|
|
||||||
host: {
|
|
||||||
'(window:keydown.escape)': 'onEscape()',
|
|
||||||
'(window:keydown)': 'onWindowKeydown($event)',
|
|
||||||
'(window:contextmenu)': 'blockContextMenu($event)',
|
|
||||||
// Aero desaturates an unfocused window's chrome — see .window.active.
|
|
||||||
'(window:focus)': 'windowFocused.set(true)',
|
|
||||||
'(window:blur)': 'windowFocused.set(false)',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export class ConnectionManager {
|
|
||||||
readonly i18n = inject(I18nService);
|
|
||||||
|
|
||||||
/** Drives `.window.active` on this window's Aero frame. */
|
|
||||||
readonly windowFocused = signal(document.hasFocus());
|
|
||||||
readonly store = inject(ConnectionsService);
|
|
||||||
readonly dialogs = inject(DialogsService);
|
|
||||||
private readonly rpc = inject(RpcService);
|
|
||||||
// injected for its side effects: fetches settings and applies theme/accent/
|
|
||||||
// text-size data attributes to this window's document root
|
|
||||||
private readonly settings = inject(SettingsService);
|
|
||||||
private readonly shortcuts = inject(ShortcutsService);
|
|
||||||
|
|
||||||
readonly draft = signal<Draft | null>(null);
|
|
||||||
readonly busy = signal(false);
|
|
||||||
readonly importing = signal(false);
|
|
||||||
/** The More… section (local directory) in the edit form. */
|
|
||||||
readonly moreOpen = signal(false);
|
|
||||||
/** Connection id being dragged between stacks. */
|
|
||||||
readonly draggedId = signal<string | null>(null);
|
|
||||||
/** Highlighted drop target: a stack id, 'loose', or null. */
|
|
||||||
readonly dropTarget = signal<string | null>(null);
|
|
||||||
|
|
||||||
readonly valid = computed(() => {
|
|
||||||
const d = this.draft();
|
|
||||||
if (!d) return false;
|
|
||||||
if (!d.host.trim() || !d.username.trim()) return false;
|
|
||||||
if (d.authKind === 'key' && !d.privateKeyPath.trim()) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
patch<K extends keyof Draft>(key: K, value: Draft[K]): void {
|
|
||||||
this.draft.update((d) => (d ? { ...d, [key]: value } : d));
|
|
||||||
}
|
|
||||||
|
|
||||||
newConnection(): void {
|
|
||||||
this.moreOpen.set(false);
|
|
||||||
this.draft.set({
|
|
||||||
id: '',
|
|
||||||
name: '',
|
|
||||||
stackId: null,
|
|
||||||
host: '',
|
|
||||||
port: 22,
|
|
||||||
username: 'root',
|
|
||||||
authKind: 'password',
|
|
||||||
password: '',
|
|
||||||
privateKeyPath: '',
|
|
||||||
passphrase: '',
|
|
||||||
initialPath: '',
|
|
||||||
localDir: '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
edit(connection: SavedConnection): void {
|
|
||||||
this.draft.set(toDraft(connection));
|
|
||||||
this.moreOpen.set(!!connection.localDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
close(): void {
|
|
||||||
void this.rpc.request.managerClose({});
|
|
||||||
}
|
|
||||||
|
|
||||||
onEscape(): void {
|
|
||||||
// in-window dialogs (confirm/prompt) handle their own Escape first
|
|
||||||
if (!this.dialogs.anyOpen) this.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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 {
|
|
||||||
blockNativeContextMenu(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
async save(): Promise<string | null> {
|
|
||||||
const d = this.draft();
|
|
||||||
if (!d || !this.valid()) return null;
|
|
||||||
const id = await this.store.upsert(fromDraft(d));
|
|
||||||
if (id) this.patch('id', id);
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveAndConnect(): Promise<void> {
|
|
||||||
if (!this.draft()) return;
|
|
||||||
this.busy.set(true);
|
|
||||||
try {
|
|
||||||
const id = await this.save();
|
|
||||||
if (!id) return;
|
|
||||||
// the main window connects (and this window closes); a failed connect
|
|
||||||
// surfaces as an error there
|
|
||||||
await this.rpc.request.managerConnect({ id });
|
|
||||||
} finally {
|
|
||||||
this.busy.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteCurrent(): void {
|
|
||||||
const d = this.draft();
|
|
||||||
if (!d?.id) {
|
|
||||||
this.draft.set(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.dialogs.confirm.set({
|
|
||||||
title: this.i18n.t('connMgr.deleteConnectionTitle'),
|
|
||||||
body: this.i18n.t('connMgr.deleteConnectionBody', { name: d.name || d.host }),
|
|
||||||
confirmLabel: this.i18n.t('common.delete'),
|
|
||||||
destructive: true,
|
|
||||||
onConfirm: async () => {
|
|
||||||
await this.store.remove(d.id);
|
|
||||||
this.draft.set(null);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async pickKeyFile(): Promise<void> {
|
|
||||||
const res = await this.rpc.request.pickKeyFile({});
|
|
||||||
if (res.ok && res.path) this.patch('privateKeyPath', res.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
async importWinScp(): Promise<void> {
|
|
||||||
this.importing.set(true);
|
|
||||||
try {
|
|
||||||
await this.store.importWinScp();
|
|
||||||
} finally {
|
|
||||||
this.importing.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- stacks ----
|
|
||||||
|
|
||||||
openStack(stackId: string): void {
|
|
||||||
// opened as tabs in the main window; bun closes this window after
|
|
||||||
void this.rpc.request.managerOpenStack({ id: stackId });
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleStack(stack: Stack): void {
|
|
||||||
void this.store.upsertStack({ ...stack, collapsed: !stack.collapsed });
|
|
||||||
}
|
|
||||||
|
|
||||||
newStack(): void {
|
|
||||||
this.dialogs.prompt.set({
|
|
||||||
title: this.i18n.t('connMgr.newStackTitle'),
|
|
||||||
placeholder: this.i18n.t('connMgr.newStackPrompt'),
|
|
||||||
confirmLabel: this.i18n.t('fileActions.createConfirm'),
|
|
||||||
onConfirm: (name) => void this.store.upsertStack({ id: '', name }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
renameStack(stack: Stack): void {
|
|
||||||
this.dialogs.prompt.set({
|
|
||||||
title: this.i18n.t('connMgr.renameStackTitle'),
|
|
||||||
initial: stack.name,
|
|
||||||
confirmLabel: this.i18n.t('connMgr.renameStack'),
|
|
||||||
onConfirm: (name) => void this.store.upsertStack({ ...stack, name }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteStack(stack: Stack): void {
|
|
||||||
this.dialogs.confirm.set({
|
|
||||||
title: this.i18n.t('connMgr.deleteStackTitle'),
|
|
||||||
body: this.i18n.t('connMgr.deleteStackBody', { name: stack.name }),
|
|
||||||
confirmLabel: this.i18n.t('common.delete'),
|
|
||||||
destructive: true,
|
|
||||||
onConfirm: () => void this.store.removeStack(stack.id),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- drag & drop (move a connection between stacks / loose) ----
|
|
||||||
|
|
||||||
onDragStart(event: DragEvent, connection: SavedConnection): void {
|
|
||||||
this.draggedId.set(connection.id);
|
|
||||||
event.dataTransfer?.setData('text/plain', connection.id);
|
|
||||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
|
||||||
}
|
|
||||||
|
|
||||||
onDragOver(event: DragEvent, target: string): void {
|
|
||||||
if (!this.draggedId()) return;
|
|
||||||
event.preventDefault();
|
|
||||||
this.dropTarget.set(target);
|
|
||||||
}
|
|
||||||
|
|
||||||
onDrop(event: DragEvent, stackId: string | null): void {
|
|
||||||
event.preventDefault();
|
|
||||||
const id = this.draggedId();
|
|
||||||
this.draggedId.set(null);
|
|
||||||
this.dropTarget.set(null);
|
|
||||||
const connection = this.store.connections().find((c) => c.id === id);
|
|
||||||
if (!connection || connection.stackId === stackId) return;
|
|
||||||
void this.store.upsert({ ...connection, stackId });
|
|
||||||
}
|
|
||||||
|
|
||||||
onDragEnd(): void {
|
|
||||||
this.draggedId.set(null);
|
|
||||||
this.dropTarget.set(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
import { Component, ElementRef, computed, effect, inject, signal, viewChild } from '@angular/core';
|
|
||||||
import { ContextMenuService, type MenuItem } from '@core/context-menu.service';
|
|
||||||
import { FocusService } from '@core/focus.service';
|
|
||||||
import { remPx } from '@core/metrics';
|
|
||||||
|
|
||||||
/** Rough per-item metrics in REM, used to size the menu and decide whether it
|
|
||||||
* must flip. Rem because the interface scale is a root font-size lever: the
|
|
||||||
* old `MENU_W = 200` was pinned into the DOM via [style.width.px], so the box
|
|
||||||
* stayed 200px while the text inside grew with the scale and overflowed.
|
|
||||||
* Values mirror the skinfix restatement of 7.css's menu metrics in 7like.css
|
|
||||||
* (item padding 0.25rem x2 + the ~0.9375rem line). */
|
|
||||||
const ITEM_H_REM = 1.5; // 24px @16
|
|
||||||
const DIVIDER_H_REM = 0.4375; // 7px @16: 2px bevel + 3px/2px margins
|
|
||||||
const MENU_W_REM = 12.5; // 200px @16
|
|
||||||
const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
|
|
||||||
|
|
||||||
/** One rendered <li>: separators collapse onto the item BEFORE them. */
|
|
||||||
interface MenuRow {
|
|
||||||
item: MenuItem;
|
|
||||||
hasDivider: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* The markup is 7.css's menu contract EXACTLY — `ul[role=menu] >
|
|
||||||
* li[role=menuitem] > button` for actions, `> input[type=checkbox] + label`
|
|
||||||
* for the checkable column toggles, and dividers as `.has-divider` on the
|
|
||||||
* PRECEDING item (7.css draws them as an ::after bevel, not as a node). Get
|
|
||||||
* any of that wrong and 7.css silently styles nothing: an earlier version
|
|
||||||
* rendered div > button, concluded "7.css has no menu component", and
|
|
||||||
* hand-built a lookalike. It does have one; the selectors just never matched.
|
|
||||||
*
|
|
||||||
* `w7-ctx` is OURS, not 7.css's: `ul[role=menu]` carries `position: relative`
|
|
||||||
* in 7.css (for its gutter stripe and submenus), and the skin layer outranks
|
|
||||||
* Tailwind's `.fixed` — so the menu laid out in flow, at the bottom of the
|
|
||||||
* h-screen frame, off the viewport. The class scopes a `position: fixed`
|
|
||||||
* restatement in 7like.css to THIS menu; see scripts/menu-probe.ts.
|
|
||||||
*
|
|
||||||
* Keyboard follows the platform: arrows move REAL focus between items (7.css
|
|
||||||
* styles :focus-visible identically to :hover), Enter/Space on a button
|
|
||||||
* activate it natively; only labels need a synthetic click.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'app-context-menu',
|
|
||||||
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>
|
|
||||||
<ul
|
|
||||||
#menuEl
|
|
||||||
role="menu"
|
|
||||||
tabindex="-1"
|
|
||||||
(keydown)="onKeydown($event)"
|
|
||||||
[style.left.px]="position().x"
|
|
||||||
[style.top.px]="position().y"
|
|
||||||
[style.width.rem]="menuWidthRem"
|
|
||||||
class="w7-ctx fixed z-50 outline-none"
|
|
||||||
>
|
|
||||||
@for (row of rows(); track $index) {
|
|
||||||
<!-- aria-disabled on the LI, not the button: that is where 7.css
|
|
||||||
hangs its disabled state (opacity + pointer-events: none). -->
|
|
||||||
<li
|
|
||||||
role="menuitem"
|
|
||||||
[class.has-divider]="row.hasDivider"
|
|
||||||
[attr.aria-disabled]="row.item.disabled ? true : null"
|
|
||||||
[title]="row.item.tooltip ?? ''"
|
|
||||||
>
|
|
||||||
@if (row.item.checked !== undefined) {
|
|
||||||
<input type="checkbox" [id]="'ctx-check-' + $index" [checked]="row.item.checked" tabindex="-1" />
|
|
||||||
<label [for]="'ctx-check-' + $index" tabindex="-1" (click)="pick(row.item, $event)">
|
|
||||||
{{ row.item.label }}
|
|
||||||
</label>
|
|
||||||
} @else {
|
|
||||||
<button type="button" (click)="pick(row.item)">
|
|
||||||
{{ row.item.label }}
|
|
||||||
@if (row.item.accelerator) {
|
|
||||||
<span>{{ row.item.accelerator }}</span>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
// 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<HTMLUListElement>>('menuEl');
|
|
||||||
|
|
||||||
/** Rendered width AND math input are the same rem value, so the flip point
|
|
||||||
* and the box can never disagree — the shadlike menu's old bug. */
|
|
||||||
protected readonly menuWidthRem = MENU_W_REM;
|
|
||||||
|
|
||||||
/** Model items → rendered rows: a separator becomes has-divider on its predecessor. */
|
|
||||||
readonly rows = computed<MenuRow[]>(() => {
|
|
||||||
const items = this.menu.request()?.items ?? [];
|
|
||||||
const rows: MenuRow[] = [];
|
|
||||||
for (const item of items) {
|
|
||||||
if (item.type === 'separator') {
|
|
||||||
if (rows.length) rows[rows.length - 1].hasDivider = true;
|
|
||||||
} else {
|
|
||||||
rows.push({ item, hasDivider: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Click point, flipped/clamped so the menu never overflows the viewport.
|
|
||||||
* UI metrics convert through the live root font-size (remPx); the click
|
|
||||||
* point and window.inner* are both viewport CSS px already. */
|
|
||||||
readonly position = computed(() => {
|
|
||||||
const req = this.menu.request();
|
|
||||||
if (!req) return { x: 0, y: 0 };
|
|
||||||
const rows = this.rows();
|
|
||||||
const menuW = remPx(MENU_W_REM);
|
|
||||||
const height =
|
|
||||||
rows.length * remPx(ITEM_H_REM) +
|
|
||||||
rows.filter((r) => r.hasDivider).length * remPx(DIVIDER_H_REM) +
|
|
||||||
remPx(0.25); // ul padding 0.125rem 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.focus.bump();
|
|
||||||
}
|
|
||||||
|
|
||||||
pick(item: MenuItem, event?: Event): void {
|
|
||||||
// labels: don't let the click also toggle the real checkbox — the fresh
|
|
||||||
// `checked` state comes back through settings when the menu reopens
|
|
||||||
event?.preventDefault();
|
|
||||||
// 7.css already blocks the pointer on [aria-disabled]; this covers the
|
|
||||||
// keyboard path, which routes through focus and never consults CSS
|
|
||||||
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 {
|
|
||||||
switch (event.key) {
|
|
||||||
case 'Escape':
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
this.close();
|
|
||||||
break;
|
|
||||||
case 'ArrowDown':
|
|
||||||
event.preventDefault();
|
|
||||||
this.step(1);
|
|
||||||
break;
|
|
||||||
case 'ArrowUp':
|
|
||||||
event.preventDefault();
|
|
||||||
this.step(-1);
|
|
||||||
break;
|
|
||||||
case 'Enter':
|
|
||||||
case ' ': {
|
|
||||||
// buttons activate natively from their own focus; labels don't, so
|
|
||||||
// synthesize the click (which routes through pick via the template)
|
|
||||||
const active = document.activeElement;
|
|
||||||
if (active instanceof HTMLLabelElement && this.menuEl()?.nativeElement.contains(active)) {
|
|
||||||
event.preventDefault();
|
|
||||||
active.click();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Move REAL focus by one item, wrapping. 7.css styles :focus-visible the
|
|
||||||
* same as :hover, and keyboard-initiated programmatic focus counts as
|
|
||||||
* focus-visible, so the highlight comes from the framework for free.
|
|
||||||
*/
|
|
||||||
private step(delta: number): void {
|
|
||||||
const root = this.menuEl()?.nativeElement;
|
|
||||||
if (!root) return;
|
|
||||||
// disabled items are skipped, not merely unclickable: pointer-events:none
|
|
||||||
// does not make an element unfocusable, so arrows would still land on them
|
|
||||||
const targets = Array.from(
|
|
||||||
root.querySelectorAll<HTMLElement>(
|
|
||||||
'li:not([aria-disabled]) > button, li:not([aria-disabled]) > label',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (!targets.length) return;
|
|
||||||
const current = targets.indexOf(document.activeElement as HTMLElement);
|
|
||||||
const next = current < 0 && delta < 0 ? targets.length - 1 : (current + delta + targets.length) % targets.length;
|
|
||||||
targets[next].focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import { Component, computed, inject, input, signal } from '@angular/core';
|
|
||||||
import { I18nService } from '@core/i18n.service';
|
|
||||||
import { DialogsService, type ChmodRequest } from '@core/dialogs.service';
|
|
||||||
import { FocusService } from '@core/focus.service';
|
|
||||||
|
|
||||||
const ROLES = ['roleOwner', 'roleGroup', 'roleOthers'] as const;
|
|
||||||
const PERMS = ['r', 'w', 'x'] as const;
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-chmod-dialog',
|
|
||||||
template: `
|
|
||||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
|
|
||||||
<div
|
|
||||||
class="w-80 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
|
||||||
(mousedown)="$event.stopPropagation()"
|
|
||||||
(keydown.escape)="cancel()"
|
|
||||||
>
|
|
||||||
<h2 class="text-sm font-semibold">{{ i18n.t('dialogs.chmodTitle') }}</h2>
|
|
||||||
<p class="mt-1 truncate text-xs text-muted-foreground">{{ targetLabel() }}</p>
|
|
||||||
|
|
||||||
<table class="mt-3 w-full text-xs">
|
|
||||||
<thead>
|
|
||||||
<tr class="text-muted-foreground">
|
|
||||||
<th class="pb-1 text-left font-normal"></th>
|
|
||||||
<th class="pb-1 font-normal">{{ i18n.t('dialogs.read') }}</th>
|
|
||||||
<th class="pb-1 font-normal">{{ i18n.t('dialogs.write') }}</th>
|
|
||||||
<th class="pb-1 font-normal">{{ i18n.t('dialogs.execute') }}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (role of roles; track role; let r = $index) {
|
|
||||||
<tr>
|
|
||||||
<td class="py-1 pr-2 text-muted-foreground">{{ i18n.t('dialogs.' + role) }}</td>
|
|
||||||
@for (perm of perms; track perm; let p = $index) {
|
|
||||||
<td class="py-1 text-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
[checked]="hasBit(r, p)"
|
|
||||||
(change)="toggleBit(r, p)"
|
|
||||||
class="size-3.5 accent-primary"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
}
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div class="mt-3 flex items-center gap-2">
|
|
||||||
<label class="text-xs text-muted-foreground">{{ i18n.t('dialogs.octal') }}</label>
|
|
||||||
<input
|
|
||||||
[value]="octal()"
|
|
||||||
(input)="setOctal($any($event.target).value)"
|
|
||||||
maxlength="4"
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
(click)="cancel()"
|
|
||||||
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
|
||||||
>
|
|
||||||
{{ i18n.t('common.cancel') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="submit()"
|
|
||||||
class="w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
{{ i18n.t('common.apply') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class ChmodDialog {
|
|
||||||
readonly i18n = inject(I18nService);
|
|
||||||
readonly request = input.required<ChmodRequest>();
|
|
||||||
|
|
||||||
readonly roles = ROLES;
|
|
||||||
readonly perms = PERMS;
|
|
||||||
|
|
||||||
private readonly dialogs = inject(DialogsService);
|
|
||||||
private readonly focus = inject(FocusService);
|
|
||||||
|
|
||||||
// mode is initialized lazily from the request on first read
|
|
||||||
private readonly modeOverride = signal<number | null>(null);
|
|
||||||
readonly mode = computed(() => this.modeOverride() ?? this.request().initialMode & 0o777);
|
|
||||||
readonly octal = computed(() => this.mode().toString(8).padStart(3, '0'));
|
|
||||||
|
|
||||||
targetLabel(): string {
|
|
||||||
const targets = this.request().targets;
|
|
||||||
return targets.length === 1
|
|
||||||
? targets[0]
|
|
||||||
: this.i18n.t('dialogs.chmodItems', { count: targets.length });
|
|
||||||
}
|
|
||||||
|
|
||||||
hasBit(role: number, perm: number): boolean {
|
|
||||||
return !!(this.mode() & (0o400 >> (role * 3 + perm)));
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleBit(role: number, perm: number): void {
|
|
||||||
this.modeOverride.set(this.mode() ^ (0o400 >> (role * 3 + perm)));
|
|
||||||
}
|
|
||||||
|
|
||||||
setOctal(text: string): void {
|
|
||||||
if (/^[0-7]{1,4}$/.test(text)) {
|
|
||||||
this.modeOverride.set(parseInt(text, 8) & 0o777);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
submit(): void {
|
|
||||||
const req = this.request();
|
|
||||||
const mode = this.mode();
|
|
||||||
this.close();
|
|
||||||
req.onConfirm(mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel(): void {
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
private close(): void {
|
|
||||||
this.dialogs.chmod.set(null);
|
|
||||||
this.focus.bump();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core';
|
|
||||||
import { I18nService } from '@core/i18n.service';
|
|
||||||
import { DialogsService, type ConfirmRequest } from '@core/dialogs.service';
|
|
||||||
import { FocusService } from '@core/focus.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-confirm-dialog',
|
|
||||||
template: `
|
|
||||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
|
|
||||||
<div
|
|
||||||
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
|
||||||
(mousedown)="$event.stopPropagation()"
|
|
||||||
(keydown.escape)="cancel()"
|
|
||||||
>
|
|
||||||
<h2 class="text-sm font-semibold">{{ request().title }}</h2>
|
|
||||||
<p class="mt-2 text-xs text-muted-foreground">{{ request().body }}</p>
|
|
||||||
<div class="mt-4 flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
(click)="cancel()"
|
|
||||||
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
|
||||||
>
|
|
||||||
{{ i18n.t('common.cancel') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
#confirmBtn
|
|
||||||
(click)="submit()"
|
|
||||||
[class]="
|
|
||||||
request().destructive
|
|
||||||
? 'w7-btn rounded-md bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground hover:bg-destructive/90'
|
|
||||||
: 'w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ request().confirmLabel }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class ConfirmDialog {
|
|
||||||
readonly i18n = inject(I18nService);
|
|
||||||
readonly request = input.required<ConfirmRequest>();
|
|
||||||
|
|
||||||
private readonly confirmBtn = viewChild.required<ElementRef<HTMLButtonElement>>('confirmBtn');
|
|
||||||
private readonly dialogs = inject(DialogsService);
|
|
||||||
private readonly focus = inject(FocusService);
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
afterNextRender(() => this.confirmBtn().nativeElement.focus());
|
|
||||||
}
|
|
||||||
|
|
||||||
submit(): void {
|
|
||||||
const req = this.request();
|
|
||||||
this.close();
|
|
||||||
req.onConfirm();
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel(): void {
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
private close(): void {
|
|
||||||
this.dialogs.confirm.set(null);
|
|
||||||
this.focus.bump();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import {
|
|
||||||
Component,
|
|
||||||
ElementRef,
|
|
||||||
afterNextRender,
|
|
||||||
computed,
|
|
||||||
inject,
|
|
||||||
input,
|
|
||||||
viewChild,
|
|
||||||
} from '@angular/core';
|
|
||||||
import { NgIcon } from '@ng-icons/core';
|
|
||||||
import { I18nService } from '@core/i18n.service';
|
|
||||||
import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '@core/dialogs.service';
|
|
||||||
import { FocusService } from '@core/focus.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-overwrite-dialog',
|
|
||||||
imports: [NgIcon],
|
|
||||||
template: `
|
|
||||||
<div
|
|
||||||
class="fixed inset-0 z-40 grid place-items-center bg-black/40"
|
|
||||||
(keydown.escape)="pick('cancel')"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-[26rem] rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-2">
|
|
||||||
<h2 class="min-w-0 text-sm font-semibold">
|
|
||||||
{{ isDir() ? i18n.t('dialogs.mergeFolder') : i18n.t('dialogs.replace') }}
|
|
||||||
<span class="font-mono">"{{ request().srcEntry.name }}"</span>?
|
|
||||||
</h2>
|
|
||||||
@if (request().total > 1) {
|
|
||||||
<span
|
|
||||||
class="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[0.625rem] font-medium text-muted-foreground"
|
|
||||||
>
|
|
||||||
{{ i18n.t('dialogs.ofTotal', { index: request().index, total: request().total }) }}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
|
||||||
@if (isDir()) {
|
|
||||||
{{ i18n.t('dialogs.overwriteDirBody') }}
|
|
||||||
} @else {
|
|
||||||
{{ i18n.t('dialogs.overwriteFileBody') }}
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- comparison -->
|
|
||||||
<div class="mt-3 grid grid-cols-2 gap-2">
|
|
||||||
@for (side of comparison(); track side.label) {
|
|
||||||
<div class="rounded-md border border-border p-2.5">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
|
|
||||||
{{ i18n.t('dialogs.' + side.label) }}
|
|
||||||
</span>
|
|
||||||
@if (side.newer) {
|
|
||||||
<span class="rounded-full bg-success/15 px-1.5 text-[0.625rem] font-medium text-success">
|
|
||||||
{{ i18n.t('dialogs.newer') }}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="mt-1.5 flex items-center gap-1.5 text-xs">
|
|
||||||
<ng-icon
|
|
||||||
[name]="isDir() ? 'mynaFolder' : 'mynaFile'"
|
|
||||||
size="0.9rem"
|
|
||||||
class="shrink-0 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<span class="font-mono tabular-nums">
|
|
||||||
{{ isDir() ? '—' : i18n.size(side.entry.size) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 font-mono text-[0.6875rem] tabular-nums text-muted-foreground">
|
|
||||||
{{ i18n.date(side.entry.mtime) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- actions -->
|
|
||||||
<div class="mt-4 flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
(click)="pick('cancel')"
|
|
||||||
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
{{ i18n.t('common.cancel') }}
|
|
||||||
</button>
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
@if (request().total > 1) {
|
|
||||||
<button
|
|
||||||
(click)="pick('skipAll')"
|
|
||||||
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
{{ i18n.t('dialogs.skipAll') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="pick('overwriteAll')"
|
|
||||||
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
{{ isDir() ? i18n.t('dialogs.mergeAll') : i18n.t('dialogs.replaceAll') }}
|
|
||||||
</button>
|
|
||||||
<div class="h-4 w-px bg-border"></div>
|
|
||||||
}
|
|
||||||
<button
|
|
||||||
(click)="pick('skip')"
|
|
||||||
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
|
||||||
>
|
|
||||||
{{ i18n.t('dialogs.skip') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
#primaryBtn
|
|
||||||
(click)="pick('overwrite')"
|
|
||||||
class="w7-btn rounded-md 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') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class OverwriteDialog {
|
|
||||||
readonly i18n = inject(I18nService);
|
|
||||||
readonly request = input.required<OverwriteRequest>();
|
|
||||||
|
|
||||||
|
|
||||||
private readonly dialogs = inject(DialogsService);
|
|
||||||
private readonly focus = inject(FocusService);
|
|
||||||
private readonly primaryBtn = viewChild.required<ElementRef<HTMLButtonElement>>('primaryBtn');
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
afterNextRender(() => this.primaryBtn().nativeElement.focus());
|
|
||||||
}
|
|
||||||
|
|
||||||
readonly isDir = computed(() => this.request().srcEntry.type === 'dir');
|
|
||||||
|
|
||||||
readonly comparison = computed(() => {
|
|
||||||
const { srcEntry, dstEntry } = this.request();
|
|
||||||
return [
|
|
||||||
{ label: 'source', entry: srcEntry, newer: srcEntry.mtime > dstEntry.mtime },
|
|
||||||
{ label: 'destination', entry: dstEntry, newer: dstEntry.mtime > srcEntry.mtime },
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
pick(choice: OverwriteChoice): void {
|
|
||||||
const req = this.request();
|
|
||||||
this.dialogs.overwrite.set(null);
|
|
||||||
this.focus.bump();
|
|
||||||
req.resolve(choice);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import {
|
|
||||||
Component,
|
|
||||||
ElementRef,
|
|
||||||
afterNextRender,
|
|
||||||
inject,
|
|
||||||
input,
|
|
||||||
signal,
|
|
||||||
viewChild,
|
|
||||||
} from '@angular/core';
|
|
||||||
import { I18nService } from '@core/i18n.service';
|
|
||||||
import { DialogsService, type PromptRequest } from '@core/dialogs.service';
|
|
||||||
import { FocusService } from '@core/focus.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-prompt-dialog',
|
|
||||||
template: `
|
|
||||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
|
|
||||||
<div
|
|
||||||
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
|
||||||
(mousedown)="$event.stopPropagation()"
|
|
||||||
>
|
|
||||||
<h2 class="text-sm font-semibold">{{ request().title }}</h2>
|
|
||||||
<input
|
|
||||||
#box
|
|
||||||
[value]="value()"
|
|
||||||
(input)="value.set($any($event.target).value)"
|
|
||||||
(keydown.enter)="submit()"
|
|
||||||
(keydown.escape)="cancel()"
|
|
||||||
[placeholder]="request().placeholder ?? ''"
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
<div class="mt-4 flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
(click)="cancel()"
|
|
||||||
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
|
||||||
>
|
|
||||||
{{ i18n.t('common.cancel') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="submit()"
|
|
||||||
[disabled]="!value().trim()"
|
|
||||||
class="w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{{ request().confirmLabel }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class PromptDialog {
|
|
||||||
readonly i18n = inject(I18nService);
|
|
||||||
readonly request = input.required<PromptRequest>();
|
|
||||||
readonly value = signal('');
|
|
||||||
|
|
||||||
private readonly box = viewChild.required<ElementRef<HTMLInputElement>>('box');
|
|
||||||
private readonly dialogs = inject(DialogsService);
|
|
||||||
private readonly focus = inject(FocusService);
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
afterNextRender(() => {
|
|
||||||
this.value.set(this.request().initial ?? '');
|
|
||||||
const el = this.box().nativeElement;
|
|
||||||
el.focus();
|
|
||||||
el.select();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
submit(): void {
|
|
||||||
const value = this.value().trim();
|
|
||||||
if (!value) return;
|
|
||||||
const req = this.request();
|
|
||||||
this.close();
|
|
||||||
req.onConfirm(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel(): void {
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
private close(): void {
|
|
||||||
this.dialogs.prompt.set(null);
|
|
||||||
this.focus.bump();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
<div class="window glass w7-frame flex h-screen flex-col" [class.active]="windowFocused()">
|
|
||||||
@if (!ctx) {
|
|
||||||
<div class="flex flex-1 items-center justify-center">
|
|
||||||
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm">
|
|
||||||
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
|
|
||||||
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.noFileTitle') }}</p>
|
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
|
||||||
{{ i18n.t('editor.noFileBody') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<!-- The window is frameless. The X runs the SAME dirty prompt as ⌘W and
|
|
||||||
the Close button — never a raw window close. -->
|
|
||||||
<app-title-bar [title]="ctx.name + (dirty() ? ' *' : '')" (closeRequested)="requestClose()" />
|
|
||||||
<!-- Toolbar strip under the title bar. h-9/pl-3 are load-bearing: without a
|
|
||||||
height this row collapses onto the icon and the screen reads as broken. -->
|
|
||||||
<header
|
|
||||||
class="w7-toolbar flex h-9 shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2 pl-3"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaFileText" size="0.95rem" class="shrink-0 text-muted-foreground" />
|
|
||||||
<h1 class="min-w-0 truncate font-mono text-sm font-semibold">
|
|
||||||
{{ ctx.name }}{{ dirty() ? ' •' : '' }}
|
|
||||||
</h1>
|
|
||||||
<span class="shrink-0 text-[0.6875rem] text-muted-foreground">{{ ctx.sessionTitle }}</span>
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
<button
|
|
||||||
(click)="toggleWrap()"
|
|
||||||
[title]="i18n.t('editor.wrapHint', { alt: altLabel })"
|
|
||||||
class="rounded-md px-2 py-1 text-[0.6875rem] electrobun-webkit-app-region-no-drag hover:bg-accent"
|
|
||||||
[class.bg-accent]="wrap()"
|
|
||||||
[class.text-foreground]="wrap()"
|
|
||||||
[class.text-muted-foreground]="!wrap()"
|
|
||||||
>
|
|
||||||
{{ i18n.t('editor.wrap') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="openFind()"
|
|
||||||
[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"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaSearch" size="0.85rem" />
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
@if (findOpen()) {
|
|
||||||
<div class="flex shrink-0 items-center gap-2 border-b border-border bg-chrome px-3 py-1.5">
|
|
||||||
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
|
||||||
<input
|
|
||||||
#findBox
|
|
||||||
[value]="findQuery()"
|
|
||||||
(input)="onFindInput($any($event.target).value)"
|
|
||||||
(keydown)="onFindKeydown($event)"
|
|
||||||
[placeholder]="i18n.t('editor.findPlaceholder')"
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="text-[0.6875rem] tabular-nums"
|
|
||||||
[class.text-destructive]="findQuery() && !matches().length"
|
|
||||||
[class.text-muted-foreground]="!findQuery() || matches().length"
|
|
||||||
>
|
|
||||||
{{ countLabel() }}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
(click)="findStep(-1)"
|
|
||||||
[title]="i18n.t('editor.prevMatch')"
|
|
||||||
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaChevronUp" size="0.8rem" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="findStep(1)"
|
|
||||||
[title]="i18n.t('editor.nextMatch')"
|
|
||||||
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaChevronDown" size="0.8rem" />
|
|
||||||
</button>
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
<button
|
|
||||||
(click)="closeFind()"
|
|
||||||
[title]="i18n.t('editor.closeHint')"
|
|
||||||
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaX" size="0.8rem" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (sessionDead()) {
|
|
||||||
<div
|
|
||||||
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" />
|
|
||||||
{{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (loading()) {
|
|
||||||
<div class="flex flex-1 items-center justify-center">
|
|
||||||
<ng-icon name="mynaSpinner" size="1.25rem" class="animate-spin text-primary" />
|
|
||||||
</div>
|
|
||||||
} @else if (loadError(); as error) {
|
|
||||||
<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">
|
|
||||||
<ng-icon name="mynaXCircle" 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-1 text-xs text-muted-foreground">{{ error }}</p>
|
|
||||||
<button
|
|
||||||
(click)="discardAndClose()"
|
|
||||||
class="mt-4 rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
|
||||||
>
|
|
||||||
{{ i18n.t('editor.closeWindow') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="flex min-h-0 flex-1">
|
|
||||||
<!-- gutter and textarea share font/leading/vertical padding — that's
|
|
||||||
the whole alignment contract; keep the classes in lockstep -->
|
|
||||||
@if (!wrap()) {
|
|
||||||
<div
|
|
||||||
#gutter
|
|
||||||
aria-hidden="true"
|
|
||||||
[style.width.ch]="gutterDigits() + 3"
|
|
||||||
class="shrink-0 overflow-hidden border-r border-border py-4 pr-2 text-right font-mono text-xs leading-relaxed whitespace-pre text-muted-foreground select-none"
|
|
||||||
>{{ gutterText() }}</div>
|
|
||||||
}
|
|
||||||
<textarea
|
|
||||||
#box
|
|
||||||
[value]="text()"
|
|
||||||
(input)="onInput($any($event.target).value)"
|
|
||||||
(scroll)="onScroll()"
|
|
||||||
spellcheck="false"
|
|
||||||
[attr.wrap]="wrap() ? 'soft' : 'off'"
|
|
||||||
class="min-h-0 flex-1 resize-none overflow-auto bg-transparent px-4 py-4 font-mono text-xs leading-relaxed outline-none"
|
|
||||||
[class.whitespace-pre]="!wrap()"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="flex shrink-0 items-center gap-2 border-t border-border bg-chrome px-4 py-2">
|
|
||||||
<span class="text-[0.6875rem] text-muted-foreground">{{ i18n.t('editor.shortcuts', { mod: modLabel }) }}</span>
|
|
||||||
<span class="text-[0.6875rem] text-muted-foreground">·</span>
|
|
||||||
<span class="text-[0.6875rem] tabular-nums text-muted-foreground">
|
|
||||||
{{ i18n.t('editor.lines', { count: lineCount() }) }}
|
|
||||||
</span>
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
<button
|
|
||||||
(click)="requestClose()"
|
|
||||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
|
||||||
>
|
|
||||||
{{ i18n.t('editor.close') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="save()"
|
|
||||||
[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"
|
|
||||||
>
|
|
||||||
@if (saving()) {
|
|
||||||
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
|
|
||||||
}
|
|
||||||
{{ i18n.t('common.save') }}
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- dirty-close prompt. Component-local on purpose: the shared confirm
|
|
||||||
dialog pulls in PanelsService, which must not boot in this window. -->
|
|
||||||
@if (closePrompt()) {
|
|
||||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancelClose()">
|
|
||||||
<div
|
|
||||||
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
|
||||||
(mousedown)="$event.stopPropagation()"
|
|
||||||
>
|
|
||||||
<h2 class="text-sm font-semibold">{{ i18n.t('editor.saveChangesTitle', { name: ctx.name }) }}</h2>
|
|
||||||
<p class="mt-1.5 text-xs text-muted-foreground">
|
|
||||||
{{ i18n.t('editor.saveChangesBody') }}
|
|
||||||
</p>
|
|
||||||
<div class="mt-4 flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
(click)="discardAndClose()"
|
|
||||||
class="rounded-md px-3 py-1.5 text-xs font-medium text-destructive hover:bg-accent"
|
|
||||||
>
|
|
||||||
{{ i18n.t('editor.dontSave') }}
|
|
||||||
</button>
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
<button
|
|
||||||
(click)="cancelClose()"
|
|
||||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
|
||||||
>
|
|
||||||
{{ i18n.t('common.cancel') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="saveAndClose()"
|
|
||||||
[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"
|
|
||||||
>
|
|
||||||
{{ i18n.t('common.save') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- STOPGAP: the frameless window has no WM resize borders, so we draw our
|
|
||||||
own handles. Delete with the rest of the hack — checklist in
|
|
||||||
window-resize/window-resize.ts. -->
|
|
||||||
<app-window-resize />
|
|
||||||
|
|
||||||
<app-toasts />
|
|
||||||
</div>
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
import {
|
|
||||||
Component,
|
|
||||||
DOCUMENT,
|
|
||||||
ElementRef,
|
|
||||||
afterNextRender,
|
|
||||||
computed,
|
|
||||||
effect,
|
|
||||||
inject,
|
|
||||||
signal,
|
|
||||||
viewChild,
|
|
||||||
} from '@angular/core';
|
|
||||||
import { NgIcon } from '@ng-icons/core';
|
|
||||||
import { I18nService } from '@core/i18n.service';
|
|
||||||
import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, MOD_LABEL } from '@core/platform';
|
|
||||||
import { RpcService } from '@core/rpc.service';
|
|
||||||
import { SettingsService } from '@core/settings.service';
|
|
||||||
import { ShortcutsService } from '@core/shortcuts.service';
|
|
||||||
import { ToastService } from '@core/toast.service';
|
|
||||||
import { TitleBar } from '../title-bar/title-bar';
|
|
||||||
import { Toasts } from '../toasts/toasts';
|
|
||||||
import { WindowResize } from '../window-resize/window-resize';
|
|
||||||
|
|
||||||
/** Debounce for mirroring the draft to bun (the traffic-light-close safety net). */
|
|
||||||
const DRAFT_DEBOUNCE_MS = 400;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Root component of an editor window (window.__APP_MODE === 'editor').
|
|
||||||
*
|
|
||||||
* Deliberately injects only Rpc/Toast/Settings: main-window root services
|
|
||||||
* (PanelsService, ConnectionsService, TransfersService — and the dialogs that
|
|
||||||
* inject them) have constructor side effects like creating workspaces and
|
|
||||||
* loading connections that must not run here.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'app-root',
|
|
||||||
imports: [NgIcon, Toasts, TitleBar, WindowResize],
|
|
||||||
templateUrl: './editor-window.html',
|
|
||||||
host: {
|
|
||||||
'(window:keydown)': 'onKeydown($event)',
|
|
||||||
'(window:contextmenu)': 'blockContextMenu($event)',
|
|
||||||
// Aero desaturates an unfocused window's chrome — see .window.active.
|
|
||||||
// blur already had to flush the draft; both run from the one binding
|
|
||||||
// (a duplicated key is a TS1117 build error, not a merge).
|
|
||||||
'(window:focus)': 'windowFocused.set(true)',
|
|
||||||
'(window:blur)': 'flushDraft(); windowFocused.set(false)',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export class EditorWindow {
|
|
||||||
readonly i18n = inject(I18nService);
|
|
||||||
|
|
||||||
/** Drives `.window.active` on this window's Aero frame. */
|
|
||||||
readonly windowFocused = signal(document.hasFocus());
|
|
||||||
readonly ctx = EDITOR_CONTEXT;
|
|
||||||
readonly modLabel = MOD_LABEL;
|
|
||||||
readonly altLabel = ALT_LABEL;
|
|
||||||
|
|
||||||
readonly loading = signal(true);
|
|
||||||
readonly loadError = signal<string | null>(null);
|
|
||||||
readonly text = signal('');
|
|
||||||
readonly savedContent = signal('');
|
|
||||||
readonly dirty = computed(() => this.text() !== this.savedContent());
|
|
||||||
readonly saving = signal(false);
|
|
||||||
readonly closePrompt = signal(false);
|
|
||||||
readonly sessionDead = signal(false);
|
|
||||||
/** Soft-wrap long lines. Hides the gutter: correct wrapped line numbers
|
|
||||||
* would need per-edit height measurement — not worth it without an editor lib. */
|
|
||||||
readonly wrap = signal(false);
|
|
||||||
readonly findOpen = signal(false);
|
|
||||||
readonly findQuery = signal('');
|
|
||||||
readonly activeMatch = signal(0);
|
|
||||||
|
|
||||||
/** Line count without allocating a split array on every keystroke. */
|
|
||||||
readonly lineCount = computed(() => {
|
|
||||||
const t = this.text();
|
|
||||||
let n = 1;
|
|
||||||
let i = -1;
|
|
||||||
while ((i = t.indexOf('\n', i + 1)) !== -1) n++;
|
|
||||||
return n;
|
|
||||||
});
|
|
||||||
readonly gutterText = computed(() =>
|
|
||||||
Array.from({ length: this.lineCount() }, (_, i) => i + 1).join('\n'),
|
|
||||||
);
|
|
||||||
readonly gutterDigits = computed(() => String(this.lineCount()).length);
|
|
||||||
|
|
||||||
/** Case-insensitive match start offsets for the find query. */
|
|
||||||
readonly matches = computed(() => {
|
|
||||||
const query = this.findQuery().toLowerCase();
|
|
||||||
if (!query) return [];
|
|
||||||
const hay = this.text().toLowerCase();
|
|
||||||
const out: number[] = [];
|
|
||||||
let i = hay.indexOf(query);
|
|
||||||
while (i !== -1) {
|
|
||||||
out.push(i);
|
|
||||||
i = hay.indexOf(query, i + query.length);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
});
|
|
||||||
readonly countLabel = computed(() =>
|
|
||||||
this.matches().length ? `${this.activeMatch() + 1}/${this.matches().length}` : '0/0',
|
|
||||||
);
|
|
||||||
|
|
||||||
private readonly box = viewChild<ElementRef<HTMLTextAreaElement>>('box');
|
|
||||||
private readonly gutter = viewChild<ElementRef<HTMLElement>>('gutter');
|
|
||||||
private readonly findBox = viewChild<ElementRef<HTMLInputElement>>('findBox');
|
|
||||||
private cachedCharWidth = 0;
|
|
||||||
private cachedCharFont = '';
|
|
||||||
private readonly rpc = inject(RpcService);
|
|
||||||
private readonly toast = inject(ToastService);
|
|
||||||
private readonly document = inject(DOCUMENT);
|
|
||||||
// injected for its side effects: fetches settings and applies theme/accent/
|
|
||||||
// text-size data attributes to this window's document root
|
|
||||||
private readonly settings = inject(SettingsService);
|
|
||||||
private readonly shortcuts = inject(ShortcutsService);
|
|
||||||
|
|
||||||
private draftTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
private lastSentDirty = false;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
const ctx = this.ctx;
|
|
||||||
if (!ctx) return; // opened outside the app (plain browser) — error card renders
|
|
||||||
|
|
||||||
const baseTitle = `${ctx.name} — ${ctx.sessionTitle}`;
|
|
||||||
effect(() => {
|
|
||||||
this.document.title = `${this.dirty() ? '• ' : ''}${baseTitle}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.rpc.onEditorCloseRequest(() => this.requestClose());
|
|
||||||
this.rpc.onSessionClosed((event) => {
|
|
||||||
if (event.sessionId === ctx.sessionId) this.sessionDead.set(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterNextRender(() => void this.load(ctx.sessionId, ctx.path));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async load(sessionId: string, path: string): Promise<void> {
|
|
||||||
const res = await this.rpc.request.readFile({ sessionId, path });
|
|
||||||
if (!res.ok) {
|
|
||||||
this.loadError.set(res.error);
|
|
||||||
this.loading.set(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.savedContent.set(res.content);
|
|
||||||
this.text.set(res.content);
|
|
||||||
this.loading.set(false);
|
|
||||||
queueMicrotask(() => this.box()?.nativeElement.focus());
|
|
||||||
}
|
|
||||||
|
|
||||||
blockContextMenu(event: Event): void {
|
|
||||||
blockNativeContextMenu(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
onInput(value: string): void {
|
|
||||||
this.text.set(value);
|
|
||||||
this.scheduleDraft();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Keep the line-number gutter locked to the textarea's scroll position. */
|
|
||||||
onScroll(): void {
|
|
||||||
const gutter = this.gutter()?.nativeElement;
|
|
||||||
if (gutter) gutter.scrollTop = this.box()!.nativeElement.scrollTop;
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleWrap(): void {
|
|
||||||
this.wrap.update((w) => !w);
|
|
||||||
queueMicrotask(() => this.onScroll());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- find ----
|
|
||||||
|
|
||||||
openFind(): void {
|
|
||||||
const el = this.box()?.nativeElement;
|
|
||||||
// prefill from the current selection when it's a sane single-line snippet
|
|
||||||
if (el && el.selectionStart !== el.selectionEnd) {
|
|
||||||
const selected = el.value.slice(el.selectionStart, el.selectionEnd);
|
|
||||||
if (selected.length < 200 && !selected.includes('\n')) this.findQuery.set(selected);
|
|
||||||
}
|
|
||||||
this.findOpen.set(true);
|
|
||||||
this.activeMatch.set(0);
|
|
||||||
queueMicrotask(() => {
|
|
||||||
const input = this.findBox()?.nativeElement;
|
|
||||||
input?.focus();
|
|
||||||
input?.select();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
closeFind(): void {
|
|
||||||
this.findOpen.set(false);
|
|
||||||
// selection was left on the match — the caret lands there
|
|
||||||
this.box()?.nativeElement.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
onFindInput(value: string): void {
|
|
||||||
this.findQuery.set(value);
|
|
||||||
const list = this.matches();
|
|
||||||
if (!list.length) return;
|
|
||||||
// jump to the first match at/after the caret, else wrap to the first
|
|
||||||
const from = this.box()?.nativeElement.selectionStart ?? 0;
|
|
||||||
const at = list.findIndex((start) => start >= from);
|
|
||||||
this.goTo(at >= 0 ? at : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
findStep(delta: 1 | -1): void {
|
|
||||||
const count = this.matches().length;
|
|
||||||
if (!count) return;
|
|
||||||
this.goTo((this.activeMatch() + delta + count) % count);
|
|
||||||
}
|
|
||||||
|
|
||||||
onFindKeydown(event: KeyboardEvent): void {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
event.preventDefault();
|
|
||||||
this.findStep(event.shiftKey ? -1 : 1);
|
|
||||||
} else if (event.key === 'Escape') {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
this.closeFind();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Select match `index` and scroll it into the middle of the viewport. */
|
|
||||||
private goTo(index: number): void {
|
|
||||||
const el = this.box()?.nativeElement;
|
|
||||||
const start = this.matches()[index];
|
|
||||||
if (!el || start === undefined) return;
|
|
||||||
this.activeMatch.set(index);
|
|
||||||
el.setSelectionRange(start, start + this.findQuery().length);
|
|
||||||
|
|
||||||
const text = this.text();
|
|
||||||
// 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 i = text.indexOf('\n');
|
|
||||||
while (i !== -1 && i < start) {
|
|
||||||
line++;
|
|
||||||
i = text.indexOf('\n', i + 1);
|
|
||||||
}
|
|
||||||
el.scrollTop = Math.max(0, line * lineHeight - el.clientHeight / 2);
|
|
||||||
if (!this.wrap()) {
|
|
||||||
const column = start - (text.lastIndexOf('\n', start - 1) + 1);
|
|
||||||
el.scrollLeft = Math.max(0, column * this.charWidth(el) - el.clientWidth / 2);
|
|
||||||
}
|
|
||||||
this.onScroll();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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 {
|
|
||||||
const font = getComputedStyle(el).font;
|
|
||||||
if (font !== this.cachedCharFont) {
|
|
||||||
const canvas = this.document.createElement('canvas').getContext('2d')!;
|
|
||||||
canvas.font = font;
|
|
||||||
this.cachedCharWidth = canvas.measureText('0').width || parseFloat(getComputedStyle(el).fontSize) * 0.6;
|
|
||||||
this.cachedCharFont = font;
|
|
||||||
}
|
|
||||||
return this.cachedCharWidth;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- draft mirror: bun holds the latest content for the native-close prompt ----
|
|
||||||
|
|
||||||
private scheduleDraft(): void {
|
|
||||||
if (this.dirty() !== this.lastSentDirty) {
|
|
||||||
// dirty flag flipped — tell bun immediately so the native title updates
|
|
||||||
this.flushDraft();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.draftTimer) clearTimeout(this.draftTimer);
|
|
||||||
this.draftTimer = setTimeout(() => this.flushDraft(), DRAFT_DEBOUNCE_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
flushDraft(): void {
|
|
||||||
if (!this.ctx || this.loading()) return;
|
|
||||||
if (this.draftTimer) {
|
|
||||||
clearTimeout(this.draftTimer);
|
|
||||||
this.draftTimer = null;
|
|
||||||
}
|
|
||||||
this.lastSentDirty = this.dirty();
|
|
||||||
this.rpc.send.editorState({ dirty: this.dirty(), content: this.text() });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- save / close ----
|
|
||||||
|
|
||||||
async save(): Promise<boolean> {
|
|
||||||
if (!this.dirty() || this.saving()) return true;
|
|
||||||
const content = this.text();
|
|
||||||
this.saving.set(true);
|
|
||||||
try {
|
|
||||||
const res = await this.rpc.request.editorSave({ content });
|
|
||||||
if (!res.ok) {
|
|
||||||
this.toast.error(res.error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
this.savedContent.set(content);
|
|
||||||
this.lastSentDirty = false; // bun record already cleaned by the handler
|
|
||||||
this.toast.success(this.i18n.t('toasts.saved', { name: this.ctx!.name }));
|
|
||||||
return true;
|
|
||||||
} finally {
|
|
||||||
this.saving.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ⌘W / Close button / bun's editorCloseRequest: prompt if dirty, else close. */
|
|
||||||
requestClose(): void {
|
|
||||||
if (this.dirty()) {
|
|
||||||
this.closePrompt.set(true);
|
|
||||||
} else {
|
|
||||||
void this.rpc.request.editorClose({});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveAndClose(): Promise<void> {
|
|
||||||
if (await this.save()) void this.rpc.request.editorClose({});
|
|
||||||
}
|
|
||||||
|
|
||||||
discardAndClose(): void {
|
|
||||||
void this.rpc.request.editorClose({});
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelClose(): void {
|
|
||||||
this.closePrompt.set(false);
|
|
||||||
queueMicrotask(() => this.box()?.nativeElement.focus());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- keyboard ----
|
|
||||||
|
|
||||||
onKeydown(event: KeyboardEvent): void {
|
|
||||||
// Chord shortcuts resolve through the shared keymap (ShortcutsService);
|
|
||||||
// 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();
|
|
||||||
switch (match.id) {
|
|
||||||
case 'save':
|
|
||||||
void this.save();
|
|
||||||
break;
|
|
||||||
case 'find':
|
|
||||||
this.openFind();
|
|
||||||
break;
|
|
||||||
case 'close-window':
|
|
||||||
// reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W
|
|
||||||
// and bun routes it back as editorCloseRequest
|
|
||||||
this.requestClose();
|
|
||||||
break;
|
|
||||||
case 'toggle-wrap':
|
|
||||||
this.toggleWrap();
|
|
||||||
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();
|
|
||||||
this.cancelClose();
|
|
||||||
} else if (event.key === 'Escape' && this.findOpen()) {
|
|
||||||
event.preventDefault();
|
|
||||||
this.closeFind();
|
|
||||||
} else if (event.key === 'Tab' && event.target === this.box()?.nativeElement) {
|
|
||||||
// keep Tab as an input character inside the editor
|
|
||||||
event.preventDefault();
|
|
||||||
this.insertTab();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private insertTab(): void {
|
|
||||||
const el = this.box()!.nativeElement;
|
|
||||||
// execCommand keeps the native undo stack intact where supported
|
|
||||||
if (!this.document.execCommand('insertText', false, '\t')) {
|
|
||||||
const { selectionStart, selectionEnd, value } = el;
|
|
||||||
el.value = `${value.slice(0, selectionStart)}\t${value.slice(selectionEnd)}`;
|
|
||||||
el.selectionStart = el.selectionEnd = selectionStart + 1;
|
|
||||||
}
|
|
||||||
this.onInput(el.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,349 +0,0 @@
|
|||||||
<!-- session strip.
|
|
||||||
w7-toolbar: these are Explorer command-bar verbs, not dialog push buttons,
|
|
||||||
so under the win7 skin they go flat with a hover glow instead of wearing
|
|
||||||
the full Aero slab — the same treatment the Connections button gets from
|
|
||||||
the tab-bar row. -->
|
|
||||||
<div class="w7-toolbar relative flex items-center gap-1.5 border-b border-border px-2 py-1">
|
|
||||||
<button
|
|
||||||
(click)="toggleMenu()"
|
|
||||||
class="flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1 text-(length:--path-font) font-medium hover:bg-accent"
|
|
||||||
[title]="i18n.t('panel.choosePanel')"
|
|
||||||
>
|
|
||||||
@if (panel().connecting()) {
|
|
||||||
<ng-icon name="mynaSpinner" class="shrink-0 animate-spin text-primary" size="var(--action-icon)" />
|
|
||||||
} @else {
|
|
||||||
<ng-icon
|
|
||||||
[name]="isRemote() ? 'mynaServers' : 'mynaHardDrive'"
|
|
||||||
[class]="isRemote() ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'"
|
|
||||||
size="var(--action-icon)"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
<span class="truncate">{{ isRemote() ? panel().title() : i18n.t('common.local') }}</span>
|
|
||||||
<ng-icon
|
|
||||||
name="mynaChevronDown"
|
|
||||||
class="shrink-0 text-muted-foreground"
|
|
||||||
size="calc(var(--action-icon) - 0.1rem)"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
|
|
||||||
<!-- panel actions: act on THIS panel. Frequent/destructive verbs grow text
|
|
||||||
labels when the panel is wide enough (container query). -->
|
|
||||||
<button
|
|
||||||
(click)="copyToOther()"
|
|
||||||
[title]="side() === 'left' ? 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"
|
|
||||||
>
|
|
||||||
<ng-icon [name]="copyIcon()" size="var(--action-icon)" />
|
|
||||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.copy') }}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="run('edit')"
|
|
||||||
[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"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaFileText" size="var(--action-icon)" />
|
|
||||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.edit') }}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="run('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"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaEdit" size="var(--action-icon)" />
|
|
||||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.rename') }}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="run('mkdir')"
|
|
||||||
[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"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaFolderPlus" size="var(--action-icon)" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="run('createFile')"
|
|
||||||
[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"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaFilePlus" size="var(--action-icon)" />
|
|
||||||
</button>
|
|
||||||
@if (chmodAvailable()) {
|
|
||||||
<button
|
|
||||||
(click)="run('chmod')"
|
|
||||||
[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"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaLock" size="var(--action-icon)" />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
<button
|
|
||||||
(click)="run('delete')"
|
|
||||||
[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"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaTrash" size="var(--action-icon)" />
|
|
||||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.delete') }}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
@if (menuOpen()) {
|
|
||||||
<div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div>
|
|
||||||
<div
|
|
||||||
class="has-scrollbar absolute top-full left-2 z-30 mt-1 max-h-80 w-64 overflow-y-auto rounded-lg border border-border bg-card py-1 text-card-foreground shadow-xl"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
(click)="pickLocal()"
|
|
||||||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
|
||||||
<span class="flex-1">{{ i18n.t('common.local') }}</span>
|
|
||||||
@if (!isRemote()) {
|
|
||||||
<ng-icon name="mynaCheck" size="0.85rem" class="shrink-0 text-primary" />
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Windows drive roots (empty list elsewhere) -->
|
|
||||||
@for (drive of drives(); track drive) {
|
|
||||||
<button
|
|
||||||
(click)="pickDrive(drive)"
|
|
||||||
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-6 text-left text-(length:--path-font) hover:bg-accent"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
|
||||||
<span class="flex-1 font-mono">{{ drive }}</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (connections.grouped().loose.length || connections.grouped().stacks.length) {
|
|
||||||
<div class="my-1 border-t border-border"></div>
|
|
||||||
}
|
|
||||||
@for (connection of connections.grouped().loose; track connection.id) {
|
|
||||||
<button
|
|
||||||
(click)="pickConnection(connection)"
|
|
||||||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
|
||||||
<span class="flex-1 truncate">{{ connection.name }}</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
@for (group of connections.grouped().stacks; track group.stack.id) {
|
|
||||||
<div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
|
|
||||||
{{ group.stack.name }}
|
|
||||||
</div>
|
|
||||||
@for (connection of group.connections; track connection.id) {
|
|
||||||
<button
|
|
||||||
(click)="pickConnection(connection)"
|
|
||||||
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
|
||||||
<span class="flex-1 truncate">{{ connection.name }}</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (connections.sshHosts().length) {
|
|
||||||
<div class="my-1 border-t border-border"></div>
|
|
||||||
<div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
|
|
||||||
~/.ssh/config
|
|
||||||
</div>
|
|
||||||
@for (host of connections.sshHosts(); track host.name) {
|
|
||||||
<button
|
|
||||||
(click)="pickSshHost(host)"
|
|
||||||
[disabled]="host.unsupported"
|
|
||||||
[title]="sshHostTitle(host)"
|
|
||||||
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent disabled:opacity-60 disabled:hover:bg-transparent"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
|
||||||
<span class="flex-1 truncate">{{ host.name }}</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- path bar. Also w7-toolbar: the up/refresh controls sit next to the action
|
|
||||||
row, so leaving them as Aero slabs would read as arbitrary. The class only
|
|
||||||
targets buttons — the path <input> keeps its 7.css textbox treatment. -->
|
|
||||||
<div class="w7-toolbar flex items-center gap-1 border-b border-border px-2 py-1.5">
|
|
||||||
<button
|
|
||||||
(click)="panel().up()"
|
|
||||||
[disabled]="blocked()"
|
|
||||||
[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"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaArrowUp" size="1rem" />
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
[value]="pathDraft()"
|
|
||||||
(input)="pathDraft.set($any($event.target).value)"
|
|
||||||
(keydown.enter)="commitPath()"
|
|
||||||
(keydown.escape)="resetPath()"
|
|
||||||
(keydown)="$event.stopPropagation()"
|
|
||||||
[disabled]="blocked()"
|
|
||||||
spellcheck="false"
|
|
||||||
class="h-(--path-ctl-h) min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono text-(length:--path-font) outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
(click)="panel().refresh()"
|
|
||||||
[disabled]="blocked()"
|
|
||||||
[title]="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"
|
|
||||||
>
|
|
||||||
@if (panel().loading()) {
|
|
||||||
<ng-icon name="mynaSpinner" size="1rem" class="animate-spin" />
|
|
||||||
} @else {
|
|
||||||
<ng-icon name="mynaRefresh" size="1rem" />
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Column headers live INSIDE the table now (7.css makes thead sticky), so
|
|
||||||
there is no separate header row here — see the ListView below. -->
|
|
||||||
|
|
||||||
<!-- type-to-filter bar (appears while a query is active) -->
|
|
||||||
@if (panel().filter(); as query) {
|
|
||||||
<div class="flex items-center gap-1.5 border-b border-border bg-accent/50 px-2 py-1">
|
|
||||||
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
|
||||||
<span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span>
|
|
||||||
<span class="shrink-0 text-[0.6875rem] text-muted-foreground">
|
|
||||||
{{ i18n.t('panel.matches', { count: filterMatches() }) }}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
(click)="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"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaX" size="0.8rem" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- file list -->
|
|
||||||
<div class="relative min-h-0 flex-1">
|
|
||||||
<div
|
|
||||||
#list
|
|
||||||
tabindex="0"
|
|
||||||
(keydown)="onKeydown($event)"
|
|
||||||
(mousedown)="activate()"
|
|
||||||
(contextmenu)="listContextMenu($event)"
|
|
||||||
class="has-scrollbar h-full overflow-y-auto outline-none"
|
|
||||||
>
|
|
||||||
@if (panel().connecting()) {
|
|
||||||
<div class="panel-fade-in grid h-full place-items-center">
|
|
||||||
<div class="flex flex-col items-center gap-3">
|
|
||||||
<ng-icon name="mynaSpinner" size="1.4rem" class="animate-spin text-primary" />
|
|
||||||
<span class="text-xs text-muted-foreground">
|
|
||||||
{{ i18n.t('panel.connectingTo', { name: panel().connectingTo() ?? '' }) }}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
(click)="cancelConnect()"
|
|
||||||
class="rounded-md border border-border px-3 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
{{ i18n.t('common.cancel') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<!-- 7.css ListView: a plain <table>. The header is <thead> (7.css makes it
|
|
||||||
position:sticky, which is why it moved in here), the sorted column is
|
|
||||||
.highlighted.indicator[.up], and the selection band is tr.highlighted.
|
|
||||||
Widths come from <colgroup> because 7.css sets table-layout:fixed.
|
|
||||||
`w-full` is Tailwind's job and safe here: 7.css never declares `width` on
|
|
||||||
table, so the `skin` layer has nothing to outrank `utilities` with.
|
|
||||||
Without it the fixed layout shrinks to content and leaves a dead grey
|
|
||||||
gutter down the right of the pane; with it the unsized first <col>
|
|
||||||
absorbs the slack, which is what makes Name the elastic column. -->
|
|
||||||
<table class="w-full" [class.is-active]="isActive()">
|
|
||||||
<colgroup>
|
|
||||||
<col />
|
|
||||||
@for (col of visibleColumns(); track col.key) {
|
|
||||||
<col [style.width]="col.width" />
|
|
||||||
}
|
|
||||||
</colgroup>
|
|
||||||
<thead>
|
|
||||||
<tr (contextmenu)="headerContextMenu($event)">
|
|
||||||
<th
|
|
||||||
(click)="panel().toggleSort('name')"
|
|
||||||
[class.highlighted]="panel().sortKey() === 'name'"
|
|
||||||
[class.indicator]="panel().sortKey() === 'name'"
|
|
||||||
[class.up]="panel().sortKey() === 'name' && panel().sortAsc()"
|
|
||||||
>
|
|
||||||
{{ i18n.t('columns.name') }}
|
|
||||||
</th>
|
|
||||||
@for (col of visibleColumns(); track col.key) {
|
|
||||||
<th
|
|
||||||
(click)="panel().toggleSort(col.key)"
|
|
||||||
[class.highlighted]="panel().sortKey() === col.key"
|
|
||||||
[class.indicator]="panel().sortKey() === col.key"
|
|
||||||
[class.up]="panel().sortKey() === col.key && panel().sortAsc()"
|
|
||||||
[style.text-align]="col.key === 'size' ? 'right' : 'left'"
|
|
||||||
>
|
|
||||||
{{ i18n.t('columns.' + col.key) }}
|
|
||||||
</th>
|
|
||||||
}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (entry of panel().sorted(); track entry.name; let i = $index) {
|
|
||||||
<tr
|
|
||||||
[attr.data-index]="i"
|
|
||||||
(click)="rowClick($event, i)"
|
|
||||||
(dblclick)="openAt(i)"
|
|
||||||
(contextmenu)="rowContextMenu($event, i)"
|
|
||||||
[class.highlighted]="isSelected(entry) || i === panel().cursor()"
|
|
||||||
[class.is-cursor]="i === panel().cursor() && isActive()"
|
|
||||||
>
|
|
||||||
<td>
|
|
||||||
<span class="flex min-w-0 items-center gap-1.5">
|
|
||||||
<ng-icon [name]="iconFor(entry)" size="0.95rem" [class]="iconClass(entry)" />
|
|
||||||
<span class="truncate" [class.italic]="entry.type === 'symlink'">{{ entry.name }}</span>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
@for (col of visibleColumns(); track col.key) {
|
|
||||||
@switch (col.key) {
|
|
||||||
@case ('size') {
|
|
||||||
<td style="text-align: right">{{ entry.type === 'dir' ? '—' : formatSize(entry.size) }}</td>
|
|
||||||
}
|
|
||||||
@case ('mtime') {
|
|
||||||
<td>{{ formatDate(entry.mtime) }}</td>
|
|
||||||
}
|
|
||||||
@case ('kind') {
|
|
||||||
<!-- fileKind is localized in file-panel.ts via i18n -->
|
|
||||||
<td>{{ fileKind(entry) }}</td>
|
|
||||||
}
|
|
||||||
@case ('rights') {
|
|
||||||
<td>{{ entry.name === '..' ? '' : formatMode(entry.mode) }}</td>
|
|
||||||
}
|
|
||||||
@case ('owner') {
|
|
||||||
<td>{{ entry.name === '..' ? '' : (entry.owner ?? '—') }}</td>
|
|
||||||
}
|
|
||||||
@case ('group') {
|
|
||||||
<td>{{ entry.name === '..' ? '' : (entry.group ?? '—') }}</td>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr>
|
|
||||||
<td [attr.colspan]="visibleColumns().length + 1" style="text-align: center; padding: 1.5rem">
|
|
||||||
{{ i18n.t('panel.emptyDir') }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
@if (panel().filter() && filterMatches() === 0) {
|
|
||||||
<tr>
|
|
||||||
<td [attr.colspan]="visibleColumns().length + 1" style="text-align: center; padding: 1.5rem">
|
|
||||||
{{ i18n.t('panel.noMatches') }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@if (panel().slowLoad() && !panel().connecting()) {
|
|
||||||
<div class="panel-fade-in absolute inset-0 z-10 grid place-items-center bg-background/60">
|
|
||||||
<ng-icon name="mynaSpinner" size="1.4rem" class="animate-spin text-primary" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@@ -1,355 +0,0 @@
|
|||||||
import {
|
|
||||||
Component,
|
|
||||||
ElementRef,
|
|
||||||
computed,
|
|
||||||
effect,
|
|
||||||
inject,
|
|
||||||
input,
|
|
||||||
signal,
|
|
||||||
viewChild,
|
|
||||||
} from '@angular/core';
|
|
||||||
import { NgIcon } from '@ng-icons/core';
|
|
||||||
import { I18nService } from '@core/i18n.service';
|
|
||||||
import type { ColumnKey, FileEntry, SavedConnection, SshConfigHost } from '@shared/types';
|
|
||||||
import { ConnectionsService } from '@core/connections.service';
|
|
||||||
import { ContextMenuService } from '@core/context-menu.service';
|
|
||||||
import { DialogsService } from '@core/dialogs.service';
|
|
||||||
import { FileActionsService } from '@core/file-actions.service';
|
|
||||||
import { fileGlyph, fileIconClass, fileKindKey } from '@core/file-icons';
|
|
||||||
import { formatMode } from '@core/format';
|
|
||||||
import { PanelsService, type Side, type SortKey } from '@core/panels.service';
|
|
||||||
import { IS_WINDOWS } from '@core/platform';
|
|
||||||
import { RpcService } from '@core/rpc.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. */
|
|
||||||
const COLUMN_DEFS: { key: ColumnKey; label: string; width: string }[] = [
|
|
||||||
{ key: 'size', label: 'Size', width: '5.5rem' },
|
|
||||||
{ key: 'mtime', label: 'Modified', width: '8.5rem' },
|
|
||||||
{ key: 'kind', label: 'Kind', width: '5.5rem' },
|
|
||||||
{ key: 'rights', label: 'Rights', width: '6.5rem' },
|
|
||||||
{ key: 'owner', label: 'Owner', width: '6rem' },
|
|
||||||
{ key: 'group', label: 'Group', width: '6rem' },
|
|
||||||
];
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-file-panel',
|
|
||||||
imports: [NgIcon],
|
|
||||||
templateUrl: './file-panel.html',
|
|
||||||
host: { class: '@container flex min-w-0 flex-1 flex-col overflow-hidden' },
|
|
||||||
})
|
|
||||||
export class FilePanel {
|
|
||||||
readonly i18n = inject(I18nService);
|
|
||||||
readonly side = input.required<Side>();
|
|
||||||
|
|
||||||
readonly panels = inject(PanelsService);
|
|
||||||
readonly actions = inject(FileActionsService);
|
|
||||||
readonly transfers = inject(TransfersService);
|
|
||||||
readonly connections = inject(ConnectionsService);
|
|
||||||
readonly dialogs = inject(DialogsService);
|
|
||||||
private readonly menu = inject(ContextMenuService);
|
|
||||||
private readonly settings = inject(SettingsService);
|
|
||||||
private readonly shortcuts = inject(ShortcutsService);
|
|
||||||
private readonly rpc = inject(RpcService);
|
|
||||||
readonly panel = computed(() => this.panels.get(this.side()));
|
|
||||||
readonly isActive = computed(() => this.panels.activeSide() === this.side());
|
|
||||||
readonly isRemote = computed(() => this.panel().sessionId() !== 'local');
|
|
||||||
/** Input is ignored while the panel is connecting or a listing is in flight. */
|
|
||||||
readonly blocked = computed(() => this.panel().busy());
|
|
||||||
/** The copy button points toward the other panel. */
|
|
||||||
readonly copyIcon = computed(() => (this.side() === 'left' ? 'mynaArrowRight' : 'mynaArrowLeft'));
|
|
||||||
|
|
||||||
readonly pathDraft = signal('');
|
|
||||||
readonly menuOpen = signal(false);
|
|
||||||
/** Windows drive roots for the source picker; always empty elsewhere. */
|
|
||||||
readonly drives = signal<string[]>([]);
|
|
||||||
/** chmod on Windows local files only flips the read-only bit — hide it. */
|
|
||||||
readonly chmodAvailable = computed(() => !(IS_WINDOWS && !this.isRemote()));
|
|
||||||
|
|
||||||
private readonly listEl = viewChild.required<ElementRef<HTMLDivElement>>('list');
|
|
||||||
private lastClickedIndex = 0;
|
|
||||||
|
|
||||||
// locale-reactive wrappers: reading i18n.locale() inside makes the file-list
|
|
||||||
// cells re-render on a language switch (see i18n.service reactive t())
|
|
||||||
readonly formatSize = (bytes: number): string => this.i18n.size(bytes);
|
|
||||||
readonly formatDate = (ms: number): string => this.i18n.date(ms);
|
|
||||||
readonly formatMode = formatMode;
|
|
||||||
readonly fileKind = (entry: FileEntry): string => {
|
|
||||||
const key = fileKindKey(entry);
|
|
||||||
return key ? this.i18n.t('kinds.' + key) : '';
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The optional columns currently shown, in canonical order. */
|
|
||||||
readonly visibleColumns = computed(() => {
|
|
||||||
const visible = this.settings.columns();
|
|
||||||
return COLUMN_DEFS.filter((c) => visible.includes(c.key));
|
|
||||||
});
|
|
||||||
|
|
||||||
headerContextMenu(event: MouseEvent): void {
|
|
||||||
event.preventDefault();
|
|
||||||
this.menu.open(event, this.menu.columnItems(this.settings.columns()));
|
|
||||||
}
|
|
||||||
|
|
||||||
pickDrive(drive: string): void {
|
|
||||||
this.menuOpen.set(false);
|
|
||||||
this.activate();
|
|
||||||
void this.panel().load(drive);
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
if (IS_WINDOWS && this.rpc.available) {
|
|
||||||
void this.rpc.request.listDrives({}).then((res) => {
|
|
||||||
if (res.ok) this.drives.set(res.drives);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// keep the editable path bar in sync with the actual cwd
|
|
||||||
effect(() => this.pathDraft.set(this.panel().cwd()));
|
|
||||||
// focus follows the active side (Tab switches panels, dialogs refocus on close)
|
|
||||||
effect(() => {
|
|
||||||
this.panels.focusTick();
|
|
||||||
if (this.isActive()) this.listEl().nativeElement.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
activate(): void {
|
|
||||||
this.panels.setActiveSide(this.side());
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelConnect(): void {
|
|
||||||
this.panels.cancelConnect(this.side());
|
|
||||||
}
|
|
||||||
|
|
||||||
copyToOther(): void {
|
|
||||||
if (this.blocked()) return;
|
|
||||||
this.activate();
|
|
||||||
void this.transfers.copyToOtherPanel(this.side());
|
|
||||||
}
|
|
||||||
|
|
||||||
run(action: 'edit' | 'rename' | 'mkdir' | 'createFile' | 'chmod' | 'delete'): void {
|
|
||||||
if (this.blocked()) return;
|
|
||||||
this.activate();
|
|
||||||
void this.actions[action](this.panel());
|
|
||||||
}
|
|
||||||
|
|
||||||
pickLocal(): void {
|
|
||||||
this.menuOpen.set(false);
|
|
||||||
void this.panels.disconnectPanel(this.side());
|
|
||||||
}
|
|
||||||
|
|
||||||
pickConnection(connection: SavedConnection): void {
|
|
||||||
this.menuOpen.set(false);
|
|
||||||
this.activate();
|
|
||||||
void this.panels.connectPanel(this.side(), connection, connection.name, connection.localDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleMenu(): void {
|
|
||||||
this.menuOpen.set(!this.menuOpen());
|
|
||||||
// re-read ~/.ssh/config on each open so external edits show up
|
|
||||||
if (this.menuOpen()) void this.connections.reloadSshHosts();
|
|
||||||
}
|
|
||||||
|
|
||||||
pickSshHost(host: SshConfigHost): void {
|
|
||||||
this.menuOpen.set(false);
|
|
||||||
this.activate();
|
|
||||||
void this.panels.connectPanel(
|
|
||||||
this.side(),
|
|
||||||
{
|
|
||||||
host: host.hostname,
|
|
||||||
port: host.port,
|
|
||||||
username: host.username,
|
|
||||||
auth: host.identityFile
|
|
||||||
? { kind: 'key', privateKeyPath: host.identityFile }
|
|
||||||
: { kind: 'agent' },
|
|
||||||
initialPath: null,
|
|
||||||
},
|
|
||||||
host.name,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
sshHostTitle(host: SshConfigHost): string {
|
|
||||||
if (host.unsupported === 'proxy') return 'Uses ProxyJump/ProxyCommand — not supported';
|
|
||||||
return `${host.username}@${host.hostname}:${host.port}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
iconFor(entry: FileEntry): string {
|
|
||||||
return fileGlyph(entry, this.settings.iconColors() === 'multi');
|
|
||||||
}
|
|
||||||
|
|
||||||
iconClass(entry: FileEntry): string {
|
|
||||||
return fileIconClass(entry, this.settings.iconColors() === 'multi');
|
|
||||||
}
|
|
||||||
|
|
||||||
isSelected(entry: FileEntry): boolean {
|
|
||||||
return this.panel().selected().has(entry.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearFilter(): void {
|
|
||||||
this.panel().setFilter('');
|
|
||||||
this.listEl().nativeElement.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
filterMatches(): number {
|
|
||||||
return this.panel()
|
|
||||||
.sorted()
|
|
||||||
.filter((e) => e.name !== '..').length;
|
|
||||||
}
|
|
||||||
|
|
||||||
commitPath(): void {
|
|
||||||
if (this.blocked()) return;
|
|
||||||
const value = this.pathDraft().trim();
|
|
||||||
if (value) void this.panel().load(value);
|
|
||||||
this.listEl().nativeElement.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
resetPath(): void {
|
|
||||||
this.pathDraft.set(this.panel().cwd());
|
|
||||||
this.listEl().nativeElement.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
rowClick(event: MouseEvent, index: number): void {
|
|
||||||
if (this.blocked()) return;
|
|
||||||
this.activate();
|
|
||||||
const panel = this.panel();
|
|
||||||
if (event.shiftKey) {
|
|
||||||
panel.selectRange(this.lastClickedIndex, index);
|
|
||||||
} else if (this.shortcuts.isMod(event)) {
|
|
||||||
const entry = panel.sorted()[index];
|
|
||||||
if (entry) panel.toggleSelect(entry.name);
|
|
||||||
} else {
|
|
||||||
panel.clearSelection();
|
|
||||||
}
|
|
||||||
panel.cursor.set(index);
|
|
||||||
this.lastClickedIndex = index;
|
|
||||||
}
|
|
||||||
|
|
||||||
rowContextMenu(event: MouseEvent, index: number): void {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
if (this.blocked()) return;
|
|
||||||
this.activate();
|
|
||||||
const panel = this.panel();
|
|
||||||
const entry = panel.sorted()[index] ?? null;
|
|
||||||
// standard behavior: keep the selection when right-clicking inside it
|
|
||||||
if (entry && !panel.selected().has(entry.name)) panel.clearSelection();
|
|
||||||
panel.cursor.set(index);
|
|
||||||
this.actions.openContextMenu(event, { side: this.side(), panel, entry });
|
|
||||||
}
|
|
||||||
|
|
||||||
listContextMenu(event: MouseEvent): void {
|
|
||||||
event.preventDefault();
|
|
||||||
if (this.blocked()) return;
|
|
||||||
this.activate();
|
|
||||||
this.actions.openContextMenu(event, { side: this.side(), panel: this.panel(), entry: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
openAt(index: number): void {
|
|
||||||
if (this.blocked()) return;
|
|
||||||
const entry = this.panel().sorted()[index];
|
|
||||||
if (entry) this.panel().open(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
onKeydown(event: KeyboardEvent): void {
|
|
||||||
if (this.blocked()) return;
|
|
||||||
const panel = this.panel();
|
|
||||||
const key = event.key;
|
|
||||||
|
|
||||||
if (key === 'Tab') {
|
|
||||||
event.preventDefault();
|
|
||||||
this.panels.setActiveSide(this.panels.otherSide(this.side()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.shortcuts.isMod(event) && key.toLowerCase() === 'a') {
|
|
||||||
event.preventDefault();
|
|
||||||
panel.selectAll();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// type-to-filter: printable characters build the query (Space only once a
|
|
||||||
// query exists — an empty-filter Space keeps its toggle-select meaning)
|
|
||||||
if (
|
|
||||||
key.length === 1 &&
|
|
||||||
!event.metaKey &&
|
|
||||||
!event.ctrlKey &&
|
|
||||||
!event.altKey &&
|
|
||||||
(key !== ' ' || panel.filter() !== '')
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
panel.setFilter(panel.filter() + key);
|
|
||||||
this.scrollCursorIntoView();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key === 'Backspace' && panel.filter() !== '') {
|
|
||||||
event.preventDefault();
|
|
||||||
panel.setFilter(panel.filter().slice(0, -1));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key === 'Escape' && panel.filter() !== '') {
|
|
||||||
event.preventDefault();
|
|
||||||
panel.setFilter('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (key) {
|
|
||||||
case 'ArrowDown':
|
|
||||||
panel.moveCursor(1);
|
|
||||||
break;
|
|
||||||
case 'ArrowUp':
|
|
||||||
panel.moveCursor(-1);
|
|
||||||
break;
|
|
||||||
case 'PageDown':
|
|
||||||
panel.moveCursor(20);
|
|
||||||
break;
|
|
||||||
case 'PageUp':
|
|
||||||
panel.moveCursor(-20);
|
|
||||||
break;
|
|
||||||
case 'Home':
|
|
||||||
panel.cursor.set(0);
|
|
||||||
break;
|
|
||||||
case 'End':
|
|
||||||
panel.cursor.set(Math.max(0, panel.sorted().length - 1));
|
|
||||||
break;
|
|
||||||
case ' ':
|
|
||||||
{
|
|
||||||
const entry = panel.sorted()[panel.cursor()];
|
|
||||||
if (entry) panel.toggleSelect(entry.name);
|
|
||||||
panel.moveCursor(1);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'Enter':
|
|
||||||
this.openAt(panel.cursor());
|
|
||||||
return void event.preventDefault();
|
|
||||||
case 'Backspace':
|
|
||||||
panel.up();
|
|
||||||
break;
|
|
||||||
case 'F2':
|
|
||||||
this.actions.rename(panel);
|
|
||||||
break;
|
|
||||||
case 'F4':
|
|
||||||
void this.actions.edit(panel);
|
|
||||||
break;
|
|
||||||
case 'F5':
|
|
||||||
void this.transfers.copyToOtherPanel(this.side());
|
|
||||||
break;
|
|
||||||
case 'F7':
|
|
||||||
this.actions.mkdir(panel);
|
|
||||||
break;
|
|
||||||
case 'F8':
|
|
||||||
case 'Delete':
|
|
||||||
this.actions.delete(panel);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
return; // let unhandled keys through
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
this.scrollCursorIntoView();
|
|
||||||
}
|
|
||||||
|
|
||||||
private scrollCursorIntoView(): void {
|
|
||||||
const row = this.listEl().nativeElement.querySelector(
|
|
||||||
`[data-index="${this.panel().cursor()}"]`,
|
|
||||||
);
|
|
||||||
row?.scrollIntoView({ block: 'nearest' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -1,17 +0,0 @@
|
|||||||
import { App } from './app';
|
|
||||||
import { ConnectionManager } from './connection-manager/connection-manager';
|
|
||||||
import { EditorWindow } from './editor-window/editor-window';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The three window roots of the Windows 7 skin, keyed by APP_MODE.
|
|
||||||
*
|
|
||||||
* Every skin exports this same shape, so main.ts selects a tree by skin and
|
|
||||||
* then a root by mode without knowing anything about either skin's internals.
|
|
||||||
* All three roots declare `selector: 'app-root'` — the single <app-root> in
|
|
||||||
* index.html hosts whichever one gets bootstrapped.
|
|
||||||
*/
|
|
||||||
export const SEVEN_ROOTS = {
|
|
||||||
main: App,
|
|
||||||
manager: ConnectionManager,
|
|
||||||
editor: EditorWindow,
|
|
||||||
} as const;
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { Component, computed, inject } from '@angular/core';
|
|
||||||
import { NgIcon } from '@ng-icons/core';
|
|
||||||
import { I18nService } from '@core/i18n.service';
|
|
||||||
import type { TransferSnapshot } from '@shared/types';
|
|
||||||
import { formatSpeed } from '@core/format';
|
|
||||||
import { PanelsService } from '@core/panels.service';
|
|
||||||
import { SettingsService } from '@core/settings.service';
|
|
||||||
import { TransfersService } from '@core/transfers.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-status-bar',
|
|
||||||
imports: [NgIcon],
|
|
||||||
template: `
|
|
||||||
<div
|
|
||||||
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 -->
|
|
||||||
<!-- w7-flat: this is a text toggle, not a push button. Without it 7.css
|
|
||||||
paints the full Aero slab and the status line reads as a stray
|
|
||||||
bordered button sitting in the status bar. -->
|
|
||||||
<button
|
|
||||||
(click)="transfers.expanded.set(!transfers.expanded())"
|
|
||||||
class="w7-flat flex min-w-0 items-center gap-1.5 hover:text-foreground"
|
|
||||||
[title]="i18n.t('transfer.showDetails')"
|
|
||||||
>
|
|
||||||
@if (transfers.currentJob(); as job) {
|
|
||||||
<ng-icon name="mynaSpinner" size="0.8rem" class="shrink-0 animate-spin text-primary" />
|
|
||||||
<span class="truncate">
|
|
||||||
{{ i18n.t('transfer.copying', { name: job.name, percent: percent(job), speed: speedSuffix(job) }) }}
|
|
||||||
@if (transfers.queuedCount(); as queued) {
|
|
||||||
· {{ i18n.t('transfer.queued', { count: queued }) }}
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
} @else if (transfers.failedJobs().length; as failed) {
|
|
||||||
<ng-icon name="mynaXCircle" size="0.8rem" class="shrink-0 text-destructive" />
|
|
||||||
<span class="text-destructive">
|
|
||||||
{{ i18n.t('transfer.failed', { count: failed }) }}
|
|
||||||
</span>
|
|
||||||
} @else if (transfers.hasJobs()) {
|
|
||||||
<ng-icon name="mynaCheckCircle" size="0.8rem" class="shrink-0 text-success" />
|
|
||||||
<span>{{ i18n.t('transfer.finished') }}</span>
|
|
||||||
} @else {
|
|
||||||
<span>{{ i18n.t('transfer.ready') }}</span>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- active panel info -->
|
|
||||||
<div class="flex shrink-0 items-center gap-1.5">
|
|
||||||
<ng-icon
|
|
||||||
[name]="activePanel().sessionId() === 'local' ? 'mynaHardDrive' : 'mynaServers'"
|
|
||||||
size="0.8rem"
|
|
||||||
[class.text-primary]="activePanel().sessionId() !== 'local'"
|
|
||||||
/>
|
|
||||||
<span class="max-w-56 truncate">
|
|
||||||
{{ activePanel().sessionId() === 'local' ? i18n.t('common.local') : activePanel().title() }}
|
|
||||||
</span>
|
|
||||||
<span>· {{ i18n.t('transfer.items', { count: activePanel().sorted().length }) }}</span>
|
|
||||||
@if (activePanel().selected().size; as count) {
|
|
||||||
<span>· {{ i18n.t('transfer.selected', { count }) }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class StatusBar {
|
|
||||||
readonly i18n = inject(I18nService);
|
|
||||||
readonly transfers = inject(TransfersService);
|
|
||||||
readonly panels = inject(PanelsService);
|
|
||||||
readonly settings = inject(SettingsService);
|
|
||||||
readonly activePanel = computed(() => this.panels.get(this.panels.activeSide()));
|
|
||||||
|
|
||||||
percent(job: TransferSnapshot): number {
|
|
||||||
if (!job.totalBytes) return 0;
|
|
||||||
return Math.min(100, Math.round((job.doneBytes / job.totalBytes) * 100));
|
|
||||||
}
|
|
||||||
|
|
||||||
speedSuffix(job: TransferSnapshot): string {
|
|
||||||
const speed = this.transfers.speeds().get(job.id);
|
|
||||||
return speed ? ` · ${formatSpeed(speed, this.settings.speedUnit())}` : '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,621 +0,0 @@
|
|||||||
import { Component, ElementRef, computed, 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 { ContextMenuService } from '@core/context-menu.service';
|
|
||||||
import { I18nService } from '@core/i18n.service';
|
|
||||||
import { PanelsService, type Workspace } from '@core/panels.service';
|
|
||||||
import { APP_SKIN, MOD_LABEL } from '@core/platform';
|
|
||||||
import { RpcService } from '@core/rpc.service';
|
|
||||||
import { SettingsService } from '@core/settings.service';
|
|
||||||
import { UpdaterService } from '@core/updater.service';
|
|
||||||
|
|
||||||
const SKINS = ['modern', 'win7'] as const;
|
|
||||||
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 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: `
|
|
||||||
<!-- The workspace strip, directly under the title bar. Draggable so the
|
|
||||||
empty stretch between the tabs and the right-hand tools still moves the
|
|
||||||
frameless window. ONE class attribute: a second one is silently dropped
|
|
||||||
by the template parser, which is exactly how this row lost its height. -->
|
|
||||||
<div
|
|
||||||
class="w7-toolbar relative flex h-9 items-center gap-0.5 border-b border-border bg-chrome pt-1 pr-1.5 pl-1.5 electrobun-webkit-app-region-drag"
|
|
||||||
>
|
|
||||||
<!-- 7.css's real tab component. The contract is exact: menu[role=tablist]
|
|
||||||
for the strip and a button[aria-selected] per tab — 7.css then draws
|
|
||||||
the joined inactive tabs and raises the active one (white fill, no
|
|
||||||
bottom border, pulled 2px down over the row's border so it reads as
|
|
||||||
joined to the content). Everything the hand-rolled version painted
|
|
||||||
(bg-accent, the absolutely-positioned bg-primary underline) is gone:
|
|
||||||
it was a lookalike competing with the framework.
|
|
||||||
NOTE the selector is "menu[role=tablist] button" — a DESCENDANT, so
|
|
||||||
any button in here becomes a tab. The "+" and the right-hand tools
|
|
||||||
deliberately live OUTSIDE this element.
|
|
||||||
(No backticks in this template: it is a TS template literal.) -->
|
|
||||||
<menu
|
|
||||||
#tabScroll
|
|
||||||
role="tablist"
|
|
||||||
class="flex min-w-0 items-center overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
||||||
>
|
|
||||||
@for (ws of panels.workspaces(); track ws.id; let i = $index) {
|
|
||||||
<!-- No ✕ inside the tab: real Win7 tab controls had none, and a button
|
|
||||||
nested in a button is invalid HTML. Closing is Ctrl/Cmd+W or
|
|
||||||
middle-click (auxclick), so a pointer path survives. -->
|
|
||||||
<button
|
|
||||||
role="tab"
|
|
||||||
[attr.aria-selected]="ws.id === panels.activeWorkspaceId()"
|
|
||||||
[attr.data-tab-id]="ws.id"
|
|
||||||
(click)="panels.setActiveWorkspace(ws.id)"
|
|
||||||
(auxclick)="onAuxClick($event, ws.id)"
|
|
||||||
(contextmenu)="onTabContextMenu($event, ws, i)"
|
|
||||||
class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5 electrobun-webkit-app-region-no-drag"
|
|
||||||
[class.opacity-50]="draggedId() === ws.id"
|
|
||||||
[style.box-shadow]="dropIndicatorShadow(i)"
|
|
||||||
[title]="panels.workspaceName(ws)"
|
|
||||||
draggable="true"
|
|
||||||
(dragstart)="onDragStart($event, ws.id)"
|
|
||||||
(dragover)="onDragOver($event, i)"
|
|
||||||
(drop)="onDrop($event, i)"
|
|
||||||
(dragend)="onDragEnd()"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
[name]="panels.workspaceIsRemote(ws) ? 'mynaServers' : 'mynaHardDrive'"
|
|
||||||
size="0.85rem"
|
|
||||||
class="shrink-0"
|
|
||||||
/>
|
|
||||||
<span class="truncate">{{ panels.workspaceName(ws) }}</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</menu>
|
|
||||||
<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-[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="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-[0.6875rem] 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-[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>
|
|
||||||
|
|
||||||
<!-- Skin: a LAUNCH-TIME choice. It swaps the whole component tree
|
|
||||||
(ui/src/app/skins/) and the native frame, and neither can change
|
|
||||||
on a live window — the tree is bootstrapped from the preload-baked
|
|
||||||
__APP_SKIN and titleBarStyle/transparent are BrowserWindow
|
|
||||||
constructor options. Clicking only saves; the running UI is left
|
|
||||||
alone. Highlight the SAVED value so the click registers, and show
|
|
||||||
the pending hint whenever it differs from the booted skin. -->
|
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
||||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.skin.label') }}</span>
|
|
||||||
<div class="flex rounded-md border border-border p-0.5">
|
|
||||||
@for (s of skins; track s) {
|
|
||||||
<button
|
|
||||||
(click)="settings.setSkin(s)"
|
|
||||||
[title]="i18n.t('options.skin.restartHint')"
|
|
||||||
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
|
||||||
[class.bg-accent]="settings.skin() === s"
|
|
||||||
[class.text-foreground]="settings.skin() === s"
|
|
||||||
[class.text-muted-foreground]="settings.skin() !== s"
|
|
||||||
>
|
|
||||||
{{ i18n.t('options.skin.' + s) }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@if (skinPending()) {
|
|
||||||
<p class="mt-1 text-right text-[0.625rem] text-muted-foreground">
|
|
||||||
{{ i18n.t('options.skin.restartHint') }}
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
<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-[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="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-[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>
|
|
||||||
}
|
|
||||||
|
|
||||||
<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-[0.6875rem]"
|
|
||||||
[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>
|
|
||||||
|
|
||||||
<!-- Interface scale: a continuous stepper, 50%..400% in 10-point
|
|
||||||
steps. One lever moves the whole UI (root font-size; everything
|
|
||||||
is rem), which is also why this popover itself grows with it. -->
|
|
||||||
<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 items-center rounded-md 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 rounded 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 rounded 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 rounded text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</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-[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="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-[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="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-[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="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-[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="rounded 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="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-[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 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-[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="rounded 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>
|
|
||||||
|
|
||||||
<!-- 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-[0.6875rem]"
|
|
||||||
[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-[0.6875rem]"
|
|
||||||
[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-[0.6875rem] 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 contextMenu = inject(ContextMenuService);
|
|
||||||
private readonly rpc = inject(RpcService);
|
|
||||||
readonly settingsOpen = signal(false);
|
|
||||||
readonly localeOptions = SUPPORTED;
|
|
||||||
readonly localeLabels = LOCALE_LABELS;
|
|
||||||
readonly modLabel = MOD_LABEL;
|
|
||||||
readonly appName = BRANDING.appName;
|
|
||||||
readonly buildLabel = BRANDING.buildLabel;
|
|
||||||
/** What the window was BUILT with, not the current setting — the frameless
|
|
||||||
* chrome only changes on reopen, so the hint compares against this. */
|
|
||||||
readonly skins = SKINS;
|
|
||||||
/**
|
|
||||||
* The saved skin differs from the one this window actually booted, so the
|
|
||||||
* choice is waiting on a restart. APP_SKIN is the preload-baked value that
|
|
||||||
* picked the running component tree; settings.skin() is merely persisted.
|
|
||||||
*/
|
|
||||||
readonly skinPending = computed(() => this.settings.skin() !== APP_SKIN);
|
|
||||||
readonly themes = THEMES;
|
|
||||||
readonly darkTones = DARK_TONES;
|
|
||||||
readonly accents = ACCENTS;
|
|
||||||
readonly iconColorModes = ICON_COLOR_MODES;
|
|
||||||
readonly speedUnits = SPEED_UNITS;
|
|
||||||
/** "100%" for the stepper readout; the value itself lives in settings. */
|
|
||||||
readonly scalePercent = computed(() => `${Math.round(this.settings.uiScale() * 100)}%`);
|
|
||||||
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<HTMLMenuElement>>('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 — the pointer path that replaced the per-tab ✕.
|
|
||||||
* `auxclick` is the non-primary-button click event; guard on button 1 so the
|
|
||||||
* right button (which also fires auxclick) never closes anything. Refuses the
|
|
||||||
* last tab, matching closeWorkspace's own floor.
|
|
||||||
*/
|
|
||||||
onAuxClick(event: MouseEvent, id: number): void {
|
|
||||||
if (event.button !== 1) return;
|
|
||||||
event.preventDefault();
|
|
||||||
if (this.panels.workspaces().length > 1) this.close(event, id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { Component, inject, input, output, signal } from '@angular/core';
|
|
||||||
import { RpcService } from '@core/rpc.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Aero title bar. Rendered by all three window kinds. The native frame is
|
|
||||||
* gone on every platform (titleBarStyle 'hidden'), so this row IS the window's
|
|
||||||
* chrome.
|
|
||||||
*
|
|
||||||
* The markup is 7.css's contract, not ours: `.title-bar` > `.title-bar-text` +
|
|
||||||
* `.title-bar-controls` > buttons keyed by `aria-label` (Minimize / Maximize /
|
|
||||||
* Restore / Close each get their own glyph). Nothing here is styled with
|
|
||||||
* Tailwind on purpose — the whole row comes from @layer skin.
|
|
||||||
*
|
|
||||||
* `.title-bar` lives on the HOST element, not on an inner div. 7.css couples
|
|
||||||
* the glass treatment with a DIRECT-child selector — `.window.glass >
|
|
||||||
* .title-bar` — and an inner div puts <app-title-bar> in between, so the rule
|
|
||||||
* never matched: the title bar rendered as the opaque fallback with its own
|
|
||||||
* border, visibly disconnected from the frame, instead of the translucent
|
|
||||||
* streaked glass that fuses with it.
|
|
||||||
*
|
|
||||||
* Dragging rides on electrobun's preload drag-region classes. Two things
|
|
||||||
* electrobun does NOT provide and this component implements:
|
|
||||||
* - double-click to maximize (it only wires mousedown/mouseup)
|
|
||||||
* - the maximize/restore glyph state, which has no event; bun derives it
|
|
||||||
* from the native resize and pushes `windowState` (see trackMaximizeState).
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'app-title-bar',
|
|
||||||
host: {
|
|
||||||
class: 'title-bar electrobun-webkit-app-region-drag',
|
|
||||||
// Published so the FRAME can react: a maximized Win7 window has square
|
|
||||||
// corners. The window root reads this off its own child via :has() rather
|
|
||||||
// than subscribing to windowState a second time — see styles.css.
|
|
||||||
'[class.is-maximized]': 'maximized()',
|
|
||||||
'(dblclick)': 'toggle()',
|
|
||||||
},
|
|
||||||
template: `
|
|
||||||
<div class="title-bar-text">{{ title() }}</div>
|
|
||||||
<div class="title-bar-controls electrobun-webkit-app-region-no-drag">
|
|
||||||
@if (resizable()) {
|
|
||||||
<button aria-label="Minimize" (click)="minimize()"></button>
|
|
||||||
<button
|
|
||||||
[attr.aria-label]="maximized() ? 'Restore' : 'Maximize'"
|
|
||||||
(click)="toggle()"
|
|
||||||
></button>
|
|
||||||
}
|
|
||||||
<button aria-label="Close" (click)="closeRequested.emit()"></button>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class TitleBar {
|
|
||||||
readonly title = input('');
|
|
||||||
/** The manager window is a fixed-size modal — it gets Close only. */
|
|
||||||
readonly resizable = input(true);
|
|
||||||
/** Hosts decide what closing means (quit / dirty prompt / managerClose). */
|
|
||||||
readonly closeRequested = output<void>();
|
|
||||||
|
|
||||||
protected readonly maximized = signal(false);
|
|
||||||
|
|
||||||
private readonly rpc = inject(RpcService);
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
if (!this.rpc.available) return;
|
|
||||||
// No maximize event exists, so seed from a one-shot read and then follow
|
|
||||||
// the resize-derived push (which also covers Aero Snap and Win+Up).
|
|
||||||
void this.rpc.request.windowIsMaximized({}).then((res) => {
|
|
||||||
if (res.ok) this.maximized.set(res.maximized);
|
|
||||||
});
|
|
||||||
this.rpc.onWindowState((maximized) => this.maximized.set(maximized));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected minimize(): void {
|
|
||||||
void this.rpc.request.windowMinimize({});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected toggle(): void {
|
|
||||||
void this.rpc.request.windowToggleMaximize({}).then((res) => {
|
|
||||||
if (res.ok) this.maximized.set(res.maximized);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
|
||||||
import { NgIcon } from '@ng-icons/core';
|
|
||||||
import { ToastService } from '@core/toast.service';
|
|
||||||
|
|
||||||
/** Toast stack overlay; shared by the main window and editor windows. */
|
|
||||||
@Component({
|
|
||||||
selector: 'app-toasts',
|
|
||||||
imports: [NgIcon],
|
|
||||||
template: `
|
|
||||||
<div class="pointer-events-none fixed right-3 bottom-8 z-50 flex w-80 flex-col gap-2">
|
|
||||||
@for (t of toast.toasts(); track t.id) {
|
|
||||||
<div
|
|
||||||
(mouseenter)="toast.pause(t.id)"
|
|
||||||
(mouseleave)="toast.resume(t.id)"
|
|
||||||
class="group pointer-events-auto relative flex items-start gap-2 overflow-hidden rounded-lg border border-border bg-card p-3 text-xs text-card-foreground shadow-lg"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
[name]="toastIcon(t.kind)"
|
|
||||||
size="1rem"
|
|
||||||
[class]="t.kind === 'error' ? 'shrink-0 text-destructive' : 'shrink-0 text-success'"
|
|
||||||
/>
|
|
||||||
<span class="min-w-0 flex-1 wrap-break-word">{{ t.text }}</span>
|
|
||||||
<button
|
|
||||||
(click)="toast.dismiss(t.id)"
|
|
||||||
class="shrink-0 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaX" size="0.9rem" />
|
|
||||||
</button>
|
|
||||||
<!-- auto-dismiss countdown; freezes with the timer while hovered -->
|
|
||||||
<div
|
|
||||||
class="toast-countdown absolute inset-x-0 bottom-0 h-0.5 origin-left"
|
|
||||||
[class.bg-destructive]="t.kind === 'error'"
|
|
||||||
[class.bg-success]="t.kind === 'success'"
|
|
||||||
[class.bg-primary]="t.kind === 'info'"
|
|
||||||
[style.animation-duration.ms]="t.ttlMs"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class Toasts {
|
|
||||||
readonly toast = inject(ToastService);
|
|
||||||
|
|
||||||
toastIcon(kind: 'error' | 'info' | 'success'): string {
|
|
||||||
if (kind === 'error') return 'mynaXCircle';
|
|
||||||
if (kind === 'success') return 'mynaCheckCircle';
|
|
||||||
return 'mynaCheck';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
@if (transfers.expanded() && transfers.hasJobs()) {
|
|
||||||
<div class="flex items-center justify-between border-t border-border bg-chrome px-3 py-1 text-[0.6875rem] text-muted-foreground">
|
|
||||||
<span>
|
|
||||||
{{ i18n.t('transfer.summary', { active: transfers.activeJobs().length, finished: transfers.finishedJobs().length }) }}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
(click)="transfers.clearFinished()"
|
|
||||||
[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"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaTrash" size="0.75rem" /> {{ i18n.t('transfer.clearFinished') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="has-scrollbar max-h-40 overflow-y-auto border-t border-border bg-chrome">
|
|
||||||
@for (job of transfers.jobs(); track job.id) {
|
|
||||||
<div class="flex items-center gap-2 px-3 py-1.5 text-xs">
|
|
||||||
<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>
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="truncate text-[0.6875rem] text-muted-foreground">{{ progressLabel(job) }}</div>
|
|
||||||
@if (isActive(job)) {
|
|
||||||
<!-- role+aria are 7.css's hook for the Win7 progress bar
|
|
||||||
([role=progressbar] > div); under the modern skin they are
|
|
||||||
inert and the Tailwind classes below still draw the bar. -->
|
|
||||||
<div
|
|
||||||
role="progressbar"
|
|
||||||
aria-valuemin="0"
|
|
||||||
aria-valuemax="100"
|
|
||||||
[attr.aria-valuenow]="jobPercent(job)"
|
|
||||||
class="mt-0.5 h-1 overflow-hidden rounded-full bg-muted"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="h-full rounded-full bg-primary transition-[width] duration-100"
|
|
||||||
[style.width.%]="jobPercent(job)"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@if (isActive(job)) {
|
|
||||||
<button
|
|
||||||
(click)="transfers.cancel(job.id)"
|
|
||||||
[title]="i18n.t('transfer.cancel')"
|
|
||||||
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-destructive"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaX" size="0.9rem" />
|
|
||||||
</button>
|
|
||||||
} @else {
|
|
||||||
<button
|
|
||||||
(click)="transfers.dismiss(job.id)"
|
|
||||||
[title]="i18n.t('transfer.dismiss')"
|
|
||||||
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaX" size="0.9rem" />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
|
||||||
import { NgIcon } from '@ng-icons/core';
|
|
||||||
import type { TransferSnapshot } from '@shared/types';
|
|
||||||
import { formatEta } from '@core/format';
|
|
||||||
import { I18nService } from '@core/i18n.service';
|
|
||||||
import { SettingsService } from '@core/settings.service';
|
|
||||||
import { TransfersService } from '@core/transfers.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-transfer-bar',
|
|
||||||
imports: [NgIcon],
|
|
||||||
templateUrl: './transfer-bar.html',
|
|
||||||
})
|
|
||||||
export class TransferBar {
|
|
||||||
readonly i18n = inject(I18nService);
|
|
||||||
readonly transfers = inject(TransfersService);
|
|
||||||
readonly settings = inject(SettingsService);
|
|
||||||
readonly formatSize = (bytes: number): string => this.i18n.size(bytes);
|
|
||||||
|
|
||||||
/** "14.2 MB/s · 0:42 left" while a job is moving bytes. */
|
|
||||||
speedLabel(job: TransferSnapshot): string {
|
|
||||||
if (job.state !== 'running') return '';
|
|
||||||
const speed = this.transfers.speeds().get(job.id);
|
|
||||||
if (!speed) return '';
|
|
||||||
const parts = [this.i18n.speed(speed, this.settings.speedUnit())];
|
|
||||||
const remaining = job.totalBytes - job.doneBytes;
|
|
||||||
if (remaining > 0 && speed > 0) {
|
|
||||||
parts.push(this.i18n.t('transfer.left', { eta: formatEta(remaining / speed) }));
|
|
||||||
}
|
|
||||||
return parts.join(' · ');
|
|
||||||
}
|
|
||||||
|
|
||||||
stateIcon(job: TransferSnapshot): string {
|
|
||||||
switch (job.state) {
|
|
||||||
case 'completed':
|
|
||||||
return 'mynaCheckCircle';
|
|
||||||
case 'failed':
|
|
||||||
case 'cancelled':
|
|
||||||
return 'mynaXCircle';
|
|
||||||
default:
|
|
||||||
return 'mynaSpinner';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stateClass(job: TransferSnapshot): string {
|
|
||||||
switch (job.state) {
|
|
||||||
case 'completed':
|
|
||||||
return 'shrink-0 text-success';
|
|
||||||
case 'failed':
|
|
||||||
return 'shrink-0 text-destructive';
|
|
||||||
case 'cancelled':
|
|
||||||
return 'shrink-0 text-muted-foreground';
|
|
||||||
default:
|
|
||||||
return 'shrink-0 animate-spin text-primary';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isActive(job: TransferSnapshot): boolean {
|
|
||||||
return job.state === 'queued' || job.state === 'scanning' || job.state === 'running';
|
|
||||||
}
|
|
||||||
|
|
||||||
jobPercent(job: TransferSnapshot): number {
|
|
||||||
if (!job.totalBytes) return job.state === 'completed' ? 100 : 0;
|
|
||||||
return Math.min(100, (job.doneBytes / job.totalBytes) * 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
progressLabel(job: TransferSnapshot): string {
|
|
||||||
if (job.state === 'queued') return this.i18n.t('transfer.queuedState');
|
|
||||||
if (job.state === 'scanning') return this.i18n.t('transfer.scanning');
|
|
||||||
if (job.state === 'failed')
|
|
||||||
return `${job.currentFile ? `${job.currentFile} — ` : ''}${job.error ?? this.i18n.t('transfer.failedState')}`;
|
|
||||||
if (job.state === 'cancelled') return this.i18n.t('transfer.cancelled');
|
|
||||||
if (job.state === 'completed')
|
|
||||||
return this.i18n.t('transfer.completedFiles', {
|
|
||||||
count: job.doneFiles,
|
|
||||||
size: this.i18n.size(job.doneBytes),
|
|
||||||
});
|
|
||||||
const speed = this.speedLabel(job);
|
|
||||||
const base = `${job.currentFile} · ${this.i18n.size(job.doneBytes)} / ${this.i18n.size(job.totalBytes)}`;
|
|
||||||
return speed ? `${base} · ${speed}` : base;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
/**
|
|
||||||
* Frame arithmetic for the synthesized window resize. Deliberately a separate,
|
|
||||||
* DEPENDENCY-FREE module: `scripts/resize-math-probe.ts` imports it directly to
|
|
||||||
* assert all eight edges without booting Angular or opening a window, which is
|
|
||||||
* the only automated coverage this feature can have (pointer drags cannot be
|
|
||||||
* synthesized headlessly on this machine).
|
|
||||||
*
|
|
||||||
* Part of the resize stopgap — see the removal checklist in window-resize.ts.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface Frame {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compass point of the grabbed edge or corner. */
|
|
||||||
export type ResizeEdge = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every edge the arithmetic supports — NOT what the UI mounts. The window
|
|
||||||
* currently offers only the bottom-right grip (`window-resize.ts` explains why
|
|
||||||
* the others were removed: an invisible handle over a caption button steals its
|
|
||||||
* hover). This stays complete because the origin-moving cases are the subtle
|
|
||||||
* ones and the probe is where they are proven; restoring a side is then one
|
|
||||||
* entry in the component, against already-tested math.
|
|
||||||
*/
|
|
||||||
export const RESIZE_EDGES: readonly ResizeEdge[] = ['n', 's', 'w', 'e', 'nw', 'ne', 'sw', 'se'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Floor for the synthesized resize. electrobun has no min-size window option,
|
|
||||||
* so this is enforced here or not at all. Chosen to keep both panels usable
|
|
||||||
* with their toolbars intact; the interface scale can still make the CONTENT
|
|
||||||
* too big for it, which is a separate (pre-existing) problem.
|
|
||||||
*/
|
|
||||||
export const MIN_WIDTH = 640;
|
|
||||||
export const MIN_HEIGHT = 420;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The frame a drag should produce.
|
|
||||||
*
|
|
||||||
* `dx`/`dy` are SCREEN-space deltas from where the drag began (see the note on
|
|
||||||
* screenX in window-resize.ts). North and west edges move the window's origin
|
|
||||||
* as well as its size — that is the whole reason this returns a full frame
|
|
||||||
* rather than a size, and why `setSize` alone would be wrong.
|
|
||||||
*
|
|
||||||
* The clamp holds the ANCHORED edge still: dragging the west edge east past the
|
|
||||||
* minimum must park `x` where the window is exactly MIN_WIDTH wide, not let it
|
|
||||||
* keep travelling while the width sits pinned — that would walk the window
|
|
||||||
* across the screen while it appears frozen.
|
|
||||||
*
|
|
||||||
* Results are rounded: fractional frames make the native setter jitter by a
|
|
||||||
* pixel between ticks.
|
|
||||||
*/
|
|
||||||
export function resizeFrame(
|
|
||||||
edge: ResizeEdge,
|
|
||||||
start: Frame,
|
|
||||||
dx: number,
|
|
||||||
dy: number,
|
|
||||||
min: { width: number; height: number } = { width: MIN_WIDTH, height: MIN_HEIGHT },
|
|
||||||
): Frame {
|
|
||||||
let { x, y, width, height } = start;
|
|
||||||
|
|
||||||
if (edge.includes('e')) width = start.width + dx;
|
|
||||||
if (edge.includes('w')) {
|
|
||||||
width = start.width - dx;
|
|
||||||
x = start.x + dx;
|
|
||||||
}
|
|
||||||
if (edge.includes('s')) height = start.height + dy;
|
|
||||||
if (edge.includes('n')) {
|
|
||||||
height = start.height - dy;
|
|
||||||
y = start.y + dy;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (width < min.width) {
|
|
||||||
// west drag: the east edge is the anchor, so x is derived from it
|
|
||||||
if (edge.includes('w')) x = start.x + start.width - min.width;
|
|
||||||
width = min.width;
|
|
||||||
}
|
|
||||||
if (height < min.height) {
|
|
||||||
if (edge.includes('n')) y = start.y + start.height - min.height;
|
|
||||||
height = min.height;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: Math.round(x),
|
|
||||||
y: Math.round(y),
|
|
||||||
width: Math.round(width),
|
|
||||||
height: Math.round(height),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
|
||||||
import { RpcService } from '@core/rpc.service';
|
|
||||||
import { type Frame, type ResizeEdge, resizeFrame } from './resize-math';
|
|
||||||
|
|
||||||
/* ============================================================================
|
|
||||||
* ⚠️ STOPGAP — DELETE THIS WHOLE FEATURE WHEN ELECTROBUN SHIPS WINDOW RESIZE
|
|
||||||
* ============================================================================
|
|
||||||
*
|
|
||||||
* WHY IT EXISTS
|
|
||||||
*
|
|
||||||
* The win7 skin is frameless: `chromeOptions()` (src/bun/windows.ts) passes
|
|
||||||
* `titleBarStyle: "hidden"` so 7.css can draw the Aero caption itself, and on
|
|
||||||
* Linux the GTK wrapper honours that with `gtk_window_set_decorated(false)`.
|
|
||||||
* Undecorating the window also removes the window manager's resize borders and
|
|
||||||
* corner grips — so all three win7 windows became completely unresizable, on
|
|
||||||
* every platform. (The modern skin keeps the native frame and is unaffected;
|
|
||||||
* it must never mount this component.)
|
|
||||||
*
|
|
||||||
* Electrobun 1.18.1 offers nothing to re-enable. Verified against the SHIPPED
|
|
||||||
* binary rather than assumed:
|
|
||||||
*
|
|
||||||
* - libNativeWrapper.so exports `startWindowMove` / `stopWindowMove` and no
|
|
||||||
* resize counterpart whatsoever. Its only `resize*` exports are
|
|
||||||
* `resizeWebview` / `resizeAutoSizingWebviewsInWindow`, internal webview
|
|
||||||
* plumbing unrelated to the window frame.
|
|
||||||
* - It imports `gtk_window_begin_move_drag` but NOT
|
|
||||||
* `gtk_window_begin_resize_drag`, and not `gtk_window_set_resizable`.
|
|
||||||
* - `dist/api/bun/preload/dragRegions.ts` implements moving only: there is no
|
|
||||||
* `app-region: resize`, no per-edge variant.
|
|
||||||
* - `BrowserWindow` has no `setResizable`, and its constructor options carry
|
|
||||||
* no `resizable` / `minWidth` / `minHeight`.
|
|
||||||
*
|
|
||||||
* The window is still PROGRAMMATICALLY resizable — nothing ever calls
|
|
||||||
* `gtk_window_set_resizable(false)`, and `setFrame`/`getFrame` work. So only
|
|
||||||
* the input affordance is missing, and that is exactly what this synthesizes:
|
|
||||||
* a bottom-right grip → pointer drag → `windowSetFrame` RPC →
|
|
||||||
* `BrowserWindow.setFrame`. One corner, not eight handles — see GRIP_BOX for
|
|
||||||
* why the others had to go.
|
|
||||||
*
|
|
||||||
* WHAT IT COSTS (i.e. why it should die as soon as it can)
|
|
||||||
*
|
|
||||||
* Every drag tick is an RPC hop plus a synchronous FFI call, where a real WM
|
|
||||||
* grab is a compositor-side operation — so it is jerkier than a native resize
|
|
||||||
* and always will be. Minimum size is enforced in JS because electrobun has no
|
|
||||||
* min-size option. The grip occupies real estate INSIDE the window that a
|
|
||||||
* native frame would have provided outside it — which is also why there is only
|
|
||||||
* one of them: an invisible handle over a control steals that control's hover
|
|
||||||
* (see GRIP_BOX). A real WM resize border has neither problem.
|
|
||||||
*
|
|
||||||
* ────────────────────────────────────────────────────────────────────────────
|
|
||||||
* REMOVAL CHECKLIST — when upstream ships a resize gesture, delete ALL of:
|
|
||||||
*
|
|
||||||
* 1. this directory (window-resize.ts + resize-math.ts)
|
|
||||||
* 2. its three mount points: `<app-window-resize />` and the `WindowResize`
|
|
||||||
* entry in `imports:` for skins/7like/{app, connection-manager/
|
|
||||||
* connection-manager, editor-window/editor-window}
|
|
||||||
* 3. the `.w7-resize` rule in skins/7like/7like.css (@layer skinfix)
|
|
||||||
* 4. `windowGetFrame` / `windowSetFrame` in src/shared/rpc.ts
|
|
||||||
* 5. their handlers in `windowControlHandlers` (src/bun/rpc-handlers.ts)
|
|
||||||
* 6. scripts/check-electrobun-resize.ts and scripts/resize-math-probe.ts
|
|
||||||
* 7. the check-script call in package.json's `postinstall`
|
|
||||||
* 8. the resize paragraphs in docs/{gotchas,platform-notes,architecture}.md
|
|
||||||
*
|
|
||||||
* Then adopt whatever upstream provides — most likely a `no-drag`-style CSS
|
|
||||||
* class or a `startWindowResize(edge)` preload call, which needs handles for
|
|
||||||
* hit-testing but none of the frame arithmetic below.
|
|
||||||
*
|
|
||||||
* HOW WE FIND OUT: `scripts/check-electrobun-resize.ts` inspects the installed
|
|
||||||
* electrobun for exactly those symbols and prints this checklist when they
|
|
||||||
* appear. It runs from `postinstall`, so an electrobun bump surfaces it without
|
|
||||||
* anyone remembering to look.
|
|
||||||
* ============================================================================
|
|
||||||
*
|
|
||||||
* IMPLEMENTATION NOTES
|
|
||||||
*
|
|
||||||
* - `electrobun-webkit-app-region-no-drag` on every handle is LOAD-BEARING, not
|
|
||||||
* decoration. `dragRegions.ts` starts a window MOVE from a `mousedown`
|
|
||||||
* listener on `document` in the bubble phase; it never checks
|
|
||||||
* `defaultPrevented`, so `preventDefault()` does not stop it, and a
|
|
||||||
* `pointerdown` handler does not either (the compatibility `mousedown` still
|
|
||||||
* fires afterwards, and stopping a POINTER event does nothing to a MOUSE
|
|
||||||
* event). `isAppRegionDrag` tests for the no-drag class first and
|
|
||||||
* short-circuits, so the class is the only reliable suppressor. Without it
|
|
||||||
* every resize drag would also drag the window.
|
|
||||||
* - Deltas come from `screenX/screenY`, never `clientX/clientY`. Resizing from
|
|
||||||
* the north or west edge moves the window's origin, which moves the webview's
|
|
||||||
* client origin under a stationary pointer — client coords would feed that
|
|
||||||
* movement back into the next delta and the window would run away.
|
|
||||||
* - The frame is read once per drag, asynchronously, but the pointer origin is
|
|
||||||
* recorded synchronously in the same handler: a move that arrives before the
|
|
||||||
* frame does is simply ignored, and no delta is measured from a stale origin.
|
|
||||||
* - At most one setFrame is in flight, with the newest target coalesced behind
|
|
||||||
* it. Firing one RPC per pointermove would queue faster than it drains and
|
|
||||||
* the window would keep resizing after the mouse stopped.
|
|
||||||
* - Handles are hidden when maximized by CSS alone (7like.css reads the title
|
|
||||||
* bar's own `is-maximized` through `:has()`), so this component never
|
|
||||||
* subscribes to `windowState`. That matters: RpcService's push registries
|
|
||||||
* have no `off()`, so every subscription is permanent.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ONE handle: the bottom-right grip, exactly where Win7 put its resize gripper.
|
|
||||||
*
|
|
||||||
* A full eight-handle set was tried and reverted. The handles are invisible and
|
|
||||||
* sit ON TOP of the frame (the overlay is above the title bar in paint order),
|
|
||||||
* so the north strip ran the full width across the caption buttons and the
|
|
||||||
* north-east square sat on the close button's own top-right corner. Crossing
|
|
||||||
* one stole `:hover` from the button underneath, and since 7.css cross-fades
|
|
||||||
* the caption glow, a stolen hover reads as the glow flickering or sticking —
|
|
||||||
* "weird artifacts on the buttons", with no visual bug anywhere in the CSS.
|
|
||||||
*
|
|
||||||
* The bottom-right corner is the one place that overlaps nothing: it lands in
|
|
||||||
* the 6px gutter that `.window-body { margin: 0 6px 6px }` leaves below and
|
|
||||||
* right of the content. `resizeFrame` stays general (and the probe still covers
|
|
||||||
* all eight edges) so restoring a side is one entry here — but any new handle
|
|
||||||
* must be checked against what it covers first. South, east and west are the
|
|
||||||
* innocent ones; north and both top corners are not.
|
|
||||||
*
|
|
||||||
* 1rem, not the 0.75rem the corners had: this is now the only way to resize the
|
|
||||||
* window, so the target earns the extra pixels. Rem, so it scales with the
|
|
||||||
* interface scale like everything else.
|
|
||||||
*/
|
|
||||||
const GRIP_EDGE: ResizeEdge = 'se';
|
|
||||||
const GRIP_BOX = 'right-0 bottom-0 size-4 cursor-nwse-resize';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-window-resize',
|
|
||||||
template: `
|
|
||||||
<div
|
|
||||||
class="pointer-events-auto absolute touch-none electrobun-webkit-app-region-no-drag {{ gripBox }}"
|
|
||||||
(pointerdown)="onPointerDown($event)"
|
|
||||||
(pointermove)="onPointerMove($event)"
|
|
||||||
(pointerup)="onPointerEnd()"
|
|
||||||
(pointercancel)="onPointerEnd()"
|
|
||||||
></div>
|
|
||||||
`,
|
|
||||||
// The overlay spans the frame but is transparent to the pointer; only the
|
|
||||||
// handles inside it take hits. `w7-resize` is what the maximized rule hides.
|
|
||||||
host: { class: 'w7-resize pointer-events-none absolute inset-0 z-10' },
|
|
||||||
})
|
|
||||||
export class WindowResize {
|
|
||||||
protected readonly gripBox = GRIP_BOX;
|
|
||||||
|
|
||||||
private readonly rpc = inject(RpcService);
|
|
||||||
|
|
||||||
/** Null except during a drag. Set only once the frame read resolves. */
|
|
||||||
private drag: { screenX: number; screenY: number; frame: Frame } | null = null;
|
|
||||||
/** Newest target frame not yet sent, if a send is already in flight. */
|
|
||||||
private pending: Frame | null = null;
|
|
||||||
private sending = false;
|
|
||||||
|
|
||||||
onPointerDown(event: PointerEvent): void {
|
|
||||||
if (!this.rpc.available || event.button !== 0) return;
|
|
||||||
(event.target as HTMLElement).setPointerCapture(event.pointerId);
|
|
||||||
// captured synchronously — the frame read below is a round trip
|
|
||||||
const screenX = event.screenX;
|
|
||||||
const screenY = event.screenY;
|
|
||||||
void this.rpc.request.windowGetFrame({}).then((res) => {
|
|
||||||
if (!res.ok) return;
|
|
||||||
this.drag = {
|
|
||||||
screenX,
|
|
||||||
screenY,
|
|
||||||
frame: { x: res.x, y: res.y, width: res.width, height: res.height },
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onPointerMove(event: PointerEvent): void {
|
|
||||||
const drag = this.drag;
|
|
||||||
if (!drag) return; // moved before the frame arrived, or not dragging
|
|
||||||
this.pending = resizeFrame(
|
|
||||||
GRIP_EDGE,
|
|
||||||
drag.frame,
|
|
||||||
event.screenX - drag.screenX,
|
|
||||||
event.screenY - drag.screenY,
|
|
||||||
);
|
|
||||||
void this.flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
onPointerEnd(): void {
|
|
||||||
this.drag = null; // capture releases itself on pointerup/cancel
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drain `pending`, one request at a time. Re-entrant calls return
|
|
||||||
* immediately; the running loop picks up whatever they left behind. */
|
|
||||||
private async flush(): Promise<void> {
|
|
||||||
if (this.sending) return;
|
|
||||||
this.sending = true;
|
|
||||||
try {
|
|
||||||
while (this.pending) {
|
|
||||||
const frame = this.pending;
|
|
||||||
this.pending = null;
|
|
||||||
await this.rpc.request.windowSetFrame(frame);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
this.sending = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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="closeCircle24" size="var(--icon-size-xl)" 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,12 @@
|
|||||||
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="server24" size="var(--icon-size-xl)" 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"
|
(click)="focusManager()"
|
||||||
|
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>
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
import { Component, inject, signal, viewChild } from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import { I18nService } from '@core/i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import { BRANDING } from '@shared/branding';
|
import { BRANDING } from '@shared/branding';
|
||||||
|
import { NATIVE_MENU_IDS } from '@shared/keymap';
|
||||||
import type { ColumnKey } from '@shared/types';
|
import type { ColumnKey } from '@shared/types';
|
||||||
import { ConnectionsService } from '@core/connections.service';
|
import { ConnectionsService } from '@core/connections.service';
|
||||||
|
import { ContextMenuService } from '@core/context-menu.service';
|
||||||
import { ContextMenu } from './context-menu';
|
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 '@core/dialogs.service';
|
import { DialogsService } from '@core/dialogs.service';
|
||||||
import { FileActionsService } from '@core/file-actions.service';
|
import { FileActionsService, type FileCommand } from '@core/file-actions.service';
|
||||||
import { FilePanel } from './file-panel/file-panel';
|
import { FilePanel } from './file-panel/file-panel';
|
||||||
import { PanelsService } from '@core/panels.service';
|
import { PanelsService } from '@core/panels.service';
|
||||||
import { blockNativeContextMenu } from '@core/platform';
|
import { blockNativeContextMenu, blockReloadKeys } from '@core/platform';
|
||||||
import { RpcService } from '@core/rpc.service';
|
import { RpcService } from '@core/rpc.service';
|
||||||
import { SettingsService } from '@core/settings.service';
|
import { SettingsService } from '@core/settings.service';
|
||||||
import { ShortcutsService } from '@core/shortcuts.service';
|
import { ShortcutsService, type ShortcutId } 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 '@core/toast.service';
|
import { ToastService } from '@core/toast.service';
|
||||||
@@ -44,6 +46,7 @@ import { UpdaterService } from '@core/updater.service';
|
|||||||
host: {
|
host: {
|
||||||
'(window:keydown)': 'onWindowKeydown($event)',
|
'(window:keydown)': 'onWindowKeydown($event)',
|
||||||
'(window:contextmenu)': 'blockContextMenu($event)',
|
'(window:contextmenu)': 'blockContextMenu($event)',
|
||||||
|
'(mousedown)': 'keepListFocus($event)',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export class App {
|
export class App {
|
||||||
@@ -58,6 +61,8 @@ export class App {
|
|||||||
private readonly settings = inject(SettingsService);
|
private readonly settings = inject(SettingsService);
|
||||||
private readonly shortcuts = inject(ShortcutsService);
|
private readonly shortcuts = inject(ShortcutsService);
|
||||||
private readonly updater = inject(UpdaterService);
|
private readonly updater = inject(UpdaterService);
|
||||||
|
private readonly menu = inject(ContextMenuService);
|
||||||
|
private readonly transferBar = viewChild(TransferBar);
|
||||||
|
|
||||||
/** The connection-manager window is open — this window is modal-blocked. */
|
/** The connection-manager window is open — this window is modal-blocked. */
|
||||||
readonly managerOpen = signal(false);
|
readonly managerOpen = signal(false);
|
||||||
@@ -105,10 +110,6 @@ export class App {
|
|||||||
|
|
||||||
/** Native application-menu / manager-window actions forwarded from bun. */
|
/** Native application-menu / manager-window actions forwarded from bun. */
|
||||||
private async handleMenuAction(action: string): Promise<void> {
|
private async handleMenuAction(action: string): Promise<void> {
|
||||||
if (action.startsWith('ctx-')) {
|
|
||||||
this.handleContextAction(action);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (action.startsWith('connect:')) {
|
if (action.startsWith('connect:')) {
|
||||||
const connection = await this.findConnection(action.slice('connect:'.length));
|
const connection = await this.findConnection(action.slice('connect:'.length));
|
||||||
if (connection) {
|
if (connection) {
|
||||||
@@ -137,22 +138,19 @@ export class App {
|
|||||||
}
|
}
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'new-tab':
|
case 'new-tab':
|
||||||
this.panels.newWorkspace();
|
|
||||||
break;
|
|
||||||
case 'close-tab':
|
case 'close-tab':
|
||||||
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
|
this.runWindowCommand(action);
|
||||||
break;
|
break;
|
||||||
|
// the macOS File / View items act on the active panel, exactly like their keys
|
||||||
case 'new-folder':
|
case 'new-folder':
|
||||||
this.actions.mkdir(this.panels.active());
|
|
||||||
break;
|
|
||||||
case 'new-file':
|
case 'new-file':
|
||||||
this.actions.createFile(this.panels.active());
|
this.runFileCommand(action);
|
||||||
break;
|
break;
|
||||||
case 'edit-file':
|
case 'edit-file':
|
||||||
void this.actions.edit(this.panels.active());
|
this.runFileCommand('edit');
|
||||||
break;
|
break;
|
||||||
case 'refresh-active':
|
case 'refresh-active':
|
||||||
void this.panels.active().refresh();
|
this.runFileCommand('refresh');
|
||||||
break;
|
break;
|
||||||
case 'check-for-updates':
|
case 'check-for-updates':
|
||||||
void this.updater.check();
|
void this.updater.check();
|
||||||
@@ -160,33 +158,101 @@ export class App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Window shortcuts — matching lives in ShortcutsService (one keymap for the
|
private runFileCommand(id: FileCommand): void {
|
||||||
* app); this switch only maps ids onto actions. Panel-level keys stay on
|
const panel = this.panels.active();
|
||||||
* the panels themselves. */
|
const entry = panel.sorted()[panel.cursor()] ?? null;
|
||||||
|
this.actions.run(id, { side: this.panels.activeSide(), panel, entry });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Window keys — the `main` scope of the shared keymap (ShortcutsService); the
|
||||||
|
* focused file list handles its own `panel` keys first. Whatever nobody
|
||||||
|
* claims ends at the WebView2 reload guard.
|
||||||
|
*/
|
||||||
onWindowKeydown(event: KeyboardEvent): void {
|
onWindowKeydown(event: KeyboardEvent): void {
|
||||||
if (this.managerOpen()) return; // modal-blocked behind the manager
|
this.handleWindowKey(event);
|
||||||
const match = this.shortcuts.resolve(event, 'main');
|
blockReloadKeys(event); // a no-op once something claimed the key
|
||||||
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
|
private handleWindowKey(event: KeyboardEvent): void {
|
||||||
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
|
// a component took it (the list, a menu, a dialog), or the connection
|
||||||
event.preventDefault();
|
// manager holds this window modal
|
||||||
switch (match.id) {
|
if (event.defaultPrevented || this.managerOpen()) return;
|
||||||
|
if (this.dialogs.anyOpen || this.menu.request()) {
|
||||||
|
this.swallowBehindOverlay(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.target === document.body) {
|
||||||
|
// a click dropped focus to <body>: the active list still gets its keys
|
||||||
|
this.panels.forwardListKey(event);
|
||||||
|
this.panels.refocus();
|
||||||
|
if (event.defaultPrevented) return;
|
||||||
|
}
|
||||||
|
const id = this.shortcuts.claim(event, 'main');
|
||||||
|
if (id) this.runWindowCommand(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dialog or menu owns the keyboard. Window commands are swallowed — an
|
||||||
|
* unclaimed ⌘T would fall through to the macOS menu bar and open a tab
|
||||||
|
* behind the dialog — and so are the panel commands the menu bar carries
|
||||||
|
* (⌘N, ⌘E…). The interface-scale keys still work.
|
||||||
|
*/
|
||||||
|
private swallowBehindOverlay(event: KeyboardEvent): void {
|
||||||
|
const id = this.shortcuts.resolve(event, 'main');
|
||||||
|
if (id === 'scale-up' || id === 'scale-down' || id === 'scale-reset') {
|
||||||
|
const claimed = this.shortcuts.claim(event, 'main');
|
||||||
|
if (claimed) this.runWindowCommand(claimed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const panelId = id ? null : this.shortcuts.resolve(event, 'panel');
|
||||||
|
if (id || (panelId && NATIVE_MENU_IDS.includes(panelId))) event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
private runWindowCommand(id: ShortcutId): void {
|
||||||
|
const active = this.panels.activeWorkspaceId();
|
||||||
|
switch (id) {
|
||||||
case 'new-tab':
|
case 'new-tab':
|
||||||
this.panels.newWorkspace();
|
this.panels.newWorkspace();
|
||||||
break;
|
break;
|
||||||
case 'close-tab':
|
case 'close-tab':
|
||||||
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
|
this.panels.closeWorkspace(active);
|
||||||
break;
|
break;
|
||||||
case 'cycle-tab':
|
case 'close-other-tabs':
|
||||||
|
this.panels.closeOtherWorkspaces(active);
|
||||||
|
break;
|
||||||
|
case 'close-tabs-right':
|
||||||
|
this.panels.closeWorkspacesToRight(active);
|
||||||
|
break;
|
||||||
|
case 'next-tab':
|
||||||
this.panels.cycleWorkspace(1);
|
this.panels.cycleWorkspace(1);
|
||||||
break;
|
break;
|
||||||
case 'cycle-tab-back':
|
case 'prev-tab':
|
||||||
this.panels.cycleWorkspace(-1);
|
this.panels.cycleWorkspace(-1);
|
||||||
break;
|
break;
|
||||||
case 'go-to-tab':
|
case 'go-to-last-tab':
|
||||||
// ⌘1–⌘8 jump to that tab, ⌘9 to the last, like Firefox
|
this.panels.goToWorkspace(-1);
|
||||||
this.panels.goToWorkspace(match.digit === 9 ? -1 : match.digit! - 1);
|
break;
|
||||||
|
case 'move-tab-left':
|
||||||
|
this.moveActiveTab(-1);
|
||||||
|
break;
|
||||||
|
case 'move-tab-right':
|
||||||
|
this.moveActiveTab(1);
|
||||||
|
break;
|
||||||
|
case 'disconnect-tab':
|
||||||
|
void this.panels.disconnectWorkspace(active);
|
||||||
|
break;
|
||||||
|
case 'open-settings':
|
||||||
|
void this.rpc.request.openSettings({});
|
||||||
|
break;
|
||||||
|
case 'connection-manager':
|
||||||
|
void this.rpc.request.openConnectionManager({});
|
||||||
|
break;
|
||||||
|
case 'toggle-transfers':
|
||||||
|
this.toggleTransfers();
|
||||||
|
break;
|
||||||
|
case 'install-update':
|
||||||
|
this.updater.advance();
|
||||||
break;
|
break;
|
||||||
case 'scale-up':
|
case 'scale-up':
|
||||||
this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
||||||
@@ -197,10 +263,48 @@ export class App {
|
|||||||
case 'scale-reset':
|
case 'scale-reset':
|
||||||
this.settings.setUiScale(1);
|
this.settings.setUiScale(1);
|
||||||
break;
|
break;
|
||||||
|
default: {
|
||||||
|
// go-to-tab-1…8: that tab (go-to-last-tab is ⌘9, like Firefox)
|
||||||
|
const n = this.shortcuts.binding(id)?.n;
|
||||||
|
if (n) this.panels.goToWorkspace(n - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move-tab keys: the keyboard's tab drag. */
|
||||||
|
private moveActiveTab(delta: 1 | -1): void {
|
||||||
|
const list = this.panels.workspaces();
|
||||||
|
const index = list.findIndex((w) => w.id === this.panels.activeWorkspaceId());
|
||||||
|
if (index >= 0) this.panels.moveWorkspace(list[index].id, index + delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ⌘J: open the transfer list with focus in it (Esc goes back to the files),
|
||||||
|
* or close it. It only renders with jobs, so with none there is nothing to show. */
|
||||||
|
private toggleTransfers(): void {
|
||||||
|
if (!this.transfers.hasJobs()) return;
|
||||||
|
const open = !this.transfers.expanded();
|
||||||
|
this.transfers.expanded.set(open);
|
||||||
|
if (open) this.transferBar()?.focusFirst();
|
||||||
|
else this.panels.refocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A click on a chrome button keeps keyboard focus where it was — normally the
|
||||||
|
* file list. WebKit drops focus to <body> on a button click (every list key
|
||||||
|
* then died until the list was clicked), Windows parks it on the button,
|
||||||
|
* where Space would click it again. The click itself still fires. Text
|
||||||
|
* fields, menus, dialogs and the draggable tabs keep their default.
|
||||||
|
*/
|
||||||
|
keepListFocus(event: MouseEvent): void {
|
||||||
|
const target = event.target as Element;
|
||||||
|
if (
|
||||||
|
target.closest('button') &&
|
||||||
|
!target.closest('input, select, textarea, [role=menu], [role=dialog], [draggable=true]')
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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
|
* Workspace-tab menu actions. The target id travels INSIDE the action string
|
||||||
* because the right-clicked tab is not necessarily the active one — nothing
|
* because the right-clicked tab is not necessarily the active one — nothing
|
||||||
@@ -229,55 +333,4 @@ export class App {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleContextAction(action: string): void {
|
|
||||||
const target = this.actions.menuTarget;
|
|
||||||
if (!target) return;
|
|
||||||
const { panel, side, entry } = target;
|
|
||||||
// the empty-space menu acts on the directory itself
|
|
||||||
const path = entry?.path ?? panel.cwd();
|
|
||||||
switch (action) {
|
|
||||||
case 'ctx-open':
|
|
||||||
if (entry) panel.open(entry);
|
|
||||||
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':
|
|
||||||
void this.actions.edit(panel);
|
|
||||||
break;
|
|
||||||
case 'ctx-copy':
|
|
||||||
void this.transfers.copyToOtherPanel(side);
|
|
||||||
break;
|
|
||||||
case 'ctx-rename':
|
|
||||||
this.actions.rename(panel);
|
|
||||||
break;
|
|
||||||
case 'ctx-chmod':
|
|
||||||
this.actions.chmod(panel);
|
|
||||||
break;
|
|
||||||
case 'ctx-delete':
|
|
||||||
this.actions.delete(panel);
|
|
||||||
break;
|
|
||||||
case 'ctx-trash':
|
|
||||||
if (entry) void this.actions.trash(panel, entry);
|
|
||||||
break;
|
|
||||||
case 'ctx-new-folder':
|
|
||||||
this.actions.mkdir(panel);
|
|
||||||
break;
|
|
||||||
case 'ctx-new-file':
|
|
||||||
this.actions.createFile(panel);
|
|
||||||
break;
|
|
||||||
case 'ctx-refresh':
|
|
||||||
void panel.refresh();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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="server16" size="var(--icon-size-md)" 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]="shortcuts.tip(i18n.t('connMgr.importWinscpHint'), 'conn-import')"
|
||||||
class="flex items-center gap-1.5 rounded-md 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"
|
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="var(--icon-size-sm)" class="animate-spin" />
|
||||||
} @else {
|
} @else {
|
||||||
<!-- <ng-icon name="mynaArchive" size="0.85rem" /> -->
|
<!-- <ng-icon name="tdesignFileZip" size="var(--icon-size-sm)" /> -->
|
||||||
}
|
}
|
||||||
{{ i18n.t('connMgr.importWinscp') }}
|
{{ i18n.t('connMgr.importWinscp') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -33,77 +33,79 @@
|
|||||||
<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 px-2 py-1.5">
|
||||||
<button
|
<button
|
||||||
(click)="newConnection()"
|
(click)="newConnection()"
|
||||||
[title]="i18n.t('connMgr.newConnectionHint')"
|
[title]="shortcuts.tip(i18n.t('connMgr.newConnectionHint'), 'conn-new')"
|
||||||
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="add14" size="var(--icon-size-sm)" /> {{ i18n.t('connMgr.newConnection') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="newStack()"
|
(click)="newStack()"
|
||||||
[title]="i18n.t('connMgr.newStackHint')"
|
[title]="shortcuts.tip(i18n.t('connMgr.newStackHint'), 'conn-new-stack')"
|
||||||
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
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="folderAdd14" size="var(--icon-size-sm)" /> {{ i18n.t('connMgr.newStack') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto py-1">
|
<!-- ↑ ↓ ← → Home End Enter move through it (onListKeydown) -->
|
||||||
|
<div #list (keydown)="onListKeydown($event)" class="flex-1 overflow-y-auto py-1">
|
||||||
<!-- stacks -->
|
<!-- stacks -->
|
||||||
@for (group of store.grouped().stacks; track group.stack.id) {
|
@for (group of store.grouped().stacks; track group.stack.id) {
|
||||||
<div
|
<div
|
||||||
(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"
|
||||||
[class.ring-primary]="dropTarget() === group.stack.id"
|
[class.ring-primary]="dropTarget() === group.stack.id"
|
||||||
>
|
>
|
||||||
<div class="group flex items-center gap-1 px-2 pt-2 pb-0.5">
|
<div [attr.data-stack]="group.stack.id" class="group flex items-center gap-1 px-2 pt-2 pb-0.5">
|
||||||
<button
|
<button
|
||||||
|
data-row
|
||||||
(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 ? 'chevronRight14' : 'chevronDown14'"
|
||||||
size="0.8rem"
|
size="var(--icon-size-sm)"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<span class="truncate text-[0.6875rem] 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 — and when the keyboard is in
|
||||||
so a long stack name can't collide with the buttons -->
|
the row: they never share the space, so a long stack name
|
||||||
|
can't collide with the buttons -->
|
||||||
<span
|
<span
|
||||||
class="shrink-0 text-[0.625rem] text-muted-foreground/70 tabular-nums group-hover:hidden"
|
class="shrink-0 text-[0.625rem] text-muted-foreground/70 tabular-nums group-focus-within:hidden group-hover:hidden"
|
||||||
>
|
>
|
||||||
{{ group.connections.length }}
|
{{ group.connections.length }}
|
||||||
</span>
|
</span>
|
||||||
<div class="hidden shrink-0 items-center gap-0.5 group-hover:flex">
|
<div class="hidden shrink-0 items-center gap-0.5 group-focus-within:flex group-hover:flex">
|
||||||
<button
|
<button
|
||||||
(click)="renameStack(group.stack)"
|
(click)="renameStack(group.stack)"
|
||||||
[title]="i18n.t('connMgr.renameStack')"
|
[title]="shortcuts.tip(i18n.t('connMgr.renameStack'), 'conn-rename-stack')"
|
||||||
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="edit14" size="var(--icon-size-sm)" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="deleteStack(group.stack)"
|
(click)="deleteStack(group.stack)"
|
||||||
[title]="i18n.t('connMgr.deleteStack')"
|
[title]="shortcuts.tip(i18n.t('connMgr.deleteStack'), 'conn-delete')"
|
||||||
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="delete14" size="var(--icon-size-sm)" />
|
||||||
</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]="shortcuts.tip(i18n.t('connMgr.openStack'), 'conn-connect')"
|
||||||
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="arrowRight14" size="var(--icon-size-sm)" />
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -111,6 +113,8 @@
|
|||||||
@if (!group.stack.collapsed) {
|
@if (!group.stack.collapsed) {
|
||||||
@for (connection of group.connections; track connection.id) {
|
@for (connection of group.connections; track connection.id) {
|
||||||
<button
|
<button
|
||||||
|
data-row
|
||||||
|
[attr.data-conn]="connection.id"
|
||||||
(click)="edit(connection)"
|
(click)="edit(connection)"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
(dragstart)="onDragStart($event, connection)"
|
(dragstart)="onDragStart($event, connection)"
|
||||||
@@ -119,7 +123,7 @@
|
|||||||
[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="server14" size="var(--icon-size-sm)" class="shrink-0 text-muted-foreground" />
|
||||||
<span class="truncate">{{ connection.name }}</span>
|
<span class="truncate">{{ connection.name }}</span>
|
||||||
</button>
|
</button>
|
||||||
} @empty {
|
} @empty {
|
||||||
@@ -134,7 +138,7 @@
|
|||||||
(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'"
|
||||||
@@ -147,6 +151,8 @@
|
|||||||
}
|
}
|
||||||
@for (connection of store.grouped().loose; track connection.id) {
|
@for (connection of store.grouped().loose; track connection.id) {
|
||||||
<button
|
<button
|
||||||
|
data-row
|
||||||
|
[attr.data-conn]="connection.id"
|
||||||
(click)="edit(connection)"
|
(click)="edit(connection)"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
(dragstart)="onDragStart($event, connection)"
|
(dragstart)="onDragStart($event, connection)"
|
||||||
@@ -155,7 +161,7 @@
|
|||||||
[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="server14" size="var(--icon-size-sm)" class="shrink-0 text-muted-foreground" />
|
||||||
<span class="truncate">{{ connection.name }}</span>
|
<span class="truncate">{{ connection.name }}</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -175,15 +181,20 @@
|
|||||||
<!-- edit form -->
|
<!-- edit form -->
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
@if (draft(); as d) {
|
@if (draft(); as d) {
|
||||||
<div class="grid flex-1 auto-rows-min grid-cols-2 gap-x-4 gap-y-3 overflow-y-auto p-5">
|
<!-- Enter in a field = Connect, the primary button -->
|
||||||
|
<div
|
||||||
|
(keydown.enter)="onFormEnter($event)"
|
||||||
|
class="grid flex-1 auto-rows-min grid-cols-2 gap-x-4 gap-y-3 overflow-y-auto p-5"
|
||||||
|
>
|
||||||
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
||||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.name') }}</span>
|
<span class="text-muted-foreground">{{ i18n.t('connMgr.name') }}</span>
|
||||||
<input
|
<input
|
||||||
|
#nameBox
|
||||||
[value]="d.name"
|
[value]="d.name"
|
||||||
(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 +205,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 +215,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 +242,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 +252,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 +264,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 +273,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 +285,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 +297,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-[0.6875rem] 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 +314,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,7 +327,7 @@
|
|||||||
(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-[0.625rem] text-muted-foreground/80">
|
<span class="text-[0.625rem] text-muted-foreground/80">
|
||||||
{{ i18n.t('connMgr.localDirHint') }}
|
{{ i18n.t('connMgr.localDirHint') }}
|
||||||
@@ -330,13 +339,14 @@
|
|||||||
<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"
|
[title]="shortcuts.tip(i18n.t('common.delete'), 'conn-delete')"
|
||||||
|
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="delete14" size="var(--icon-size-sm)" /> {{ 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 +354,19 @@
|
|||||||
<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"
|
[title]="shortcuts.tip(i18n.t('common.save'), 'conn-save')"
|
||||||
|
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"
|
[title]="shortcuts.tip(i18n.t('common.connect'), 'conn-connect')"
|
||||||
|
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="var(--icon-size-sm)" class="animate-spin" />
|
||||||
}
|
}
|
||||||
{{ i18n.t('common.connect') }}
|
{{ i18n.t('common.connect') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -362,7 +374,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="server24" size="var(--icon-size-xl)" 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>
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
import { Component, computed, inject, signal } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
Injector,
|
||||||
|
afterNextRender,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
viewChild,
|
||||||
|
} from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import { I18nService } from '@core/i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import type { SavedConnection, Stack } from '@shared/types';
|
import type { SavedConnection, Stack } from '@shared/types';
|
||||||
@@ -6,10 +16,12 @@ 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 '@core/dialogs.service';
|
import { DialogsService } from '@core/dialogs.service';
|
||||||
import { blockNativeContextMenu, IS_MAC_CHROME } from '@core/platform';
|
import { cycleFocus } from '@core/focus-cycle';
|
||||||
|
import { FocusService } from '@core/focus.service';
|
||||||
|
import { blockNativeContextMenu, blockReloadKeys, IS_MAC_CHROME } from '@core/platform';
|
||||||
import { RpcService } from '@core/rpc.service';
|
import { RpcService } from '@core/rpc.service';
|
||||||
import { SettingsService } from '@core/settings.service';
|
import { SettingsService } from '@core/settings.service';
|
||||||
import { ShortcutsService } from '@core/shortcuts.service';
|
import { ShortcutsService, type ShortcutId } from '@core/shortcuts.service';
|
||||||
import { Toasts } from '../toasts/toasts';
|
import { Toasts } from '../toasts/toasts';
|
||||||
|
|
||||||
interface Draft {
|
interface Draft {
|
||||||
@@ -76,8 +88,8 @@ function fromDraft(d: Draft): SavedConnection {
|
|||||||
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts],
|
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts],
|
||||||
templateUrl: './connection-manager.html',
|
templateUrl: './connection-manager.html',
|
||||||
host: {
|
host: {
|
||||||
'(window:keydown.escape)': 'onEscape()',
|
|
||||||
'(window:keydown)': 'onWindowKeydown($event)',
|
'(window:keydown)': 'onWindowKeydown($event)',
|
||||||
|
'(window:focusin)': 'rememberFocus($event)',
|
||||||
'(window:contextmenu)': 'blockContextMenu($event)',
|
'(window:contextmenu)': 'blockContextMenu($event)',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -90,7 +102,13 @@ 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 shortcuts = inject(ShortcutsService);
|
||||||
|
private readonly focus = inject(FocusService);
|
||||||
|
private readonly injector = inject(Injector);
|
||||||
|
private readonly list = viewChild.required<ElementRef<HTMLElement>>('list');
|
||||||
|
private readonly nameBox = viewChild<ElementRef<HTMLInputElement>>('nameBox');
|
||||||
|
/** Where focus was outside a dialog, to come back to when it closes. */
|
||||||
|
private lastFocus: HTMLElement | null = null;
|
||||||
|
|
||||||
readonly draft = signal<Draft | null>(null);
|
readonly draft = signal<Draft | null>(null);
|
||||||
readonly busy = signal(false);
|
readonly busy = signal(false);
|
||||||
@@ -102,6 +120,27 @@ export class ConnectionManager {
|
|||||||
/** Highlighted drop target: a stack id, 'loose', or null. */
|
/** Highlighted drop target: a stack id, 'loose', or null. */
|
||||||
readonly dropTarget = signal<string | null>(null);
|
readonly dropTarget = signal<string | null>(null);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// the keyboard starts in the list — and lands there again whenever focus
|
||||||
|
// fell to <body> (the store loads after the first render; a deleted row
|
||||||
|
// takes its focus with it)
|
||||||
|
effect(() => {
|
||||||
|
this.store.grouped();
|
||||||
|
afterNextRender(() => this.focusFallback(), { injector: this.injector });
|
||||||
|
});
|
||||||
|
// dialogs bump FocusService when they close: return to where the user was
|
||||||
|
effect(() => {
|
||||||
|
this.focus.tick();
|
||||||
|
afterNextRender(
|
||||||
|
() => {
|
||||||
|
if (this.lastFocus?.isConnected) this.lastFocus.focus();
|
||||||
|
else this.focusFallback();
|
||||||
|
},
|
||||||
|
{ injector: this.injector },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
readonly valid = computed(() => {
|
readonly valid = computed(() => {
|
||||||
const d = this.draft();
|
const d = this.draft();
|
||||||
if (!d) return false;
|
if (!d) return false;
|
||||||
@@ -141,19 +180,174 @@ export class ConnectionManager {
|
|||||||
void this.rpc.request.managerClose({});
|
void this.rpc.request.managerClose({});
|
||||||
}
|
}
|
||||||
|
|
||||||
onEscape(): void {
|
/**
|
||||||
// in-window dialogs (confirm/prompt) handle their own Escape first
|
* This window's keys (the `manager` scope), then the fixed ones: Esc closes
|
||||||
if (!this.dialogs.anyOpen) this.close();
|
* the window — from a text field it first just leaves the field, so a
|
||||||
|
* half-typed connection isn't thrown away — and Tab is walked by hand
|
||||||
|
* (WKWebView skips buttons and radios on Tab otherwise). The dialogs take
|
||||||
|
* their own Esc and stop it, so it never reaches here.
|
||||||
|
*/
|
||||||
|
onWindowKeydown(event: KeyboardEvent): void {
|
||||||
|
const id = this.shortcuts.claim(event, 'manager');
|
||||||
|
if (id) this.runCommand(id);
|
||||||
|
else if (event.key === 'Escape' && !this.dialogs.anyOpen) this.onEscape(event);
|
||||||
|
else cycleFocus(document.body, event);
|
||||||
|
blockReloadKeys(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Any-window shortcuts (interface scale). Escape has its own binding above. */
|
private onEscape(event: KeyboardEvent): void {
|
||||||
onWindowKeydown(event: KeyboardEvent): void {
|
|
||||||
const match = this.shortcuts.resolve(event, 'manager');
|
|
||||||
if (!match || this.shortcuts.isTypingTarget(event)) return;
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (match.id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
if (this.shortcuts.isTypingTarget(event)) {
|
||||||
else if (match.id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1);
|
(event.target as HTMLElement).blur();
|
||||||
else if (match.id === 'scale-reset') this.settings.setUiScale(1);
|
this.focusFallback(true);
|
||||||
|
} else {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private runCommand(id: ShortcutId): void {
|
||||||
|
const row = this.focusedRow();
|
||||||
|
switch (id) {
|
||||||
|
case 'close-window':
|
||||||
|
this.close();
|
||||||
|
break;
|
||||||
|
case 'conn-new':
|
||||||
|
this.newConnection();
|
||||||
|
this.focusName();
|
||||||
|
break;
|
||||||
|
case 'conn-new-stack':
|
||||||
|
this.newStack();
|
||||||
|
break;
|
||||||
|
case 'conn-save':
|
||||||
|
void this.save();
|
||||||
|
break;
|
||||||
|
case 'conn-connect':
|
||||||
|
// the focused stack opens in tabs; a focused connection, or the form, connects
|
||||||
|
if (row?.stack) this.openStack(row.stack.id);
|
||||||
|
else {
|
||||||
|
if (row?.connection) this.edit(row.connection);
|
||||||
|
void this.saveAndConnect();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'conn-delete':
|
||||||
|
if (row?.stack) this.deleteStack(row.stack);
|
||||||
|
else {
|
||||||
|
if (row?.connection) this.edit(row.connection);
|
||||||
|
this.deleteCurrent();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'conn-rename-stack':
|
||||||
|
if (row?.stack) this.renameStack(row.stack);
|
||||||
|
break;
|
||||||
|
case 'conn-import':
|
||||||
|
void this.importWinScp();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- keyboard: the list ----
|
||||||
|
|
||||||
|
/** The stack or connection row the focus sits in (its buttons included). */
|
||||||
|
private focusedRow(): { stack?: Stack; connection?: SavedConnection } | null {
|
||||||
|
const el = document.activeElement as HTMLElement | null;
|
||||||
|
const stackId = el?.closest<HTMLElement>('[data-stack]')?.dataset['stack'];
|
||||||
|
if (stackId) {
|
||||||
|
const stack = this.store.stacks().find((s) => s.id === stackId);
|
||||||
|
return stack ? { stack } : null;
|
||||||
|
}
|
||||||
|
const connId = el?.closest<HTMLElement>('[data-conn]')?.dataset['conn'];
|
||||||
|
const connection = connId && this.store.connections().find((c) => c.id === connId);
|
||||||
|
return connection ? { connection } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private rows(): HTMLElement[] {
|
||||||
|
return [...this.list().nativeElement.querySelectorAll<HTMLElement>('[data-row]')];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ↑ ↓ Home End move between rows (focus only — loading a connection would
|
||||||
|
* replace the form without asking); ← / → collapse and expand a stack, ← on
|
||||||
|
* a connection goes up to its stack; Enter on a connection opens it in the
|
||||||
|
* form (Enter / Space on a stack toggles it, natively).
|
||||||
|
*/
|
||||||
|
onListKeydown(event: KeyboardEvent): void {
|
||||||
|
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||||
|
const rows = this.rows();
|
||||||
|
const at = rows.indexOf(document.activeElement as HTMLElement);
|
||||||
|
const row = this.focusedRow();
|
||||||
|
let target: HTMLElement | undefined;
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
target = rows[Math.min(rows.length - 1, at + 1)];
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
target = rows[Math.max(0, at - 1)];
|
||||||
|
break;
|
||||||
|
case 'Home':
|
||||||
|
target = rows[0];
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
target = rows.at(-1);
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
if (row?.stack && !row.stack.collapsed) this.toggleStack(row.stack);
|
||||||
|
else if (row?.connection?.stackId) target = this.stackRow(row.connection.stackId);
|
||||||
|
else return;
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
if (row?.stack?.collapsed) this.toggleStack(row.stack);
|
||||||
|
else return;
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
if (!row?.connection) return;
|
||||||
|
this.edit(row.connection);
|
||||||
|
this.focusName();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
target?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private stackRow(stackId: string): HTMLElement | undefined {
|
||||||
|
return this.rows().find((el) => el.closest<HTMLElement>('[data-stack]')?.dataset['stack'] === stackId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enter in a form field = Connect, the primary button (not mid-IME). */
|
||||||
|
onFormEnter(event: Event): void {
|
||||||
|
const key = event as KeyboardEvent;
|
||||||
|
const target = key.target as HTMLElement;
|
||||||
|
if (key.isComposing || !target.matches('input:not([type=radio]):not([type=checkbox])')) return;
|
||||||
|
key.preventDefault();
|
||||||
|
if (this.valid()) void this.saveAndConnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
rememberFocus(event: FocusEvent): void {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target instanceof HTMLElement && !target.closest('[role=dialog]')) this.lastFocus = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Focus the edited connection's row, else the first row — only when focus
|
||||||
|
* is nowhere (on <body>), unless forced. */
|
||||||
|
private focusFallback(force = false): void {
|
||||||
|
if (!force && document.activeElement !== document.body) return;
|
||||||
|
const id = this.draft()?.id;
|
||||||
|
const own = id ? this.rows().find((el) => el.closest<HTMLElement>('[data-conn]')?.dataset['conn'] === id) : undefined;
|
||||||
|
(own ?? this.rows()[0])?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private focusName(): void {
|
||||||
|
afterNextRender(() => this.nameBox()?.nativeElement.focus(), { injector: this.injector });
|
||||||
}
|
}
|
||||||
|
|
||||||
blockContextMenu(event: Event): void {
|
blockContextMenu(event: Event): void {
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import {
|
|||||||
viewChild,
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import { ContextMenuService, type MenuItem } from '@core/context-menu.service';
|
import { ContextMenuService, isSelectable, type MenuItem } from '@core/context-menu.service';
|
||||||
import { FocusService } from '@core/focus.service';
|
import { FocusService } from '@core/focus.service';
|
||||||
|
import { stepIndex } from '@core/menu-nav';
|
||||||
import { remPx } from '@core/metrics';
|
import { remPx } from '@core/metrics';
|
||||||
|
import { ShortcutsService } from '@core/shortcuts.service';
|
||||||
|
|
||||||
/** Rough per-item metrics in REM, used only to decide whether the menu must
|
/** 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
|
* flip. Converted through remPx() at open time so the math tracks the same
|
||||||
@@ -19,13 +21,19 @@ import { remPx } from '@core/metrics';
|
|||||||
* scale while the flip math still believed 224 and fired at the wrong edge. */
|
* 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 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 SEPARATOR_H_REM = 0.4375; // my-1 + 1px border ≈ 7px @16
|
||||||
const MENU_W_REM = 14; // w-56
|
const HEADER_H_REM = 1.25; // pt-1.5 pb-0.5 + the 0.625rem caps line
|
||||||
|
const MENU_W_REM = 15; // w-60
|
||||||
const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
|
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
|
* The HTML context menu (rows, tabs, column headers, the panel source picker).
|
||||||
* is a no-op on Linux — see ContextMenuService for the why. Emits the same
|
* Replaces electrobun's native ContextMenu, which is a no-op on Linux — see
|
||||||
* `ctx-*` / `col:*` action strings the native menu did.
|
* ContextMenuService for the why.
|
||||||
|
*
|
||||||
|
* Keyboard: ↑↓ Home End move one highlight shared with the mouse, Enter/Space
|
||||||
|
* pick, Esc closes, and an item's own shortcut picks it while the menu is open
|
||||||
|
* (F2 renames from an open row menu). Every key it handles stops here, so the
|
||||||
|
* window's shortcut handler never runs it a second time.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-context-menu',
|
selector: 'app-context-menu',
|
||||||
@@ -38,33 +46,50 @@ const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
|
|||||||
#menuEl
|
#menuEl
|
||||||
role="menu"
|
role="menu"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
|
[attr.aria-activedescendant]="activeIndex() >= 0 ? 'ctx-item-' + activeIndex() : null"
|
||||||
(keydown)="onKeydown($event)"
|
(keydown)="onKeydown($event)"
|
||||||
[style.left.px]="position().x"
|
[style.left.px]="position().x"
|
||||||
[style.top.px]="position().y"
|
[style.top.px]="position().y"
|
||||||
class="fixed z-50 w-56 rounded-lg border border-border bg-card py-1 text-card-foreground shadow-xl outline-none"
|
[style.max-height.px]="position().maxHeight"
|
||||||
|
class="fixed z-50 w-60 overflow-y-auto border border-border/60 bg-chrome py-1 text-foreground shadow-xl outline-none"
|
||||||
>
|
>
|
||||||
@for (item of req.items; track $index) {
|
@for (item of req.items; track $index) {
|
||||||
@if (item.type === 'separator') {
|
@if (item.type === 'separator') {
|
||||||
<div class="my-1 border-t border-border"></div>
|
<div class="my-1 border-t border-border/60"></div>
|
||||||
|
} @else if (item.type === 'header') {
|
||||||
|
<div class="truncate px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
|
||||||
|
{{ item.label }}
|
||||||
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
|
tabindex="-1"
|
||||||
|
[attr.id]="'ctx-item-' + $index"
|
||||||
[title]="item.tooltip ?? ''"
|
[title]="item.tooltip ?? ''"
|
||||||
[disabled]="!!item.disabled"
|
[disabled]="!!item.disabled"
|
||||||
[class.bg-accent]="$index === activeIndex() && !item.disabled"
|
[class.bg-primary-surface]="$index === activeIndex() && !item.disabled"
|
||||||
|
[class.text-primary]="$index === activeIndex() && !item.disabled"
|
||||||
(mouseenter)="activeIndex.set($index)"
|
(mouseenter)="activeIndex.set($index)"
|
||||||
(click)="pick(item)"
|
(click)="pick(item)"
|
||||||
class="flex w-full items-center gap-2 px-3 py-1 text-left text-(length:--path-font) hover:bg-accent disabled:pointer-events-none disabled:opacity-50"
|
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">
|
<!-- the check column: a checkmark, else the item's icon -->
|
||||||
|
<span class="grid w-4 shrink-0 place-items-center">
|
||||||
@if (item.checked) {
|
@if (item.checked) {
|
||||||
<ng-icon name="mynaCheck" size="0.85rem" class="text-primary" />
|
<ng-icon name="check14" size="var(--icon-size-sm)" class="text-primary" />
|
||||||
|
} @else if (item.icon) {
|
||||||
|
<ng-icon
|
||||||
|
[name]="item.icon"
|
||||||
|
size="var(--icon-size-sm)"
|
||||||
|
[class.text-muted-foreground]="$index !== activeIndex() || !!item.disabled"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
<span class="flex-1 truncate">{{ item.label }}</span>
|
<span class="flex-1 truncate">{{ item.label }}</span>
|
||||||
@if (item.accelerator) {
|
@if (item.shortcut && shortcuts.label(item.shortcut); as hint) {
|
||||||
<span class="shrink-0 text-muted-foreground">{{ item.accelerator }}</span>
|
<!-- soft: the row's own colour at 60%, so it turns soft green with the highlight -->
|
||||||
|
<span class="ml-4 shrink-0 opacity-60">{{ hint }}</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -82,30 +107,37 @@ const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
|
|||||||
})
|
})
|
||||||
export class ContextMenu {
|
export class ContextMenu {
|
||||||
readonly menu = inject(ContextMenuService);
|
readonly menu = inject(ContextMenuService);
|
||||||
|
readonly shortcuts = inject(ShortcutsService);
|
||||||
private readonly focus = inject(FocusService);
|
private readonly focus = inject(FocusService);
|
||||||
private readonly menuEl = viewChild<ElementRef<HTMLDivElement>>('menuEl');
|
private readonly menuEl = viewChild<ElementRef<HTMLDivElement>>('menuEl');
|
||||||
|
|
||||||
readonly activeIndex = signal(-1);
|
readonly activeIndex = signal(-1);
|
||||||
|
|
||||||
/** Click point, flipped/clamped so the menu never overflows the viewport.
|
/** Click point, flipped/clamped so the menu never overflows the viewport.
|
||||||
* All UI metrics convert through the live root font-size; window.inner* is
|
* A menu taller than the viewport (a long source picker) scrolls inside a
|
||||||
* viewport CSS px, so both sides of every comparison share units. */
|
* clamped box. 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(() => {
|
readonly position = computed(() => {
|
||||||
const req = this.menu.request();
|
const req = this.menu.request();
|
||||||
if (!req) return { x: 0, y: 0 };
|
if (!req) return { x: 0, y: 0, maxHeight: 0 };
|
||||||
const itemH = remPx(ITEM_H_REM);
|
const heights = {
|
||||||
const separatorH = remPx(SEPARATOR_H_REM);
|
separator: remPx(SEPARATOR_H_REM),
|
||||||
|
header: remPx(HEADER_H_REM),
|
||||||
|
item: remPx(ITEM_H_REM),
|
||||||
|
};
|
||||||
|
const content =
|
||||||
|
req.items.reduce((sum, i) => sum + heights[i.type ?? 'item'], 0) + remPx(0.5); // py-1 ×2
|
||||||
|
const maxHeight = window.innerHeight - 2 * EDGE_GAP;
|
||||||
|
const height = Math.min(content, maxHeight);
|
||||||
const menuW = remPx(MENU_W_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 =
|
const x =
|
||||||
req.x + menuW > window.innerWidth - EDGE_GAP ? Math.max(EDGE_GAP, req.x - menuW) : req.x;
|
req.x + menuW > window.innerWidth - EDGE_GAP ? Math.max(EDGE_GAP, req.x - menuW) : req.x;
|
||||||
const y =
|
const y =
|
||||||
req.y + height > window.innerHeight - EDGE_GAP
|
req.y + height > window.innerHeight - EDGE_GAP
|
||||||
? Math.max(EDGE_GAP, req.y - height)
|
? Math.max(EDGE_GAP, req.y - height)
|
||||||
: req.y;
|
: req.y;
|
||||||
return { x, y };
|
return { x, y, maxHeight };
|
||||||
});
|
});
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -113,22 +145,25 @@ export class ContextMenu {
|
|||||||
// the viewChild signal, NOT on request(): under zoneless CD an effect can
|
// the viewChild signal, NOT on request(): under zoneless CD an effect can
|
||||||
// run before the @if has rendered the element.
|
// run before the @if has rendered the element.
|
||||||
effect(() => this.menuEl()?.nativeElement.focus());
|
effect(() => this.menuEl()?.nativeElement.focus());
|
||||||
|
// a fresh menu starts with nothing highlighted — or, opened from the
|
||||||
|
// keyboard, with its first item
|
||||||
|
effect(() => this.activeIndex.set(this.menu.request()?.initialIndex ?? -1));
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
if (!this.menu.request()) return;
|
if (!this.menu.request()) return;
|
||||||
this.menu.close();
|
this.menu.close();
|
||||||
this.activeIndex.set(-1); // a fresh menu starts with nothing highlighted
|
|
||||||
this.focus.bump();
|
this.focus.bump();
|
||||||
}
|
}
|
||||||
|
|
||||||
pick(item: MenuItem): void {
|
pick(item: MenuItem): void {
|
||||||
// the keyboard path never consults the disabled attribute — guard here
|
// the keyboard path never consults the disabled attribute — guard here
|
||||||
if (item.disabled) return;
|
if (item.disabled || !item.action) return;
|
||||||
if (!item.action) return;
|
|
||||||
const action = item.action;
|
const action = item.action;
|
||||||
|
const onPick = this.menu.request()?.onPick;
|
||||||
this.close();
|
this.close();
|
||||||
this.menu.onPick(action);
|
if (onPick) onPick(action);
|
||||||
|
else this.menu.emit(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Right-clicking the backdrop closes rather than stacking a second menu. */
|
/** Right-clicking the backdrop closes rather than stacking a second menu. */
|
||||||
@@ -139,42 +174,48 @@ export class ContextMenu {
|
|||||||
|
|
||||||
onKeydown(event: KeyboardEvent): void {
|
onKeydown(event: KeyboardEvent): void {
|
||||||
const items = this.menu.request()?.items ?? [];
|
const items = this.menu.request()?.items ?? [];
|
||||||
|
const selectable = items.map(isSelectable);
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
this.close();
|
this.close();
|
||||||
break;
|
break;
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
event.preventDefault();
|
this.highlight(stepIndex(selectable, this.activeIndex(), 1));
|
||||||
this.step(items, 1);
|
|
||||||
break;
|
break;
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
event.preventDefault();
|
this.highlight(stepIndex(selectable, this.activeIndex(), -1));
|
||||||
this.step(items, -1);
|
break;
|
||||||
|
case 'Home':
|
||||||
|
this.highlight(stepIndex(selectable, -1, 1));
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
this.highlight(stepIndex(selectable, -1, -1));
|
||||||
break;
|
break;
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
case ' ': {
|
case ' ': {
|
||||||
event.preventDefault();
|
|
||||||
const item = items[this.activeIndex()];
|
const item = items[this.activeIndex()];
|
||||||
if (item) this.pick(item);
|
if (item) this.pick(item);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
case 'Tab':
|
||||||
}
|
// focus stays on the menu: the items are not Tab stops
|
||||||
|
break;
|
||||||
/** Move the highlight by one selectable item, skipping separators and
|
default: {
|
||||||
* disabled entries, wrapping. */
|
// an item's own shortcut picks it, as in a native menu
|
||||||
private step(items: MenuItem[], delta: number): void {
|
const hit = items.find(
|
||||||
const count = items.length;
|
(i) => isSelectable(i) && i.shortcut && this.shortcuts.matches(i.shortcut, event),
|
||||||
if (!count) return;
|
);
|
||||||
let index = this.activeIndex();
|
if (!hit) return; // not ours: let it bubble
|
||||||
for (let hops = 0; hops < count; hops++) {
|
this.pick(hit);
|
||||||
index = (index + delta + count) % count;
|
|
||||||
if (items[index].type !== 'separator' && !items[index].disabled) {
|
|
||||||
this.activeIndex.set(index);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move the highlight and keep it in view (the menu may scroll). */
|
||||||
|
private highlight(index: number): void {
|
||||||
|
this.activeIndex.set(index);
|
||||||
|
document.getElementById(`ctx-item-${index}`)?.scrollIntoView({ block: 'nearest' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { Component, computed, inject, input, signal } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
afterNextRender,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
signal,
|
||||||
|
viewChild,
|
||||||
|
} from '@angular/core';
|
||||||
import { I18nService } from '@core/i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import { DialogsService, type ChmodRequest } from '@core/dialogs.service';
|
import { DialogsService, type ChmodRequest } from '@core/dialogs.service';
|
||||||
|
import { cycleFocus } from '@core/focus-cycle';
|
||||||
import { FocusService } from '@core/focus.service';
|
import { FocusService } from '@core/focus.service';
|
||||||
|
|
||||||
const ROLES = ['roleOwner', 'roleGroup', 'roleOthers'] as const;
|
const ROLES = ['roleOwner', 'roleGroup', 'roleOthers'] as const;
|
||||||
@@ -11,9 +21,12 @@ 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"
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
[attr.aria-label]="i18n.t('dialogs.chmodTitle')"
|
||||||
|
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)="onKeydown($event)"
|
||||||
>
|
>
|
||||||
<h2 class="text-sm font-semibold">{{ i18n.t('dialogs.chmodTitle') }}</h2>
|
<h2 class="text-sm font-semibold">{{ i18n.t('dialogs.chmodTitle') }}</h2>
|
||||||
<p class="mt-1 truncate text-xs text-muted-foreground">{{ targetLabel() }}</p>
|
<p class="mt-1 truncate text-xs text-muted-foreground">{{ targetLabel() }}</p>
|
||||||
@@ -37,7 +50,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>
|
||||||
}
|
}
|
||||||
@@ -49,24 +62,25 @@ const PERMS = ['r', 'w', 'x'] as const;
|
|||||||
<div class="mt-3 flex items-center gap-2">
|
<div class="mt-3 flex items-center gap-2">
|
||||||
<label class="text-xs text-muted-foreground">{{ i18n.t('dialogs.octal') }}</label>
|
<label class="text-xs text-muted-foreground">{{ i18n.t('dialogs.octal') }}</label>
|
||||||
<input
|
<input
|
||||||
|
#octalBox
|
||||||
[value]="octal()"
|
[value]="octal()"
|
||||||
(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>
|
||||||
@@ -84,6 +98,32 @@ export class ChmodDialog {
|
|||||||
|
|
||||||
private readonly dialogs = inject(DialogsService);
|
private readonly dialogs = inject(DialogsService);
|
||||||
private readonly focus = inject(FocusService);
|
private readonly focus = inject(FocusService);
|
||||||
|
private readonly octalBox = viewChild.required<ElementRef<HTMLInputElement>>('octalBox');
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// typing the octal is the fast path; the checkboxes are a Tab away
|
||||||
|
afterNextRender(() => {
|
||||||
|
const el = this.octalBox().nativeElement;
|
||||||
|
el.focus();
|
||||||
|
el.select();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Esc cancels (and stops here); Tab stays inside; Enter applies — except on
|
||||||
|
* a button, which Enter clicks natively, and mid IME composition. */
|
||||||
|
onKeydown(event: KeyboardEvent): void {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.cancel();
|
||||||
|
} else if (event.key === 'Enter') {
|
||||||
|
if (event.isComposing || (event.target as HTMLElement).closest('button')) return;
|
||||||
|
event.preventDefault();
|
||||||
|
this.submit();
|
||||||
|
} else {
|
||||||
|
cycleFocus(event.currentTarget as Element, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// mode is initialized lazily from the request on first read
|
// mode is initialized lazily from the request on first read
|
||||||
private readonly modeOverride = signal<number | null>(null);
|
private readonly modeOverride = signal<number | null>(null);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core';
|
import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core';
|
||||||
import { I18nService } from '@core/i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import { DialogsService, type ConfirmRequest } from '@core/dialogs.service';
|
import { DialogsService, type ConfirmRequest } from '@core/dialogs.service';
|
||||||
|
import { cycleFocus } from '@core/focus-cycle';
|
||||||
import { FocusService } from '@core/focus.service';
|
import { FocusService } from '@core/focus.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -8,16 +9,19 @@ import { FocusService } from '@core/focus.service';
|
|||||||
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"
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
[attr.aria-label]="request().title"
|
||||||
|
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)="onKeydown($event)"
|
||||||
>
|
>
|
||||||
<h2 class="text-sm font-semibold">{{ request().title }}</h2>
|
<h2 class="text-sm font-semibold">{{ request().title }}</h2>
|
||||||
<p class="mt-2 text-xs text-muted-foreground">{{ request().body }}</p>
|
<p class="mt-2 text-xs text-muted-foreground">{{ request().body }}</p>
|
||||||
<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 +30,8 @@ import { FocusService } from '@core/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 }}
|
||||||
@@ -49,6 +53,17 @@ export class ConfirmDialog {
|
|||||||
afterNextRender(() => this.confirmBtn().nativeElement.focus());
|
afterNextRender(() => this.confirmBtn().nativeElement.focus());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Esc cancels (and stops here — see PromptDialog); Tab stays inside. */
|
||||||
|
onKeydown(event: KeyboardEvent): void {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.cancel();
|
||||||
|
} else {
|
||||||
|
cycleFocus(event.currentTarget as Element, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
const req = this.request();
|
const req = this.request();
|
||||||
this.close();
|
this.close();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
afterNextRender,
|
afterRenderEffect,
|
||||||
computed,
|
computed,
|
||||||
inject,
|
inject,
|
||||||
input,
|
input,
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import { I18nService } from '@core/i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '@core/dialogs.service';
|
import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '@core/dialogs.service';
|
||||||
|
import { cycleFocus } from '@core/focus-cycle';
|
||||||
import { FocusService } from '@core/focus.service';
|
import { FocusService } from '@core/focus.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -18,10 +19,12 @@ import { FocusService } from '@core/focus.service';
|
|||||||
template: `
|
template: `
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-40 grid place-items-center bg-black/40"
|
class="fixed inset-0 z-40 grid place-items-center bg-black/40"
|
||||||
(keydown.escape)="pick('cancel')"
|
(keydown)="onKeydown($event)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-[26rem] rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
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 +33,7 @@ import { FocusService } from '@core/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-[0.625rem] 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,21 +50,21 @@ import { FocusService } from '@core/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-[0.625rem] 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-[0.625rem] 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() ? 'folder14' : 'file14'"
|
||||||
size="0.9rem"
|
size="var(--icon-size-sm)"
|
||||||
class="shrink-0 text-muted-foreground"
|
class="shrink-0 text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
<span class="font-mono tabular-nums">
|
<span class="font-mono tabular-nums">
|
||||||
@@ -79,7 +82,7 @@ import { FocusService } from '@core/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 +90,13 @@ import { FocusService } from '@core/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 +104,14 @@ import { FocusService } from '@core/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>
|
||||||
@@ -127,7 +130,23 @@ export class OverwriteDialog {
|
|||||||
private readonly primaryBtn = viewChild.required<ElementRef<HTMLButtonElement>>('primaryBtn');
|
private readonly primaryBtn = viewChild.required<ElementRef<HTMLButtonElement>>('primaryBtn');
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
afterNextRender(() => this.primaryBtn().nativeElement.focus());
|
// per QUESTION, not per component: a run of collisions reuses this one
|
||||||
|
// instance (the signal goes null → next inside one change detection)
|
||||||
|
afterRenderEffect(() => {
|
||||||
|
this.request();
|
||||||
|
this.primaryBtn().nativeElement.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Esc cancels the whole run (and stops here); Tab stays inside. */
|
||||||
|
onKeydown(event: KeyboardEvent): void {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.pick('cancel');
|
||||||
|
} else {
|
||||||
|
cycleFocus(event.currentTarget as Element, event);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly isDir = computed(() => this.request().srcEntry.type === 'dir');
|
readonly isDir = computed(() => this.request().srcEntry.type === 'dir');
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { I18nService } from '@core/i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import { DialogsService, type PromptRequest } from '@core/dialogs.service';
|
import { DialogsService, type PromptRequest } from '@core/dialogs.service';
|
||||||
|
import { cycleFocus } from '@core/focus-cycle';
|
||||||
import { FocusService } from '@core/focus.service';
|
import { FocusService } from '@core/focus.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -16,31 +17,34 @@ import { FocusService } from '@core/focus.service';
|
|||||||
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"
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
[attr.aria-label]="request().title"
|
||||||
|
class="w-96 border border-border bg-card p-4 text-card-foreground shadow-xl"
|
||||||
(mousedown)="$event.stopPropagation()"
|
(mousedown)="$event.stopPropagation()"
|
||||||
|
(keydown)="onKeydown($event)"
|
||||||
>
|
>
|
||||||
<h2 class="text-sm font-semibold">{{ request().title }}</h2>
|
<h2 class="text-sm font-semibold">{{ request().title }}</h2>
|
||||||
<input
|
<input
|
||||||
#box
|
#box
|
||||||
[value]="value()"
|
[value]="value()"
|
||||||
(input)="value.set($any($event.target).value)"
|
(input)="value.set($any($event.target).value)"
|
||||||
(keydown.enter)="submit()"
|
(keydown.enter)="onEnter($event)"
|
||||||
(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>
|
||||||
@@ -67,6 +71,25 @@ export class PromptDialog {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Esc anywhere in the dialog cancels; Tab stays inside it. Esc stops here:
|
||||||
|
* a window-level Escape (the connection manager closes on it) must not see
|
||||||
|
* the same key. */
|
||||||
|
onKeydown(event: KeyboardEvent): void {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.cancel();
|
||||||
|
} else {
|
||||||
|
cycleFocus(event.currentTarget as Element, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enter submits — but not the Enter that commits an IME candidate. */
|
||||||
|
onEnter(event: Event): void {
|
||||||
|
if ((event as KeyboardEvent).isComposing) return;
|
||||||
|
this.submit();
|
||||||
|
}
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
const value = this.value().trim();
|
const value = this.value().trim();
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
|
|||||||
@@ -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="closeCircle24" size="var(--icon-size-xl)" 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,7 +20,7 @@
|
|||||||
[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="fileText16" size="var(--icon-size-md)" 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>
|
||||||
@@ -28,8 +28,8 @@
|
|||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button
|
<button
|
||||||
(click)="toggleWrap()"
|
(click)="toggleWrap()"
|
||||||
[title]="i18n.t('editor.wrapHint', { alt: altLabel })"
|
[title]="shortcuts.tip(i18n.t('editor.wrapHint'), 'toggle-wrap')"
|
||||||
class="rounded-md px-2 py-1 text-[0.6875rem] 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()"
|
||||||
@@ -38,16 +38,16 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="openFind()"
|
(click)="openFind()"
|
||||||
[title]="i18n.t('editor.findHint', { mod: modLabel })"
|
[title]="shortcuts.tip(i18n.t('editor.findHint'), 'find')"
|
||||||
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="search14" size="var(--icon-size-sm)" />
|
||||||
</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="search14" size="var(--icon-size-sm)" class="shrink-0 text-muted-foreground" />
|
||||||
<input
|
<input
|
||||||
#findBox
|
#findBox
|
||||||
[value]="findQuery()"
|
[value]="findQuery()"
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
(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-[0.6875rem] tabular-nums"
|
class="text-[0.6875rem] tabular-nums"
|
||||||
@@ -66,25 +66,25 @@
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
(click)="findStep(-1)"
|
(click)="findStep(-1)"
|
||||||
[title]="i18n.t('editor.prevMatch')"
|
[title]="shortcuts.tipFixed(i18n.t('editor.prevMatch'), 'Shift+Enter')"
|
||||||
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="chevronUp14" size="var(--icon-size-sm)" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="findStep(1)"
|
(click)="findStep(1)"
|
||||||
[title]="i18n.t('editor.nextMatch')"
|
[title]="shortcuts.tipFixed(i18n.t('editor.nextMatch'), 'Enter')"
|
||||||
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="chevronDown14" size="var(--icon-size-sm)" />
|
||||||
</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="close14" size="var(--icon-size-sm)" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -93,24 +93,24 @@
|
|||||||
<div
|
<div
|
||||||
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"
|
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="closeCircle14" size="var(--icon-size-sm)" 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="var(--icon-size-lg)" 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="closeCircle24" size="var(--icon-size-xl)" 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,7 +141,7 @@
|
|||||||
</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-[0.6875rem] text-muted-foreground">{{ i18n.t('editor.shortcuts', { mod: modLabel }) }}</span>
|
<span class="text-[0.6875rem] text-muted-foreground">{{ footerHint() }}</span>
|
||||||
<span class="text-[0.6875rem] text-muted-foreground">·</span>
|
<span class="text-[0.6875rem] text-muted-foreground">·</span>
|
||||||
<span class="text-[0.6875rem] 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() }) }}
|
||||||
@@ -149,17 +149,17 @@
|
|||||||
<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="var(--icon-size-sm)" class="animate-spin" />
|
||||||
}
|
}
|
||||||
{{ i18n.t('common.save') }}
|
{{ i18n.t('common.save') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -171,7 +171,11 @@
|
|||||||
@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"
|
#prompt
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
[attr.aria-label]="i18n.t('editor.saveChangesTitle', { name: ctx.name })"
|
||||||
|
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 +185,22 @@
|
|||||||
<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
|
||||||
|
#promptSave
|
||||||
(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>
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import {
|
|||||||
Component,
|
Component,
|
||||||
DOCUMENT,
|
DOCUMENT,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
|
Injector,
|
||||||
afterNextRender,
|
afterNextRender,
|
||||||
|
afterRenderEffect,
|
||||||
computed,
|
computed,
|
||||||
effect,
|
effect,
|
||||||
inject,
|
inject,
|
||||||
@@ -11,7 +13,8 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import { I18nService } from '@core/i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME, MOD_LABEL } from '@core/platform';
|
import { cycleFocus } from '@core/focus-cycle';
|
||||||
|
import { blockNativeContextMenu, blockReloadKeys, EDITOR_CONTEXT, IS_MAC_CHROME } from '@core/platform';
|
||||||
import { RpcService } from '@core/rpc.service';
|
import { RpcService } from '@core/rpc.service';
|
||||||
import { SettingsService } from '@core/settings.service';
|
import { SettingsService } from '@core/settings.service';
|
||||||
import { ShortcutsService } from '@core/shortcuts.service';
|
import { ShortcutsService } from '@core/shortcuts.service';
|
||||||
@@ -43,8 +46,6 @@ export class EditorWindow {
|
|||||||
readonly i18n = inject(I18nService);
|
readonly i18n = inject(I18nService);
|
||||||
readonly ctx = EDITOR_CONTEXT;
|
readonly ctx = EDITOR_CONTEXT;
|
||||||
readonly isMacChrome = IS_MAC_CHROME;
|
readonly isMacChrome = IS_MAC_CHROME;
|
||||||
readonly modLabel = MOD_LABEL;
|
|
||||||
readonly altLabel = ALT_LABEL;
|
|
||||||
|
|
||||||
readonly loading = signal(true);
|
readonly loading = signal(true);
|
||||||
readonly loadError = signal<string | null>(null);
|
readonly loadError = signal<string | null>(null);
|
||||||
@@ -92,6 +93,9 @@ export class EditorWindow {
|
|||||||
);
|
);
|
||||||
|
|
||||||
private readonly box = viewChild<ElementRef<HTMLTextAreaElement>>('box');
|
private readonly box = viewChild<ElementRef<HTMLTextAreaElement>>('box');
|
||||||
|
private readonly prompt = viewChild<ElementRef<HTMLElement>>('prompt');
|
||||||
|
private readonly promptSave = viewChild<ElementRef<HTMLButtonElement>>('promptSave');
|
||||||
|
private readonly injector = inject(Injector);
|
||||||
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;
|
||||||
@@ -102,7 +106,22 @@ export class EditorWindow {
|
|||||||
// 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 shortcuts = inject(ShortcutsService);
|
||||||
|
|
||||||
|
/** "⌘S save · ⌘F find · ⌘W close" — the keys from the live keymap, so a remap
|
||||||
|
* shows here too; an unbound command drops out. */
|
||||||
|
readonly footerHint = computed(() =>
|
||||||
|
(
|
||||||
|
[
|
||||||
|
['save', 'editor.verbs.save'],
|
||||||
|
['find', 'editor.verbs.find'],
|
||||||
|
['close-window', 'editor.verbs.close'],
|
||||||
|
] as const
|
||||||
|
)
|
||||||
|
.filter(([id]) => this.shortcuts.label(id))
|
||||||
|
.map(([id, verb]) => `${this.shortcuts.label(id)} ${this.i18n.t(verb)}`)
|
||||||
|
.join(' · '),
|
||||||
|
);
|
||||||
|
|
||||||
private draftTimer: ReturnType<typeof setTimeout> | null = null;
|
private draftTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private lastSentDirty = false;
|
private lastSentDirty = false;
|
||||||
@@ -122,6 +141,11 @@ export class EditorWindow {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterNextRender(() => void this.load(ctx.sessionId, ctx.path));
|
afterNextRender(() => void this.load(ctx.sessionId, ctx.path));
|
||||||
|
// the unsaved-changes prompt takes the keyboard, so keys can't edit the
|
||||||
|
// hidden text behind it: focus its Save button whenever it shows
|
||||||
|
afterRenderEffect(() => {
|
||||||
|
if (this.closePrompt()) this.promptSave()?.nativeElement.focus();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async load(sessionId: string, path: string): Promise<void> {
|
private async load(sessionId: string, path: string): Promise<void> {
|
||||||
@@ -134,7 +158,8 @@ export class EditorWindow {
|
|||||||
this.savedContent.set(res.content);
|
this.savedContent.set(res.content);
|
||||||
this.text.set(res.content);
|
this.text.set(res.content);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
queueMicrotask(() => this.box()?.nativeElement.focus());
|
// after the render that creates the textarea — a microtask runs before it
|
||||||
|
afterNextRender(() => this.box()?.nativeElement.focus(), { injector: this.injector });
|
||||||
}
|
}
|
||||||
|
|
||||||
blockContextMenu(event: Event): void {
|
blockContextMenu(event: Event): void {
|
||||||
@@ -168,11 +193,14 @@ export class EditorWindow {
|
|||||||
}
|
}
|
||||||
this.findOpen.set(true);
|
this.findOpen.set(true);
|
||||||
this.activeMatch.set(0);
|
this.activeMatch.set(0);
|
||||||
queueMicrotask(() => {
|
afterNextRender(
|
||||||
const input = this.findBox()?.nativeElement;
|
() => {
|
||||||
input?.focus();
|
const input = this.findBox()?.nativeElement;
|
||||||
input?.select();
|
input?.focus();
|
||||||
});
|
input?.select();
|
||||||
|
},
|
||||||
|
{ injector: this.injector },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
closeFind(): void {
|
closeFind(): void {
|
||||||
@@ -197,6 +225,29 @@ export class EditorWindow {
|
|||||||
this.goTo((this.activeMatch() + delta + count) % count);
|
this.goTo((this.activeMatch() + delta + count) % count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ⌘G / ⇧⌘G (F3 / ⇧F3): the next / previous match from the caret, find bar
|
||||||
|
* open or not — the query survives closing it. With no query yet, open the bar. */
|
||||||
|
findNext(delta: 1 | -1): void {
|
||||||
|
if (!this.findQuery()) {
|
||||||
|
this.openFind();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = this.matches();
|
||||||
|
if (!list.length) return;
|
||||||
|
const el = this.box()?.nativeElement;
|
||||||
|
let index = -1;
|
||||||
|
if (delta > 0) {
|
||||||
|
const caret = el?.selectionEnd ?? 0;
|
||||||
|
index = list.findIndex((start) => start >= caret);
|
||||||
|
if (index < 0) index = 0;
|
||||||
|
} else {
|
||||||
|
const caret = el?.selectionStart ?? 0;
|
||||||
|
for (let i = list.length - 1; i >= 0 && index < 0; i--) if (list[i] < caret) index = i;
|
||||||
|
if (index < 0) index = list.length - 1;
|
||||||
|
}
|
||||||
|
this.goTo(index);
|
||||||
|
}
|
||||||
|
|
||||||
onFindKeydown(event: KeyboardEvent): void {
|
onFindKeydown(event: KeyboardEvent): void {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -319,55 +370,78 @@ export class EditorWindow {
|
|||||||
// ---- keyboard ----
|
// ---- keyboard ----
|
||||||
|
|
||||||
onKeydown(event: KeyboardEvent): void {
|
onKeydown(event: KeyboardEvent): void {
|
||||||
// Chord shortcuts resolve through the shared keymap (ShortcutsService);
|
this.handleKey(event);
|
||||||
// Escape and Tab stay below because their meaning is THIS window's state
|
blockReloadKeys(event); // F5 / Ctrl+R must not reload over unsaved text
|
||||||
// (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
|
* The editor's keys come from the shared keymap (`editor` scope); they fire
|
||||||
// (save/find/wrap) are WANTED mid-typing, so the guard is per-shortcut
|
* mid-typing because they are all chords or F-keys. Escape and Tab stay here:
|
||||||
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
|
* their meaning is this window's state (prompt up? find bar open?).
|
||||||
event.preventDefault();
|
*/
|
||||||
switch (match.id) {
|
private handleKey(event: KeyboardEvent): void {
|
||||||
case 'save':
|
if (event.defaultPrevented) return;
|
||||||
void this.save();
|
const id = this.shortcuts.claim(event, 'editor');
|
||||||
break;
|
if (this.closePrompt()) {
|
||||||
case 'find':
|
// the unsaved-changes prompt owns the keyboard: ⌘S is its Save, Esc its
|
||||||
this.openFind();
|
// Cancel, Tab walks its buttons; other commands wait
|
||||||
break;
|
if (id === 'save') void this.saveAndClose();
|
||||||
case 'close-window':
|
else if (id === 'scale-up' || id === 'scale-down' || id === 'scale-reset') this.scale(id);
|
||||||
// reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W
|
else if (!id && event.key === 'Escape') {
|
||||||
// and bun routes it back as editorCloseRequest
|
event.preventDefault();
|
||||||
this.requestClose();
|
this.cancelClose();
|
||||||
break;
|
} else if (!id) {
|
||||||
case 'toggle-wrap':
|
const prompt = this.prompt()?.nativeElement;
|
||||||
this.toggleWrap();
|
if (prompt) cycleFocus(prompt, event);
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key === 'Escape' && this.closePrompt()) {
|
switch (id) {
|
||||||
event.preventDefault();
|
case 'save':
|
||||||
this.cancelClose();
|
void this.save();
|
||||||
} else if (event.key === 'Escape' && this.findOpen()) {
|
return;
|
||||||
|
case 'find':
|
||||||
|
this.openFind();
|
||||||
|
return;
|
||||||
|
case 'find-next':
|
||||||
|
this.findNext(1);
|
||||||
|
return;
|
||||||
|
case 'find-prev':
|
||||||
|
this.findNext(-1);
|
||||||
|
return;
|
||||||
|
case 'close-window':
|
||||||
|
this.requestClose();
|
||||||
|
return;
|
||||||
|
case 'toggle-wrap':
|
||||||
|
this.toggleWrap();
|
||||||
|
return;
|
||||||
|
case 'scale-up':
|
||||||
|
case 'scale-down':
|
||||||
|
case 'scale-reset':
|
||||||
|
this.scale(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape' && this.findOpen()) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.closeFind();
|
this.closeFind();
|
||||||
} else if (event.key === 'Tab' && event.target === this.box()?.nativeElement) {
|
} else if (
|
||||||
|
event.key === 'Tab' &&
|
||||||
|
!event.metaKey &&
|
||||||
|
!event.ctrlKey &&
|
||||||
|
!event.altKey &&
|
||||||
|
event.target === this.box()?.nativeElement
|
||||||
|
) {
|
||||||
// keep Tab as an input character inside the editor
|
// keep Tab as an input character inside the editor
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.insertTab();
|
this.insertTab();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scale(id: 'scale-up' | 'scale-down' | 'scale-reset'): void {
|
||||||
|
if (id === 'scale-reset') this.settings.setUiScale(1);
|
||||||
|
else this.settings.setUiScale(this.settings.uiScale() + (id === 'scale-up' ? 0.1 : -0.1));
|
||||||
|
}
|
||||||
|
|
||||||
private insertTab(): void {
|
private insertTab(): void {
|
||||||
const el = this.box()!.nativeElement;
|
const el = this.box()!.nativeElement;
|
||||||
// execCommand keeps the native undo stack intact where supported
|
// execCommand keeps the native undo stack intact where supported
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
<!-- 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()"
|
#sourceBtn
|
||||||
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"
|
(click)="openSourcePicker(false)"
|
||||||
[title]="i18n.t('panel.choosePanel')"
|
class="flex min-w-0 items-center gap-1.5 px-2 py-1 text-(length:--path-font) font-medium hover:bg-accent"
|
||||||
|
[title]="shortcuts.tip(i18n.t('panel.choosePanel'), 'source-picker')"
|
||||||
>
|
>
|
||||||
@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(--icon-size-sm)" />
|
||||||
} @else {
|
} @else {
|
||||||
<ng-icon
|
<ng-icon
|
||||||
[name]="isRemote() ? 'mynaServers' : 'mynaHardDrive'"
|
[name]="isRemote() ? 'server14' : 'hardDrive14'"
|
||||||
[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(--icon-size-sm)"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
<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="chevronDown14"
|
||||||
class="shrink-0 text-muted-foreground"
|
class="shrink-0 text-muted-foreground"
|
||||||
size="calc(var(--action-icon) - 0.1rem)"
|
size="var(--icon-size-sm)"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
@@ -26,167 +30,96 @@
|
|||||||
<!-- panel actions: act on THIS panel. Frequent/destructive verbs grow text
|
<!-- panel actions: act on THIS panel. Frequent/destructive verbs grow text
|
||||||
labels when the panel is wide enough (container query). -->
|
labels when the panel is wide enough (container query). -->
|
||||||
<button
|
<button
|
||||||
(click)="copyToOther()"
|
(click)="run('send')"
|
||||||
[title]="side() === 'left' ? i18n.t('panel.copyToRight') : i18n.t('panel.copyToLeft')"
|
[title]="shortcuts.tip(side() === 'left' ? i18n.t('panel.copyToRight') : i18n.t('panel.copyToLeft'), 'send')"
|
||||||
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(--icon-size-sm)" />
|
||||||
<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>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="run('edit')"
|
(click)="run('edit')"
|
||||||
[title]="i18n.t('panel.editFile')"
|
[title]="shortcuts.tip(i18n.t('panel.editFile'), 'edit')"
|
||||||
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="fileText14" size="var(--icon-size-sm)" />
|
||||||
<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]="shortcuts.tip(i18n.t('panel.rename'), '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="edit14" size="var(--icon-size-sm)" />
|
||||||
<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('new-folder')"
|
||||||
[title]="i18n.t('panel.newFolder')"
|
[title]="shortcuts.tip(i18n.t('panel.newFolder'), 'new-folder')"
|
||||||
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="folderAdd14" size="var(--icon-size-sm)" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="run('createFile')"
|
(click)="run('new-file')"
|
||||||
[title]="i18n.t('panel.newFile')"
|
[title]="shortcuts.tip(i18n.t('panel.newFile'), 'new-file')"
|
||||||
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="fileAdd14" size="var(--icon-size-sm)" />
|
||||||
</button>
|
</button>
|
||||||
@if (chmodAvailable()) {
|
@if (chmodAvailable()) {
|
||||||
<button
|
<button
|
||||||
(click)="run('chmod')"
|
(click)="run('chmod')"
|
||||||
[title]="i18n.t('panel.permissions')"
|
[title]="shortcuts.tip(i18n.t('panel.permissions'), 'chmod')"
|
||||||
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="lockOn14" size="var(--icon-size-sm)" />
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<button
|
<button
|
||||||
(click)="run('delete')"
|
(click)="run('delete')"
|
||||||
[title]="i18n.t('panel.deleteHint')"
|
[title]="shortcuts.tip(i18n.t('common.delete'), 'delete')"
|
||||||
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="delete14" size="var(--icon-size-sm)" />
|
||||||
<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()) {
|
|
||||||
<div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div>
|
|
||||||
<div
|
|
||||||
class="absolute top-full left-2 z-30 mt-1 max-h-80 w-64 overflow-y-auto rounded-lg border border-border bg-card py-1 text-card-foreground shadow-xl"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
(click)="pickLocal()"
|
|
||||||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
|
||||||
<span class="flex-1">{{ i18n.t('common.local') }}</span>
|
|
||||||
@if (!isRemote()) {
|
|
||||||
<ng-icon name="mynaCheck" size="0.85rem" class="shrink-0 text-primary" />
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Windows drive roots (empty list elsewhere) -->
|
|
||||||
@for (drive of drives(); track drive) {
|
|
||||||
<button
|
|
||||||
(click)="pickDrive(drive)"
|
|
||||||
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-6 text-left text-(length:--path-font) hover:bg-accent"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
|
||||||
<span class="flex-1 font-mono">{{ drive }}</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (connections.grouped().loose.length || connections.grouped().stacks.length) {
|
|
||||||
<div class="my-1 border-t border-border"></div>
|
|
||||||
}
|
|
||||||
@for (connection of connections.grouped().loose; track connection.id) {
|
|
||||||
<button
|
|
||||||
(click)="pickConnection(connection)"
|
|
||||||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
|
||||||
<span class="flex-1 truncate">{{ connection.name }}</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
@for (group of connections.grouped().stacks; track group.stack.id) {
|
|
||||||
<div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
|
|
||||||
{{ group.stack.name }}
|
|
||||||
</div>
|
|
||||||
@for (connection of group.connections; track connection.id) {
|
|
||||||
<button
|
|
||||||
(click)="pickConnection(connection)"
|
|
||||||
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
|
||||||
<span class="flex-1 truncate">{{ connection.name }}</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (connections.sshHosts().length) {
|
|
||||||
<div class="my-1 border-t border-border"></div>
|
|
||||||
<div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
|
|
||||||
~/.ssh/config
|
|
||||||
</div>
|
|
||||||
@for (host of connections.sshHosts(); track host.name) {
|
|
||||||
<button
|
|
||||||
(click)="pickSshHost(host)"
|
|
||||||
[disabled]="host.unsupported"
|
|
||||||
[title]="sshHostTitle(host)"
|
|
||||||
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent disabled:opacity-60 disabled:hover:bg-transparent"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
|
||||||
<span class="flex-1 truncate">{{ host.name }}</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</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]="shortcuts.tip(i18n.t('panel.parentDir'), 'parent')"
|
||||||
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="arrowUp16" size="var(--icon-size-md)" />
|
||||||
</button>
|
</button>
|
||||||
|
<!-- no blanket stopPropagation: the window handler lets only ⌘/Ctrl chords
|
||||||
|
through from a text field, and its WebView2 reload guard must see F5 -->
|
||||||
<input
|
<input
|
||||||
|
#pathBox
|
||||||
[value]="pathDraft()"
|
[value]="pathDraft()"
|
||||||
(input)="pathDraft.set($any($event.target).value)"
|
(input)="pathDraft.set($any($event.target).value)"
|
||||||
(keydown.enter)="commitPath()"
|
(keydown.enter)="commitPath()"
|
||||||
(keydown.escape)="resetPath()"
|
(keydown.escape)="resetPath()"
|
||||||
(keydown)="$event.stopPropagation()"
|
|
||||||
[disabled]="blocked()"
|
[disabled]="blocked()"
|
||||||
|
[title]="shortcuts.tip(i18n.t('keys.focus-path'), 'focus-path')"
|
||||||
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)="run('refresh')"
|
||||||
[disabled]="blocked()"
|
[disabled]="blocked()"
|
||||||
[title]="i18n.t('panel.refresh')"
|
[title]="shortcuts.tip(i18n.t('panel.refresh'), '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="var(--icon-size-md)" class="animate-spin" />
|
||||||
} @else {
|
} @else {
|
||||||
<ng-icon name="mynaRefresh" size="1rem" />
|
<ng-icon name="refresh16" size="var(--icon-size-md)" />
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -195,9 +128,13 @@
|
|||||||
<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-[0.6875rem] 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"
|
||||||
|
[title]="sortTip('name')"
|
||||||
|
(click)="panel().toggleSort('name')"
|
||||||
|
>
|
||||||
{{ i18n.t('columns.name') }} {{ sortIndicator('name') }}
|
{{ i18n.t('columns.name') }} {{ sortIndicator('name') }}
|
||||||
</button>
|
</button>
|
||||||
@for (col of visibleColumns(); track col.key) {
|
@for (col of visibleColumns(); track col.key) {
|
||||||
@@ -205,6 +142,7 @@
|
|||||||
class="px-2 py-1 hover:text-foreground"
|
class="px-2 py-1 hover:text-foreground"
|
||||||
[class.text-right]="col.key === 'size'"
|
[class.text-right]="col.key === 'size'"
|
||||||
[class.text-left]="col.key !== 'size'"
|
[class.text-left]="col.key !== 'size'"
|
||||||
|
[title]="sortTip(col.key)"
|
||||||
(click)="panel().toggleSort(col.key)"
|
(click)="panel().toggleSort(col.key)"
|
||||||
>
|
>
|
||||||
{{ i18n.t('columns.' + col.key) }} {{ sortIndicator(col.key) }}
|
{{ i18n.t('columns.' + col.key) }} {{ sortIndicator(col.key) }}
|
||||||
@@ -215,7 +153,7 @@
|
|||||||
<!-- 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="search14" size="var(--icon-size-sm)" 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-[0.6875rem] 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() }) }}
|
||||||
@@ -223,9 +161,9 @@
|
|||||||
<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="close14" size="var(--icon-size-sm)" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -243,13 +181,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="var(--icon-size-xl)" 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 +202,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()"
|
||||||
@@ -271,7 +210,7 @@
|
|||||||
[class.ring-ring]="i === panel().cursor() && isActive()"
|
[class.ring-ring]="i === panel().cursor() && isActive()"
|
||||||
>
|
>
|
||||||
<div class="flex min-w-0 items-center gap-1.5 px-2">
|
<div class="flex min-w-0 items-center gap-1.5 px-2">
|
||||||
<ng-icon [name]="iconFor(entry)" size="0.95rem" [class]="iconClass(entry)" />
|
<ng-icon [name]="iconFor(entry)" size="var(--icon-size-md)" [class]="iconClass(entry)" />
|
||||||
<span class="truncate" [class.italic]="entry.type === 'symlink'">{{ entry.name }}</span>
|
<span class="truncate" [class.italic]="entry.type === 'symlink'">{{ entry.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
@for (col of visibleColumns(); track col.key) {
|
@for (col of visibleColumns(); track col.key) {
|
||||||
@@ -321,7 +260,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="var(--icon-size-xl)" class="animate-spin text-primary" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,20 +9,22 @@ import {
|
|||||||
viewChild,
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
|
import { isRepeatable } from '@shared/keymap';
|
||||||
import { I18nService } from '@core/i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import type { ColumnKey, FileEntry, SavedConnection, SshConfigHost } from '@shared/types';
|
import type { ColumnKey, FileEntry, SshConfigHost } from '@shared/types';
|
||||||
import { ConnectionsService } from '@core/connections.service';
|
import { ConnectionsService } from '@core/connections.service';
|
||||||
import { ContextMenuService } from '@core/context-menu.service';
|
import { ContextMenuService, type MenuItem } from '@core/context-menu.service';
|
||||||
import { DialogsService } from '@core/dialogs.service';
|
import { DialogsService } from '@core/dialogs.service';
|
||||||
import { FileActionsService } from '@core/file-actions.service';
|
import { FileActionsService, type FileCommand, type FileTarget } from '@core/file-actions.service';
|
||||||
import { fileGlyph, fileIconClass, fileKindKey } from '@core/file-icons';
|
import { fileGlyph, fileIconClass, fileKindKey } from '@core/file-icons';
|
||||||
import { formatMode } from '@core/format';
|
import { formatMode } from '@core/format';
|
||||||
|
import type { Icon14, Icon16 } from '@core/icons';
|
||||||
|
import { remPx } from '@core/metrics';
|
||||||
import { PanelsService, type Side, type SortKey } from '@core/panels.service';
|
import { PanelsService, type Side, type SortKey } from '@core/panels.service';
|
||||||
import { IS_WINDOWS } from '@core/platform';
|
import { IS_WINDOWS } from '@core/platform';
|
||||||
import { RpcService } from '@core/rpc.service';
|
import { RpcService } from '@core/rpc.service';
|
||||||
import { SettingsService } from '@core/settings.service';
|
import { SettingsService } from '@core/settings.service';
|
||||||
import { ShortcutsService } from '@core/shortcuts.service';
|
import { ShortcutsService, type ShortcutId } 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 }[] = [
|
||||||
@@ -34,6 +36,33 @@ const COLUMN_DEFS: { key: ColumnKey; label: string; width: string }[] = [
|
|||||||
{ key: 'group', label: 'Group', width: '6rem' },
|
{ key: 'group', label: 'Group', width: '6rem' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Rows a PageUp / PageDown jumps. */
|
||||||
|
const PAGE = 20;
|
||||||
|
|
||||||
|
/** Cursor movements: with Shift they extend the selection instead. */
|
||||||
|
const MOVES = new Set<ShortcutId>([
|
||||||
|
'cursor-up',
|
||||||
|
'cursor-down',
|
||||||
|
'page-up',
|
||||||
|
'page-down',
|
||||||
|
'cursor-first',
|
||||||
|
'cursor-last',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** The same keydown with Shift lifted — what a Shift+movement spells underneath. */
|
||||||
|
function withoutShift(event: KeyboardEvent) {
|
||||||
|
return {
|
||||||
|
key: event.key,
|
||||||
|
code: event.code,
|
||||||
|
metaKey: event.metaKey,
|
||||||
|
ctrlKey: event.ctrlKey,
|
||||||
|
altKey: event.altKey,
|
||||||
|
shiftKey: false,
|
||||||
|
target: event.target,
|
||||||
|
getModifierState: (key: string) => event.getModifierState(key),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-file-panel',
|
selector: 'app-file-panel',
|
||||||
imports: [NgIcon],
|
imports: [NgIcon],
|
||||||
@@ -46,29 +75,34 @@ export class FilePanel {
|
|||||||
|
|
||||||
readonly panels = inject(PanelsService);
|
readonly panels = inject(PanelsService);
|
||||||
readonly actions = inject(FileActionsService);
|
readonly actions = inject(FileActionsService);
|
||||||
readonly transfers = inject(TransfersService);
|
|
||||||
readonly connections = inject(ConnectionsService);
|
readonly connections = inject(ConnectionsService);
|
||||||
readonly dialogs = inject(DialogsService);
|
readonly dialogs = inject(DialogsService);
|
||||||
|
readonly shortcuts = inject(ShortcutsService);
|
||||||
private readonly menu = inject(ContextMenuService);
|
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<Icon14>(() => (this.side() === 'left' ? 'arrowRight14' : 'arrowLeft14'));
|
||||||
|
|
||||||
readonly pathDraft = signal('');
|
readonly pathDraft = signal('');
|
||||||
readonly menuOpen = signal(false);
|
|
||||||
/** Windows drive roots for the source picker; always empty elsewhere. */
|
/** Windows drive roots for the source picker; always empty elsewhere. */
|
||||||
readonly drives = signal<string[]>([]);
|
readonly drives = signal<string[]>([]);
|
||||||
/** chmod on Windows local files only flips the read-only bit — hide it. */
|
/** chmod on Windows local files only flips the read-only bit — hide it. */
|
||||||
readonly chmodAvailable = computed(() => !(IS_WINDOWS && !this.isRemote()));
|
readonly chmodAvailable = computed(() => !(IS_WINDOWS && !this.isRemote()));
|
||||||
|
|
||||||
private readonly listEl = viewChild.required<ElementRef<HTMLDivElement>>('list');
|
private readonly listEl = viewChild.required<ElementRef<HTMLDivElement>>('list');
|
||||||
|
private readonly sourceBtn = viewChild.required<ElementRef<HTMLButtonElement>>('sourceBtn');
|
||||||
|
private readonly pathBox = viewChild.required<ElementRef<HTMLInputElement>>('pathBox');
|
||||||
private lastClickedIndex = 0;
|
private lastClickedIndex = 0;
|
||||||
|
|
||||||
// locale-reactive wrappers: reading i18n.locale() inside makes the file-list
|
// locale-reactive wrappers: reading i18n.locale() inside makes the file-list
|
||||||
@@ -90,17 +124,6 @@ export class FilePanel {
|
|||||||
['minmax(0,1fr)', ...this.visibleColumns().map((c) => c.width)].join(' '),
|
['minmax(0,1fr)', ...this.visibleColumns().map((c) => c.width)].join(' '),
|
||||||
);
|
);
|
||||||
|
|
||||||
headerContextMenu(event: MouseEvent): void {
|
|
||||||
event.preventDefault();
|
|
||||||
this.menu.open(event, this.menu.columnItems(this.settings.columns()));
|
|
||||||
}
|
|
||||||
|
|
||||||
pickDrive(drive: string): void {
|
|
||||||
this.menuOpen.set(false);
|
|
||||||
this.activate();
|
|
||||||
void this.panel().load(drive);
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (IS_WINDOWS && this.rpc.available) {
|
if (IS_WINDOWS && this.rpc.available) {
|
||||||
void this.rpc.request.listDrives({}).then((res) => {
|
void this.rpc.request.listDrives({}).then((res) => {
|
||||||
@@ -109,11 +132,23 @@ export class FilePanel {
|
|||||||
}
|
}
|
||||||
// keep the editable path bar in sync with the actual cwd
|
// keep the editable path bar in sync with the actual cwd
|
||||||
effect(() => this.pathDraft.set(this.panel().cwd()));
|
effect(() => this.pathDraft.set(this.panel().cwd()));
|
||||||
// focus follows the active side (Tab switches panels, dialogs refocus on close)
|
// focus follows the active side (Tab switches panels, dialogs and menus
|
||||||
|
// refocus on close) — but never while a dialog or menu is up: a run of
|
||||||
|
// overwrite questions reuses one dialog, and stealing focus back to the
|
||||||
|
// list behind it would hand Enter and Delete to the panel
|
||||||
effect(() => {
|
effect(() => {
|
||||||
this.panels.focusTick();
|
this.panels.focusTick();
|
||||||
if (this.isActive()) this.listEl().nativeElement.focus();
|
if (this.isActive() && !this.dialogs.anyOpen && !this.menu.request()) {
|
||||||
|
this.listEl().nativeElement.focus();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
// keys that land on <body> (a click dropped focus) still reach this list
|
||||||
|
effect(() => this.panels.registerListKeys(this.side(), (event) => this.onKeydown(event)));
|
||||||
|
}
|
||||||
|
|
||||||
|
headerContextMenu(event: MouseEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
this.menu.open(event, this.menu.columnItems(this.settings.columns()));
|
||||||
}
|
}
|
||||||
|
|
||||||
activate(): void {
|
activate(): void {
|
||||||
@@ -124,38 +159,95 @@ export class FilePanel {
|
|||||||
this.panels.cancelConnect(this.side());
|
this.panels.cancelConnect(this.side());
|
||||||
}
|
}
|
||||||
|
|
||||||
copyToOther(): void {
|
/** A toolbar button: the same command the keyboard and the menus run. */
|
||||||
|
run(id: FileCommand): void {
|
||||||
if (this.blocked()) return;
|
if (this.blocked()) return;
|
||||||
this.activate();
|
this.activate();
|
||||||
void this.transfers.copyToOtherPanel(this.side());
|
this.actions.run(id, this.cursorTarget());
|
||||||
}
|
}
|
||||||
|
|
||||||
run(action: 'edit' | 'rename' | 'mkdir' | 'createFile' | 'chmod' | 'delete'): void {
|
private cursorTarget(): FileTarget {
|
||||||
if (this.blocked()) return;
|
const panel = this.panel();
|
||||||
|
return { side: this.side(), panel, entry: panel.sorted()[panel.cursor()] ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- source picker: what this panel shows ----
|
||||||
|
|
||||||
|
/** The session button's menu (also ⌘K): Local, drives, saved connections by
|
||||||
|
* stack, ~/.ssh/config hosts — through the shared context menu, so it gets
|
||||||
|
* the same keyboard handling and look. */
|
||||||
|
async openSourcePicker(keyboard: boolean): Promise<void> {
|
||||||
this.activate();
|
this.activate();
|
||||||
void this.actions[action](this.panel());
|
// re-read ~/.ssh/config on each open so external edits show up — BEFORE
|
||||||
|
// the items are built, the menu holds a snapshot
|
||||||
|
await this.connections.reloadSshHosts();
|
||||||
|
const rect = this.sourceBtn().nativeElement.getBoundingClientRect();
|
||||||
|
this.menu.open({ clientX: rect.left, clientY: rect.bottom + remPx(0.25) }, this.sourceItems(), {
|
||||||
|
keyboard,
|
||||||
|
onPick: (action) => this.pickSource(action),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pickLocal(): void {
|
private sourceItems(): MenuItem[] {
|
||||||
this.menuOpen.set(false);
|
const items: MenuItem[] = [
|
||||||
void this.panels.disconnectPanel(this.side());
|
{
|
||||||
|
label: this.i18n.t('common.local'),
|
||||||
|
action: 'local',
|
||||||
|
icon: 'hardDrive14',
|
||||||
|
checked: !this.isRemote(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
// Windows drive roots (empty list elsewhere)
|
||||||
|
for (const drive of this.drives()) {
|
||||||
|
items.push({ label: drive, action: `drive:${drive}`, icon: 'hardDrive14' });
|
||||||
|
}
|
||||||
|
const { loose, stacks } = this.connections.grouped();
|
||||||
|
if (loose.length || stacks.length) items.push({ type: 'separator' });
|
||||||
|
const server = (connection: { id: string; name: string }): MenuItem => ({
|
||||||
|
label: connection.name,
|
||||||
|
action: `conn:${connection.id}`,
|
||||||
|
icon: 'server14',
|
||||||
|
});
|
||||||
|
items.push(...loose.map(server));
|
||||||
|
for (const group of stacks) {
|
||||||
|
items.push({ type: 'header', label: group.stack.name }, ...group.connections.map(server));
|
||||||
|
}
|
||||||
|
const hosts = this.connections.sshHosts();
|
||||||
|
if (hosts.length) {
|
||||||
|
items.push({ type: 'separator' }, { type: 'header', label: this.i18n.t('panel.sshConfig') });
|
||||||
|
for (const host of hosts) {
|
||||||
|
items.push({
|
||||||
|
label: host.name,
|
||||||
|
action: `ssh:${host.name}`,
|
||||||
|
icon: 'server14',
|
||||||
|
disabled: !!host.unsupported,
|
||||||
|
tooltip: this.sshHostTitle(host),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
pickConnection(connection: SavedConnection): void {
|
private pickSource(action: string): void {
|
||||||
this.menuOpen.set(false);
|
const [kind, ...rest] = action.split(':');
|
||||||
|
const value = rest.join(':'); // a drive root ("C:\") has a colon of its own
|
||||||
this.activate();
|
this.activate();
|
||||||
void this.panels.connectPanel(this.side(), connection, connection.name, connection.localDir);
|
if (kind === 'local') {
|
||||||
|
void this.panels.disconnectPanel(this.side());
|
||||||
|
} else if (kind === 'drive') {
|
||||||
|
void this.panel().load(value);
|
||||||
|
} else if (kind === 'conn') {
|
||||||
|
const connection = this.connections.connections().find((c) => c.id === value);
|
||||||
|
if (connection) {
|
||||||
|
void this.panels.connectPanel(this.side(), connection, connection.name, connection.localDir);
|
||||||
|
}
|
||||||
|
} else if (kind === 'ssh') {
|
||||||
|
const host = this.connections.sshHosts().find((h) => h.name === value);
|
||||||
|
if (host) this.connectSshHost(host);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleMenu(): void {
|
private connectSshHost(host: SshConfigHost): void {
|
||||||
this.menuOpen.set(!this.menuOpen());
|
|
||||||
// re-read ~/.ssh/config on each open so external edits show up
|
|
||||||
if (this.menuOpen()) void this.connections.reloadSshHosts();
|
|
||||||
}
|
|
||||||
|
|
||||||
pickSshHost(host: SshConfigHost): void {
|
|
||||||
this.menuOpen.set(false);
|
|
||||||
this.activate();
|
|
||||||
void this.panels.connectPanel(
|
void this.panels.connectPanel(
|
||||||
this.side(),
|
this.side(),
|
||||||
{
|
{
|
||||||
@@ -176,12 +268,12 @@ export class FilePanel {
|
|||||||
return `${host.username}@${host.hostname}:${host.port}`;
|
return `${host.username}@${host.hostname}:${host.port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
iconFor(entry: FileEntry): string {
|
iconFor(entry: FileEntry): Icon16 {
|
||||||
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 {
|
||||||
@@ -204,6 +296,12 @@ export class FilePanel {
|
|||||||
return this.panel().sortAsc() ? '↑' : '↓';
|
return this.panel().sortAsc() ? '↑' : '↓';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The sort header's tooltip: its keymap command and keys. */
|
||||||
|
sortTip(key: SortKey): string {
|
||||||
|
const id = `sort-${key}` as const;
|
||||||
|
return this.shortcuts.tip(this.i18n.t(`keys.${id}`), id);
|
||||||
|
}
|
||||||
|
|
||||||
commitPath(): void {
|
commitPath(): void {
|
||||||
if (this.blocked()) return;
|
if (this.blocked()) return;
|
||||||
const value = this.pathDraft().trim();
|
const value = this.pathDraft().trim();
|
||||||
@@ -216,6 +314,12 @@ export class FilePanel {
|
|||||||
this.listEl().nativeElement.focus();
|
this.listEl().nativeElement.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private focusPath(): void {
|
||||||
|
const el = this.pathBox().nativeElement;
|
||||||
|
el.focus();
|
||||||
|
el.select();
|
||||||
|
}
|
||||||
|
|
||||||
rowClick(event: MouseEvent, index: number): void {
|
rowClick(event: MouseEvent, index: number): void {
|
||||||
if (this.blocked()) return;
|
if (this.blocked()) return;
|
||||||
this.activate();
|
this.activate();
|
||||||
@@ -252,105 +356,145 @@ export class FilePanel {
|
|||||||
this.actions.openContextMenu(event, { side: this.side(), panel: this.panel(), entry: null });
|
this.actions.openContextMenu(event, { side: this.side(), panel: this.panel(), entry: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
openAt(index: number): void {
|
/** ⇧F10 / Menu / ⌃↩: the cursor row's menu, under the row. The selection is
|
||||||
if (this.blocked()) return;
|
* kept — the menu's commands act on it, like the F-keys do. */
|
||||||
const entry = this.panel().sorted()[index];
|
private openKeyboardMenu(): void {
|
||||||
if (entry) this.panel().open(entry);
|
const panel = this.panel();
|
||||||
|
const index = panel.cursor();
|
||||||
|
const entry = panel.sorted()[index] ?? null;
|
||||||
|
const row = this.listEl().nativeElement.querySelector<HTMLElement>(`[data-index="${index}"]`);
|
||||||
|
row?.scrollIntoView({ block: 'nearest' });
|
||||||
|
const rect = (row ?? this.listEl().nativeElement).getBoundingClientRect();
|
||||||
|
this.actions.openContextMenu(
|
||||||
|
{ clientX: rect.left + remPx(1.75), clientY: row ? rect.bottom : rect.top + remPx(0.5) },
|
||||||
|
{ side: this.side(), panel, entry },
|
||||||
|
true,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
onKeydown(event: KeyboardEvent): void {
|
openAt(index: number): void {
|
||||||
if (this.blocked()) return;
|
if (this.blocked()) return;
|
||||||
const panel = this.panel();
|
const panel = this.panel();
|
||||||
const key = event.key;
|
const entry = panel.sorted()[index];
|
||||||
|
if (entry) this.actions.run('open', { side: this.side(), panel, entry });
|
||||||
|
}
|
||||||
|
|
||||||
if (key === 'Tab') {
|
/**
|
||||||
event.preventDefault();
|
* The list's keys, in order:
|
||||||
this.panels.setActiveSide(this.panels.otherSide(this.side()));
|
* 1. while a query is being typed, printable keys (Space too) extend it,
|
||||||
|
* Backspace trims and Esc clears it — fixed, ahead of every binding;
|
||||||
|
* 2. the `panel` keymap (Shift + a movement extends the selection);
|
||||||
|
* 3. a `main` binding (⌘T, Ctrl+Tab…) is left to bubble to App;
|
||||||
|
* 4. any other printable key starts a type-to-filter query;
|
||||||
|
* 5. Esc clears the selection.
|
||||||
|
*/
|
||||||
|
onKeydown(event: KeyboardEvent): void {
|
||||||
|
const panel = this.panel();
|
||||||
|
if (this.blocked()) {
|
||||||
|
// a busy panel takes no input — except Esc, which cancels a connect; its
|
||||||
|
// own keys are swallowed so Tab can't wander out of the list mid-load
|
||||||
|
if (event.key === 'Escape' && panel.connecting()) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.cancelConnect();
|
||||||
|
} else if (this.shortcuts.resolve(event, 'panel')) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.shortcuts.isMod(event) && key.toLowerCase() === 'a') {
|
const plain = !event.metaKey && !event.ctrlKey && !event.altKey;
|
||||||
event.preventDefault();
|
const printable = plain && event.key.length === 1 && !event.isComposing;
|
||||||
panel.selectAll();
|
|
||||||
return;
|
if (panel.filter() !== '' && plain) {
|
||||||
|
if (printable) return this.setFilter(event, panel.filter() + event.key);
|
||||||
|
if (event.key === 'Backspace' && !event.shiftKey) return this.setFilter(event, panel.filter().slice(0, -1));
|
||||||
|
if (event.key === 'Escape') return this.setFilter(event, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// type-to-filter: printable characters build the query (Space only once a
|
const command = this.panelCommand(event);
|
||||||
// query exists — an empty-filter Space keeps its toggle-select meaning)
|
if (command) {
|
||||||
if (
|
|
||||||
key.length === 1 &&
|
|
||||||
!event.metaKey &&
|
|
||||||
!event.ctrlKey &&
|
|
||||||
!event.altKey &&
|
|
||||||
(key !== ' ' || panel.filter() !== '')
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
panel.setFilter(panel.filter() + key);
|
if (event.repeat && !isRepeatable(command.id)) return;
|
||||||
|
this.runKey(command.id, command.extend);
|
||||||
this.scrollCursorIntoView();
|
this.scrollCursorIntoView();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key === 'Backspace' && panel.filter() !== '') {
|
if (this.shortcuts.resolve(event, 'main')) return;
|
||||||
event.preventDefault();
|
|
||||||
panel.setFilter(panel.filter().slice(0, -1));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key === 'Escape' && panel.filter() !== '') {
|
|
||||||
event.preventDefault();
|
|
||||||
panel.setFilter('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (key) {
|
if (printable && event.key !== ' ') return this.setFilter(event, event.key);
|
||||||
case 'ArrowDown':
|
if (event.key === 'Escape' && plain && panel.selected().size) {
|
||||||
panel.moveCursor(1);
|
event.preventDefault();
|
||||||
break;
|
panel.clearSelection();
|
||||||
case 'ArrowUp':
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The panel command a keydown spells. Shift + a movement key is that
|
||||||
|
* movement extending the selection — a fixed rule, so it follows remaps. */
|
||||||
|
private panelCommand(event: KeyboardEvent): { id: ShortcutId; extend: boolean } | null {
|
||||||
|
const id = this.shortcuts.resolve(event, 'panel');
|
||||||
|
if (id) return { id, extend: false };
|
||||||
|
if (!event.shiftKey) return null;
|
||||||
|
const base = this.shortcuts.resolve(withoutShift(event), 'panel');
|
||||||
|
return base && MOVES.has(base) ? { id: base, extend: true } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private runKey(id: ShortcutId, extend: boolean): void {
|
||||||
|
const panel = this.panel();
|
||||||
|
const from = panel.cursor();
|
||||||
|
switch (id) {
|
||||||
|
case 'cursor-up':
|
||||||
panel.moveCursor(-1);
|
panel.moveCursor(-1);
|
||||||
break;
|
break;
|
||||||
case 'PageDown':
|
case 'cursor-down':
|
||||||
panel.moveCursor(20);
|
panel.moveCursor(1);
|
||||||
break;
|
break;
|
||||||
case 'PageUp':
|
case 'page-up':
|
||||||
panel.moveCursor(-20);
|
panel.moveCursor(-PAGE);
|
||||||
break;
|
break;
|
||||||
case 'Home':
|
case 'page-down':
|
||||||
|
panel.moveCursor(PAGE);
|
||||||
|
break;
|
||||||
|
case 'cursor-first':
|
||||||
panel.cursor.set(0);
|
panel.cursor.set(0);
|
||||||
break;
|
break;
|
||||||
case 'End':
|
case 'cursor-last':
|
||||||
panel.cursor.set(Math.max(0, panel.sorted().length - 1));
|
panel.cursor.set(Math.max(0, panel.sorted().length - 1));
|
||||||
break;
|
break;
|
||||||
case ' ':
|
case 'toggle-select': {
|
||||||
{
|
const entry = panel.sorted()[from];
|
||||||
const entry = panel.sorted()[panel.cursor()];
|
if (entry) panel.toggleSelect(entry.name);
|
||||||
if (entry) panel.toggleSelect(entry.name);
|
panel.moveCursor(1);
|
||||||
panel.moveCursor(1);
|
return;
|
||||||
}
|
}
|
||||||
break;
|
case 'select-all':
|
||||||
case 'Enter':
|
panel.selectAll();
|
||||||
this.openAt(panel.cursor());
|
return;
|
||||||
return void event.preventDefault();
|
case 'parent':
|
||||||
case 'Backspace':
|
|
||||||
panel.up();
|
panel.up();
|
||||||
break;
|
return;
|
||||||
case 'F2':
|
case 'switch-panel':
|
||||||
this.actions.rename(panel);
|
this.panels.setActiveSide(this.panels.otherSide(this.side()));
|
||||||
break;
|
return;
|
||||||
case 'F4':
|
case 'row-menu':
|
||||||
void this.actions.edit(panel);
|
this.openKeyboardMenu();
|
||||||
break;
|
return;
|
||||||
case 'F5':
|
case 'focus-path':
|
||||||
void this.transfers.copyToOtherPanel(this.side());
|
this.focusPath();
|
||||||
break;
|
return;
|
||||||
case 'F7':
|
case 'source-picker':
|
||||||
this.actions.mkdir(panel);
|
void this.openSourcePicker(true);
|
||||||
break;
|
return;
|
||||||
case 'F8':
|
|
||||||
case 'Delete':
|
|
||||||
this.actions.delete(panel);
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
return; // let unhandled keys through
|
// every other panel-scope command acts on files (keymap.ts)
|
||||||
|
this.actions.run(id as FileCommand, this.cursorTarget());
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
// Shift + movement: select every row the cursor passed over
|
||||||
|
if (extend) panel.selectRange(from, panel.cursor());
|
||||||
|
}
|
||||||
|
|
||||||
|
private setFilter(event: KeyboardEvent, query: string): void {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
this.panel().setFilter(query);
|
||||||
this.scrollCursorIntoView();
|
this.scrollCursorIntoView();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import { App } from './app';
|
import { App } from './app';
|
||||||
import { ConnectionManager } from './connection-manager/connection-manager';
|
import { ConnectionManager } from './connection-manager/connection-manager';
|
||||||
import { EditorWindow } from './editor-window/editor-window';
|
import { EditorWindow } from './editor-window/editor-window';
|
||||||
|
import { SettingsWindow } from './settings-window/settings-window';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The three window roots of the modern (shadcn-style) skin, keyed by APP_MODE.
|
* 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,
|
||||||
* Same shape as SEVEN_ROOTS, which is the point: main.ts picks a tree by skin
|
* which is what IS_MAC_CHROME accounts for).
|
||||||
* and then a root by mode, without knowing anything about either skin. This
|
|
||||||
* skin has no title-bar component — it defers to the native window frame
|
|
||||||
* (hiddenInset on macOS), which is exactly what IS_MAC_CHROME accounts for.
|
|
||||||
*/
|
*/
|
||||||
export const SHAD_ROOTS = {
|
export const SHAD_ROOTS = {
|
||||||
main: App,
|
main: App,
|
||||||
manager: ConnectionManager,
|
manager: ConnectionManager,
|
||||||
editor: EditorWindow,
|
editor: EditorWindow,
|
||||||
|
settings: SettingsWindow,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user