Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a0c708140 | ||
|
|
4605a481f1 | ||
|
|
5ed029a2b8 | ||
|
|
c4364c1051 | ||
|
|
89af52e1c0 | ||
|
|
9c54a441a9 | ||
|
|
f101257f20 | ||
|
|
9f2a076e08 | ||
|
|
c8ccc6b918 | ||
|
|
5bbfc2f475 | ||
|
|
eafe426c8e | ||
|
|
bb97e5418f | ||
|
|
b35c30d5ab | ||
|
|
39e019af9f | ||
|
|
17bb1a14fb | ||
|
|
ab0bb4ef6f | ||
|
|
06a14fb6a1 | ||
|
|
7921b2986b | ||
|
|
aaa12c3e23 | ||
|
|
01739e4cf4 | ||
|
|
7b15e0982e | ||
|
|
0ae2a62229 | ||
|
|
1f773261a8 | ||
|
|
fe75904872 | ||
|
|
2294453c93 | ||
|
|
2db367318d | ||
|
|
66076cf1d7 | ||
|
|
4102020f5c | ||
|
|
11c3d419c7 | ||
|
|
fe028a9b01 | ||
|
|
72643051f9 | ||
|
|
6bb4c3cd7d | ||
|
|
30a432cd37 | ||
|
|
f7ebed5478 | ||
|
|
07825c1bd1 | ||
|
|
fc03fa6216 | ||
|
|
e4ba846719 | ||
|
|
697f7f22c8 | ||
|
|
f87077b2b7 | ||
|
|
b5b1320776 | ||
|
|
3d8c410a82 | ||
|
|
b2cf000293 | ||
|
|
f86b10335e | ||
|
|
6ecb1b8339 | ||
|
|
a40433b99e | ||
|
|
43f1168d6a | ||
|
|
a0f56822cd | ||
|
|
d8cfc54049 | ||
|
|
ee5a9e13e6 | ||
|
|
260e2d64b2 |
@@ -8,3 +8,6 @@ ui/dist/
|
||||
ui/.angular/
|
||||
src/shared/version.gen.ts
|
||||
release/
|
||||
|
||||
# headless screenshot workspace (scripts/shot.ts)
|
||||
.shot/
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Working agreements for miniSCP
|
||||
|
||||
## Orientation
|
||||
|
||||
`docs/` is the source of truth — read the relevant file before touching adjacent code:
|
||||
[architecture.md](docs/architecture.md) (process/window model, bun modules, UI services, theming),
|
||||
[gotchas.md](docs/gotchas.md) (landmines already hit — read before changing nearby code),
|
||||
[build-and-release.md](docs/build-and-release.md), [platform-notes.md](docs/platform-notes.md).
|
||||
|
||||
## Planning
|
||||
|
||||
In plan mode, use `AskUserQuestion` to resolve **every** ambiguity before finalizing a
|
||||
plan. Never guess at intent or silently pick behavior the user didn't specify — keep asking
|
||||
until the approach is unambiguous, then exit planning.
|
||||
|
||||
## After an approved plan is executed
|
||||
|
||||
1. **Verify first** — backend: `bun run ./node_modules/typescript/bin/tsc --noEmit -p tsconfig.json`;
|
||||
UI: `bun run build:ui`; plus any smoke test that fits the change. (`bun run build:ui` is the
|
||||
full AOT type-check for Angular — trust it over IDE squiggles.)
|
||||
2. **Then commit + push automatically, no need to ask** — a descriptive message ending with:
|
||||
|
||||
```
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
Push to the current branch (this repo works directly on `main`).
|
||||
3. If verification fails, fix it or report back — do **not** commit broken work.
|
||||
|
||||
## Keep the live documentation current
|
||||
|
||||
`docs/` is living, not a one-time write. When a change alters the architecture/process model,
|
||||
a module's or service's responsibility, the RPC surface, theming, or introduces a new landmine,
|
||||
update the matching doc **in the same change, before the auto-commit**:
|
||||
|
||||
- `docs/architecture.md` — structure, bun modules, UI services, theming.
|
||||
- `docs/gotchas.md` — anything that caused a real bug or debugging spiral.
|
||||
- `docs/build-and-release.md` / `docs/platform-notes.md` — when build or platform behavior changes.
|
||||
|
||||
Match the existing dense, bulleted house style.
|
||||
@@ -15,14 +15,15 @@ remote server over SSH/SFTP, so you can move files **local↔remote**,
|
||||
Dense reference material lives in [docs/](docs/): [architecture](docs/architecture.md)
|
||||
(processes, windows, RPC, every module/service), [build & release](docs/build-and-release.md)
|
||||
(Build-n versioning, per-target release pipeline, CI), [platform notes](docs/platform-notes.md)
|
||||
(macOS/Windows matrix + smoke tests), and [gotchas](docs/gotchas.md) (known landmines).
|
||||
(macOS/Windows/Linux matrix, runtime deps + smoke tests), and
|
||||
[gotchas](docs/gotchas.md) (known landmines).
|
||||
|
||||
## Features
|
||||
|
||||
- Dual-panel commander UI; either panel can be Local or any saved connection.
|
||||
- **Session tabs**: each tab is an independent left/right workspace; remote
|
||||
sessions stay connected and transfers keep running in background tabs.
|
||||
- Per-panel action toolbar with a direction-explicit copy button, plus a
|
||||
- Per-panel action toolbar with a direction-explicit send button, plus a
|
||||
VSCode-style status bar showing live transfer progress (click to expand the
|
||||
transfer queue with per-file speed and ETA).
|
||||
- **Overwrite confirmation** on name collisions, with a source-vs-destination
|
||||
@@ -42,7 +43,7 @@ Dense reference material lives in [docs/](docs/): [architecture](docs/architectu
|
||||
cleanup at the next start.
|
||||
- Browse, sort (name/size/modified, folders first), keyboard navigation.
|
||||
- Create folder / file, rename, delete (recursive), change permissions (chmod).
|
||||
- Upload, download, and remote→remote copy — files **and** whole folders —
|
||||
- Upload, download, and remote→remote send — files **and** whole folders —
|
||||
with a transfer queue, per-job progress, and cancel.
|
||||
- Connection manager: saved connections organised into folders, password or
|
||||
private-key auth (with passphrase).
|
||||
@@ -75,6 +76,11 @@ ui/src/app/ Angular components + signal services
|
||||
|
||||
- [Bun](https://bun.sh) ≥ 1.3 (`bun --version`).
|
||||
- macOS: **Xcode Command Line Tools** (for the app build).
|
||||
- Linux: **AppIndicator** — the app links it for the tray and will not start
|
||||
without it (`ERR_DLOPEN_FAILED`). `libayatana-appindicator` (Arch),
|
||||
`libayatana-appindicator3-1` (Debian/Ubuntu),
|
||||
`libayatana-appindicator-gtk3` (Fedora), plus system `webkit2gtk`.
|
||||
Details and diagnostics in [platform notes](docs/platform-notes.md).
|
||||
- No Node install required — a preload shim (`scripts/node-version-shim.ts`)
|
||||
satisfies the Angular CLI's Node version check while running under Bun.
|
||||
|
||||
@@ -107,11 +113,14 @@ cross-compile between OSes:
|
||||
bun run release -- --target mac-arm64 # on the Mac (native)
|
||||
bun run release -- --target mac-x64 # on the same arm64 Mac, via Rosetta 2
|
||||
bun run release -- --target win-x64 # on a Windows 11 x64 machine
|
||||
bun run release -- --target linux-x64 # on a Linux x64 machine
|
||||
```
|
||||
|
||||
Artifacts land in `release/<target>/miniSCP-Build<n>-<target>.*` — a `.dmg`
|
||||
per mac arch; on Windows a `-Setup.zip` installer **and** a `-portable.zip`
|
||||
(unzip anywhere and run). The mac-x64 build fetches electrobun's x64 CLI once
|
||||
(unzip anywhere and run); on Linux a portable `.tar.gz` (unpack and run
|
||||
`miniSCP.sh`, which sets the NVIDIA workaround then execs `bin/launcher`).
|
||||
The mac-x64 build fetches electrobun's x64 CLI once
|
||||
into `~/.cache/miniscp/` and runs it under Rosetta so the whole pipeline
|
||||
believes it is on an Intel Mac. CI: `.gitea/workflows/release.yml` runs the
|
||||
same script on a macos + windows runner matrix once act_runners are
|
||||
@@ -170,7 +179,7 @@ Saved connections live in a plain JSON file in the OS app-data directory
|
||||
|
||||
`Tab` switch panel · `↑`/`↓`/`PgUp`/`PgDn`/`Home`/`End` move cursor ·
|
||||
`Space` select · `Enter` open folder · `Backspace` parent ·
|
||||
`Ctrl/Cmd+A` select all · `F5` copy to other panel · `F2` rename ·
|
||||
`Ctrl/Cmd+A` select all · `F5` send to other panel · `F2` rename ·
|
||||
`F7` new folder · `F8`/`Delete` delete · `Cmd/Ctrl+T` new tab ·
|
||||
`Cmd/Ctrl+W` close tab · `Ctrl+Tab` next tab.
|
||||
|
||||
@@ -210,6 +219,12 @@ shell-integration wording, drive listing, and the connections-file ACL. The
|
||||
application menu ([src/bun/menu.ts](src/bun/menu.ts)) is macOS-only for now;
|
||||
the in-app UI covers every menu action.
|
||||
|
||||
Right-click menus are **HTML, on every platform**
|
||||
([ui/src/app/context-menu.ts](ui/src/app/context-menu.ts)) rather than native —
|
||||
electrobun's native context menu is a documented no-op on Linux, and one menu
|
||||
implementation beats two that drift. Only reveal / open-with-default / copy-path
|
||||
cross to the backend, through the `shellItemAction` RPC.
|
||||
|
||||
## Windows (beta)
|
||||
|
||||
Windows 11+ x64 is supported as a **beta** target (win-arm64 deferred). All
|
||||
@@ -231,6 +246,29 @@ drive root ('..' disappears); local↔remote transfers; external editor launch
|
||||
renders colored; Permissions absent on local panels; Ctrl+labels in tooltips;
|
||||
portable zip runs from any folder.
|
||||
|
||||
## Linux (beta)
|
||||
|
||||
Linux x64 is supported as a **beta** target and builds a portable tarball.
|
||||
One hard requirement: the AppIndicator library listed under
|
||||
[Prerequisites](#prerequisites) — electrobun links it for the tray, so without
|
||||
it the app aborts at startup with `ERR_DLOPEN_FAILED` rather than merely losing
|
||||
its tray icon.
|
||||
|
||||
- **No native menus at all** upstream (both the app menubar and context menus
|
||||
are no-ops). The menubar was already macOS-only and the context menus are now
|
||||
HTML, so nothing is missing in practice.
|
||||
- **Tray visibility** depends on the desktop: KDE, XFCE, MATE and Cinnamon show
|
||||
it; GNOME 3.26+ needs the AppIndicator extension. The app works either way.
|
||||
- **Unsigned and unpackaged**: no AppImage, `.deb`, desktop entry or icon
|
||||
installation — unpack the tarball and run `miniSCP.sh`.
|
||||
- **NVIDIA GPUs** need `WEBKIT_DISABLE_DMABUF_RENDERER=1` or the window renders
|
||||
black. Every launch path (dev scripts and the packaged `miniSCP.sh` wrapper)
|
||||
sets it automatically when an NVIDIA driver is detected — see
|
||||
[gotchas](docs/gotchas.md#linux) if you add a new way to launch the app.
|
||||
|
||||
Full dependency table, diagnostics and the first-boot smoke test:
|
||||
[platform notes](docs/platform-notes.md).
|
||||
|
||||
## Known issues
|
||||
|
||||
- **Rare native crash under load.** A long-running session (multiple remote
|
||||
@@ -246,5 +284,5 @@ portable zip runs from any folder.
|
||||
|
||||
Drag-and-drop, file preview/edit, transfer resume, symlink following
|
||||
(symlinks are listed but skipped by transfers), OS keychain, tab persistence
|
||||
across restarts, Windows/Linux packaging (the code is portable; only macOS
|
||||
packaging is wired up).
|
||||
across restarts, Linux desktop integration (AppImage/.deb/.desktop entry —
|
||||
the release is a portable tarball).
|
||||
|
||||
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 619 B After Width: | Height: | Size: 371 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 702 B |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 702 B |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,21 @@
|
||||
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Bunny icon">
|
||||
<!--
|
||||
Front-facing bunny app icon.
|
||||
• Absolute background: TRANSPARENT (nothing drawn outside the rounded square).
|
||||
• ICON BACKGROUND = #0d0d0d (the rounded square)
|
||||
• BUNNY (fur) = #f7f7f5 (soft white; use #ffffff for pure white)
|
||||
Recolor the fur by editing the one fill on the <g>.
|
||||
• Parts: ears = ellipses (rotate = tilt) | head+body = circle |
|
||||
feet = two rounded ellipses (rx = width, ry = height; rotate = splay).
|
||||
Only change from the previous version: the feet are now chubby, gently
|
||||
splayed paws instead of pointy ovals.
|
||||
-->
|
||||
<rect x="24" y="24" width="464" height="464" rx="108" fill="#0d0d0d"/>
|
||||
<g fill="#f7f7f5">
|
||||
<ellipse cx="205" cy="185" rx="34" ry="105" transform="rotate(-9 205 185)"/> <!-- left ear -->
|
||||
<ellipse cx="307" cy="185" rx="34" ry="105" transform="rotate(9 307 185)"/> <!-- right ear -->
|
||||
<circle cx="256" cy="305" r="98"/> <!-- head + body -->
|
||||
<ellipse cx="203" cy="400" rx="32" ry="24" transform="rotate(-12 203 400)"/> <!-- left foot (cute) -->
|
||||
<ellipse cx="309" cy="400" rx="32" ry="24" transform="rotate(12 309 400)"/> <!-- right foot (cute) -->
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1,29 +1,94 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<!-- BunSCP master logo. Glyph: the two diagonal arrows of mynaui
|
||||
arrow-diagonal-one with the mini-lock of mynaui lock-square centered
|
||||
between them; the squircle itself plays the role of lock-square's
|
||||
outer square. Regenerate all raster assets with scripts/gen-icons.ts. -->
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#818cf8"/>
|
||||
<stop offset="0.5" stop-color="#4f46e5"/>
|
||||
<stop offset="1" stop-color="#1e1b4b"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect x="100" y="100" width="824" height="824" rx="186" fill="url(#bg)"/>
|
||||
|
||||
<g transform="translate(232 232) scale(23.333)"
|
||||
fill="none" stroke="#fafafa" stroke-width="1.5"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- arrow to top-left -->
|
||||
<path d="M8.2 8.2 4.6 4.6m0 0v4.2m0-4.2h4.2"/>
|
||||
<!-- arrow to bottom-right -->
|
||||
<path d="m15.8 15.8 3.6 3.6m0 0v-4.2m0 4.2h-4.2"/>
|
||||
<!-- lock: shackle -->
|
||||
<path d="M13.3 11.2V9.9c0-1.95-2.6-1.95-2.6 0v1.3"/>
|
||||
<!-- lock: body (amber accent) -->
|
||||
<rect x="9.4" y="11.2" width="5.2" height="4.6" rx="1.1"
|
||||
fill="#fbbf24" stroke="#fafafa"/>
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="0 0 512 512"
|
||||
role="img"
|
||||
aria-label="Bunny icon"
|
||||
version="1.1"
|
||||
id="svg4"
|
||||
sodipodi:docname="logo.svg"
|
||||
inkscape:version="1.4.4 (dcaf3e7, 2026-05-05)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs4" />
|
||||
<sodipodi:namedview
|
||||
id="namedview4"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="1.71875"
|
||||
inkscape:cx="256.29091"
|
||||
inkscape:cy="256.29091"
|
||||
inkscape:window-width="2560"
|
||||
inkscape:window-height="1382"
|
||||
inkscape:window-x="2560"
|
||||
inkscape:window-y="242"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg4" />
|
||||
<!--
|
||||
Front-facing bunny app icon.
|
||||
• Absolute background: TRANSPARENT (nothing drawn outside the rounded square).
|
||||
• ICON BACKGROUND = #0d0d0d (the rounded square)
|
||||
• BUNNY (fur) = #f7f7f5 (soft white; use #ffffff for pure white)
|
||||
Recolor the fur by editing the one fill on the <g>.
|
||||
• Parts: ears = ellipses (rotate = tilt) | head+body = circle |
|
||||
feet = two rounded ellipses (rx = width, ry = height; rotate = splay).
|
||||
Only change from the previous version: the feet are now chubby, gently
|
||||
splayed paws instead of pointy ovals.
|
||||
-->
|
||||
<g
|
||||
fill="#f7f7f5"
|
||||
id="g4"
|
||||
style="fill:#0e0e0c;fill-opacity:1"
|
||||
transform="matrix(1.3118908,0,0,1.3118908,-79.844041,-78.839213)">
|
||||
<ellipse
|
||||
cx="205"
|
||||
cy="185"
|
||||
rx="34"
|
||||
ry="105"
|
||||
transform="rotate(-9,205,185)"
|
||||
id="ellipse1"
|
||||
style="fill:#0e0e0c;fill-opacity:1" />
|
||||
<!-- left ear -->
|
||||
<ellipse
|
||||
cx="307"
|
||||
cy="185"
|
||||
rx="34"
|
||||
ry="105"
|
||||
transform="rotate(9,307,185)"
|
||||
id="ellipse2"
|
||||
style="fill:#0e0e0c;fill-opacity:1" />
|
||||
<!-- right ear -->
|
||||
<circle
|
||||
cx="256"
|
||||
cy="305"
|
||||
r="98"
|
||||
id="circle2"
|
||||
style="fill:#0e0e0c;fill-opacity:1" />
|
||||
<!-- head + body -->
|
||||
<ellipse
|
||||
cx="203"
|
||||
cy="400"
|
||||
rx="32"
|
||||
ry="24"
|
||||
transform="rotate(-12,203,400)"
|
||||
id="ellipse3"
|
||||
style="fill:#0e0e0c;fill-opacity:1" />
|
||||
<!-- left foot (cute) -->
|
||||
<ellipse
|
||||
cx="309"
|
||||
cy="400"
|
||||
rx="32"
|
||||
ry="24"
|
||||
transform="rotate(12,309,400)"
|
||||
id="ellipse4"
|
||||
style="fill:#0e0e0c;fill-opacity:1" />
|
||||
<!-- right foot (cute) -->
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 702 B |
|
Before Width: | Height: | Size: 621 B After Width: | Height: | Size: 855 B |
@@ -1,12 +1,94 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<!-- BunSCP tray glyph: black + alpha only (macOS template image).
|
||||
Same geometry as logo.svg, slightly bolder for menu-bar sizes. -->
|
||||
<g fill="none" stroke="#000" stroke-width="1.5"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8.2 8.2 4.6 4.6m0 0v4.2m0-4.2h4.2"/>
|
||||
<path d="m15.8 15.8 3.6 3.6m0 0v-4.2m0 4.2h-4.2"/>
|
||||
<!-- lock: outlined so the shackle + body silhouette stays readable small -->
|
||||
<path d="M13.55 11V9.7c0-2.1-3.1-2.1-3.1 0V11"/>
|
||||
<rect x="9.1" y="11" width="5.8" height="5" rx="1.2"/>
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="0 0 512 512"
|
||||
role="img"
|
||||
aria-label="Bunny icon"
|
||||
version="1.1"
|
||||
id="svg4"
|
||||
sodipodi:docname="tray-glyph.svg"
|
||||
inkscape:version="1.4.4 (dcaf3e7, 2026-05-05)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs4" />
|
||||
<sodipodi:namedview
|
||||
id="namedview4"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="1.71875"
|
||||
inkscape:cx="256.29091"
|
||||
inkscape:cy="256.29091"
|
||||
inkscape:window-width="2560"
|
||||
inkscape:window-height="1382"
|
||||
inkscape:window-x="2560"
|
||||
inkscape:window-y="242"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg4" />
|
||||
<!--
|
||||
Front-facing bunny app icon.
|
||||
• Absolute background: TRANSPARENT (nothing drawn outside the rounded square).
|
||||
• ICON BACKGROUND = #0d0d0d (the rounded square)
|
||||
• BUNNY (fur) = #f7f7f5 (soft white; use #ffffff for pure white)
|
||||
Recolor the fur by editing the one fill on the <g>.
|
||||
• Parts: ears = ellipses (rotate = tilt) | head+body = circle |
|
||||
feet = two rounded ellipses (rx = width, ry = height; rotate = splay).
|
||||
Only change from the previous version: the feet are now chubby, gently
|
||||
splayed paws instead of pointy ovals.
|
||||
-->
|
||||
<g
|
||||
fill="#f7f7f5"
|
||||
id="g4"
|
||||
style="fill:#ecece8;fill-opacity:1"
|
||||
transform="matrix(1.3118908,0,0,1.3118908,-79.844041,-78.839213)">
|
||||
<ellipse
|
||||
cx="205"
|
||||
cy="185"
|
||||
rx="34"
|
||||
ry="105"
|
||||
transform="rotate(-9,205,185)"
|
||||
id="ellipse1"
|
||||
style="fill:#ecece8;fill-opacity:1" />
|
||||
<!-- left ear -->
|
||||
<ellipse
|
||||
cx="307"
|
||||
cy="185"
|
||||
rx="34"
|
||||
ry="105"
|
||||
transform="rotate(9,307,185)"
|
||||
id="ellipse2"
|
||||
style="fill:#ecece8;fill-opacity:1" />
|
||||
<!-- right ear -->
|
||||
<circle
|
||||
cx="256"
|
||||
cy="305"
|
||||
r="98"
|
||||
id="circle2"
|
||||
style="fill:#ecece8;fill-opacity:1" />
|
||||
<!-- head + body -->
|
||||
<ellipse
|
||||
cx="203"
|
||||
cy="400"
|
||||
rx="32"
|
||||
ry="24"
|
||||
transform="rotate(-12,203,400)"
|
||||
id="ellipse3"
|
||||
style="fill:#ecece8;fill-opacity:1" />
|
||||
<!-- left foot (cute) -->
|
||||
<ellipse
|
||||
cx="309"
|
||||
cy="400"
|
||||
rx="32"
|
||||
ry="24"
|
||||
transform="rotate(12,309,400)"
|
||||
id="ellipse4"
|
||||
style="fill:#ecece8;fill-opacity:1" />
|
||||
<!-- right foot (cute) -->
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 621 B After Width: | Height: | Size: 2.7 KiB |
@@ -21,6 +21,7 @@
|
||||
"name": "ui",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"7.css": "^0.21.1",
|
||||
"@angular/common": "^22.0.0",
|
||||
"@angular/compiler": "^22.0.0",
|
||||
"@angular/core": "^22.0.0",
|
||||
@@ -45,6 +46,8 @@
|
||||
"@parcel/watcher",
|
||||
],
|
||||
"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/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=="],
|
||||
|
||||
@@ -10,7 +10,8 @@ Dense reference for development and debugging. Companion files:
|
||||
its native launcher. Owns every privileged capability: filesystem, SSH,
|
||||
window management, native menus/dialogs/tray, the connection/settings stores.
|
||||
- **Webviews** (`ui/` Angular): one per window, system WebKit on macOS /
|
||||
WebView2 on Windows. Zero privileged access — everything goes through RPC.
|
||||
WebView2 on Windows / WebKitGTK on Linux. Zero privileged access — everything
|
||||
goes through RPC.
|
||||
- **RPC transport**: one WebSocket server in the bun process, multiplexed by
|
||||
`webviewId` (each webview auto-connects using natively injected globals).
|
||||
Requests are typed by the `AppRPC` schema in `src/shared/rpc.ts`.
|
||||
@@ -42,14 +43,18 @@ no-op functions, reassigned in `src/bun/index.ts` after everything exists:
|
||||
|
||||
## Window kinds
|
||||
|
||||
`src/bun/windows.ts` — `buildPreload(mode, ctx?)` injects `__APP_PLATFORM`,
|
||||
`__APP_MODE` (`main|editor|manager`), `__APP_CTX` before any page script.
|
||||
`ui/src/main.ts` picks the root component from `APP_MODE`; all three roots use
|
||||
selector `app-root` (index.html hosts only that tag). `openSecondaryWindow` is
|
||||
the ONLY place that branches platform for secondary chrome: macOS
|
||||
`hiddenInset` + `trafficLightOffset {x:12,y:13}` (the UI renders an
|
||||
`h-10 pl-24` drag-region header via `IS_MAC_CHROME`); other platforms keep the
|
||||
native titlebar and a compact `h-9` non-drag header.
|
||||
`src/bun/windows.ts` — `buildPreload(mode, settings, ctx?)` injects
|
||||
`__APP_PLATFORM`, `__APP_MODE` (`main|editor|manager`), `__APP_SKIN`
|
||||
(`modern|win7`), `__APP_CTX` before any page script. `ui/src/main.ts` selects on
|
||||
BOTH axes: the skin picks a component tree, `APP_MODE` picks a root within it
|
||||
(see "UI layout" below). All roots use selector `app-root` (index.html hosts
|
||||
only that tag). Both globals are preload-baked because both are needed before
|
||||
first paint — settings arrive over an async RPC, far too late to choose a tree
|
||||
without flashing the wrong UI. `chromeOptions(skin)` is the ONLY place the
|
||||
native frame is decided: win7 is frameless + transparent everywhere, modern
|
||||
keeps the native titlebar (macOS `hiddenInset` + `trafficLightOffset
|
||||
{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,
|
||||
cascade placement off an anchor frame, focus tracking. electrobun's emitter
|
||||
@@ -57,7 +62,7 @@ has **no `off()`** — `track()` closures capture only `(this, key)` so closed
|
||||
records can be garbage collected. A future "window follows window" grouping
|
||||
belongs here (`anchor.on('move')` + `setPosition`).
|
||||
|
||||
### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/editor-window/`)
|
||||
### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/skins/*/editor-window/`)
|
||||
|
||||
One window per file, key `sessionId+path` (sessionId, not fsKey: saves route
|
||||
through a concrete session, and `local` is one shared session anyway).
|
||||
@@ -114,6 +119,12 @@ would float over editor windows).
|
||||
callback. mtime seconds→ms; mode masked `& 0o7777`.
|
||||
- `fs/registry.ts` — sessionId → provider map; `"local"` is built-in;
|
||||
`add()` mints `sftp-<n>`; `drop()` (session died) vs `remove()` (clean).
|
||||
- `fs/watcher.ts` — `LocalDirWatcher`: `fs.watch` handles keyed by dir,
|
||||
debounced ~150 ms (watch fires bursts). Drives local auto-refresh: the main
|
||||
webview sends `setWatchedDirs` (the active tab's local panel cwds); a change
|
||||
fires `onChange` → `rpc.send.pathRefresh({sessionId:"local", dir})`.
|
||||
`onChange` is late-bound in index.ts (the `events` pattern). SFTP has no
|
||||
watch, so remote panels are polled UI-side instead (see panels.service).
|
||||
- `transfers/engine.ts` — FIFO job queue. `Semaphore` with live resize
|
||||
(`wake()` reserves slots synchronously — an async waiter increment was a
|
||||
race). Per-job worker pool (`MAX_WORKERS_PER_JOB=8`) draws from the shared
|
||||
@@ -142,13 +153,17 @@ would float over editor windows).
|
||||
- `store/connections.ts` — plaintext-passwords JSON (deliberate v1 choice),
|
||||
atomic tmp+rename, 0600 + win32 `icacls` ACL. `store/settings.ts` — defaults
|
||||
spread-merge on load, so NEW SETTINGS KEYS need no backend change.
|
||||
- `context-menu.ts` — native row/empty-space menus + the column picker.
|
||||
Local shell actions (reveal/open/copy-path) run bun-side via Utils; others
|
||||
forward through `menuAction` and the UI applies them to the row it
|
||||
remembered (`FileActionsService.menuTarget`). Labels are per-platform.
|
||||
- `menu.ts` — macOS-only app menu, rebuilt on every connections change (the
|
||||
Connect submenu mirrors the store). ⌘W is intercepted by the menu, so bun
|
||||
routes `close-tab` to the focused editor window first.
|
||||
routes `close-tab` to the focused editor window first. Labels go through
|
||||
backend `i18n.t` and the menu is rebuilt on a locale change too.
|
||||
- `i18n.ts` — backend translator (native menus + thrown error messages, which
|
||||
no Angular service can reach). `setLocale(setting)` caches the resolved
|
||||
locale (from `settings.locale`, `system` via `Intl…resolvedOptions().locale`);
|
||||
`t(key, params)` shares the same `src/shared/i18n` dictionaries as the UI.
|
||||
Wired in index.ts on boot and on `settingsChanged` (relocalizes + rebuilds
|
||||
the app menu). The context menu no longer needs this — it is Angular and reads
|
||||
`I18nService` directly.
|
||||
- `tray.ts` — static icon, click = activate main. macOS template image;
|
||||
Windows colored icon. No `setImage` calls ever (they lose the template
|
||||
treatment) and no menu (clicks arrive directly).
|
||||
@@ -156,7 +171,41 @@ would float over editor windows).
|
||||
rpc; manager/editors constructed after win; quit guard; focus tracking for
|
||||
notification suppression across all windows).
|
||||
|
||||
## UI services (`ui/src/app/`)
|
||||
## UI layout: one core, two skin trees
|
||||
|
||||
```
|
||||
ui/src/app/
|
||||
core/ app.config.ts, icons.ts, platform.ts, format.ts, file-icons.ts,
|
||||
and all 12 *.service.ts — everything skin-agnostic
|
||||
skins/
|
||||
shadlike/ the modern (shadcn-style) tree + shadlike.css
|
||||
7like/ the Windows 7 tree + 7like.css + fonts/ + title-bar/
|
||||
```
|
||||
|
||||
Each skin owns a **complete** component tree, including its own three window
|
||||
roots, and exports a barrel (`SHAD_ROOTS` / `SEVEN_ROOTS`) with the same
|
||||
`{ main, manager, editor }` shape. `main.ts` therefore knows nothing about
|
||||
either tree's internals: `APP_SKIN` picks the barrel, `APP_MODE` picks the root.
|
||||
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:
|
||||
standalone components resolve through their importer's `imports:` array, never
|
||||
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/`)
|
||||
|
||||
- `rpc.service.ts` — wraps `Electroview.defineRPC`. **Push messages fan out
|
||||
through plain callback registries, never signals**: a signal consumed from
|
||||
@@ -172,7 +221,15 @@ would float over editor windows).
|
||||
auto-load `localDir` into an untouched local panel). `connectToken`
|
||||
supersedes stale connects. `Workspace` = tab = left/right pair + activeSide.
|
||||
Remote goes RIGHT on connect/open-stack (commander convention); per-panel
|
||||
dropdown connects target their own panel.
|
||||
dropdown connects target their own panel. **Auto-refresh** (always on):
|
||||
`refresh({silent})` re-lists without the loading/busy flags and touches no
|
||||
DOM (scroll/cursor/selection/filter preserved), snapshotting sessionId+cwd
|
||||
to discard results if the user navigated mid-flight. Only the active tab is
|
||||
live: its local panels are fs.watched (backend `setWatchedDirs` effect +
|
||||
`pathRefresh` push → silent refresh), its remote panels polled every 3s
|
||||
(`REMOTE_POLL_MS`); switching tabs silently refreshes the newly-active pair.
|
||||
A future on/off setting is stubbed in commented breadcrumbs (types.ts,
|
||||
settings.service, tab-bar).
|
||||
- `transfers.service.ts` — jobMap from `transferUpdate` callbacks; EMA speed
|
||||
(`SPEED_SMOOTHING=0.7`); `copyToOtherPanel` guards same-fs+same-dir before
|
||||
enqueue and runs the collision flow (same-path auto-skip, type-mismatch
|
||||
@@ -188,25 +245,268 @@ would float over editor windows).
|
||||
(whose constructor spawns a workspace — fatal in secondary windows).
|
||||
- `connections.service.ts` — store mirror + ssh-config hosts; reloads on the
|
||||
`connectionsChanged` push.
|
||||
- `i18n.service.ts` — runtime translator. `locale` signal + `t('ns.key', params)`
|
||||
(dotted lookup, `{param}` interpolation, `{one,other}` plural maps via
|
||||
`Intl.PluralRules`); missing keys fall back to English then the raw key.
|
||||
**Reactive by design**: `t()` reads the `locale` signal, so every template
|
||||
binding that calls it re-renders live on a language switch (same call-stack
|
||||
tracking as `iconClass`) — no pipe. Also exposes locale-aware `date()`/`size()`
|
||||
/`speed()` so the file list reformats on switch. `SettingsService.applyLocale`
|
||||
resolves the `locale` setting (`system` → `navigator.language`) into it, on
|
||||
boot and on the `settingsChanged` broadcast (so editor/manager windows follow).
|
||||
- `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always
|
||||
against an explicit panel; `menuTarget` remembers the right-clicked row.
|
||||
- `context-menu.service.ts` + `context-menu.ts` — the panel and tab right-click
|
||||
menus, in HTML. Electrobun's native `showContextMenu` is a no-op on Linux, so it was
|
||||
retired on ALL platforms rather than kept as a second code path (see
|
||||
platform-notes.md). The service builds the item list (row / empty-space menu,
|
||||
and the header column picker) and holds the open request as a signal; the
|
||||
component renders it at the pointer with edge-flip clamping, arrow/Enter/Escape
|
||||
keys, and close-on-blur/resize/scroll. Picked items are emitted verbatim as
|
||||
`ctx-*` / `col:*` / `tab:*` strings through `RpcService.emitMenuAction`, so they
|
||||
land in the SAME `App.handleMenuAction` dispatcher the macOS app menubar uses —
|
||||
one handler, two sources. The three shell actions the webview cannot perform
|
||||
(reveal, open-with-default, copy path) go back to bun via the `shellItemAction`
|
||||
RPC, which is all that remains of the old native menu.
|
||||
`tabItems()` builds the workspace-tab menu (`tab:new`, and
|
||||
`tab:close|close-others|close-right|disconnect:<id>`), served by
|
||||
`PanelsService.closeOtherWorkspaces` / `closeWorkspacesToRight` /
|
||||
`disconnectWorkspace`. **The target id travels inside the action string**: the
|
||||
right-clicked tab is not necessarily the active one, so nothing on that path
|
||||
may fall back to `activeWorkspaceId`. Illegal entries are `disabled`, not
|
||||
omitted — Win7 greys out, and a menu whose items move between right-clicks is
|
||||
worse than one with a dead row. 7.css hangs that state on
|
||||
`li[role=menuitem][aria-disabled]` (opacity + `pointer-events: none`), which
|
||||
the keyboard path never consults — so `pick()` and the arrow-key walker check
|
||||
the flag themselves, in both skins.
|
||||
- `file-icons.ts` — extension → category map driving glyphs, colors
|
||||
(fixed `--icon-*` tokens, never accent-tinted) and the Kind column.
|
||||
- `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`,
|
||||
`EDITOR_CONTEXT`, `IS_MAC_CHROME`, `IS_WINDOWS`, `MOD_LABEL`/`ALT_LABEL`)
|
||||
+ `blockNativeContextMenu` (kills WebKit's Reload/Inspect menu outside
|
||||
text fields).
|
||||
`EDITOR_CONTEXT`, `IS_MAC`/`IS_MAC_CHROME`, `IS_WINDOWS`, `IS_LINUX`,
|
||||
`MOD_LABEL`/`ALT_LABEL`) + `blockNativeContextMenu` (kills WebKit's
|
||||
Reload/Inspect menu outside text fields — the app draws its own menu instead).
|
||||
- `updater.service.ts` — auto-updater state for the tab-bar pill. Signals
|
||||
`state` (`idle→available→downloading→ready`|`error`), `version`, `progress`.
|
||||
Subscribes the `updateStatus` push (backend `Updater.checkForUpdate` on
|
||||
launch+6h, `onStatusChange` progress); `ready` is derived when the
|
||||
`downloadUpdate` request resolves ok (not a pushed phase). `download()` /
|
||||
`apply()` (relaunch) / `check()` (menu). Backend wiring lives in
|
||||
`src/bun/index.ts`; feed is `release.baseUrl` (`https://miniscp.com/dl`, the
|
||||
`/dl` host in `site/`). See build-and-release.md "Auto-updater".
|
||||
|
||||
## Theming
|
||||
|
||||
`ui/src/styles.css`: shadcn-style OKLCH tokens; dark values are duplicated in
|
||||
`:root[data-theme='dark']` AND a `prefers-color-scheme` media block (system
|
||||
mode) — every dark token change goes in BOTH. Accent tones: one
|
||||
mode) — every dark token change goes in BOTH. Light is the zinc family; the
|
||||
dark neutrals are a **warm gray family (hue 107)** anchored on `#1a1a19`
|
||||
(`--background`) — retoning one dark neutral means retoning them all, or warm
|
||||
and cool surfaces clash. Surface elevation ramp, both themes: `--chrome`
|
||||
(frame strips) sits *below* `--background` (content panes) < `--card`
|
||||
(popovers/dialogs) < `--accent` (hover). Dark ramp `#151514` → `#1a1a19` →
|
||||
`#20201f` → `#2e2e2d`. The "darker" tone is true-black AMOLED: `--chrome` AND
|
||||
`--background` are both `#000` (the frame/content step collapses on purpose),
|
||||
elevation survives only via `--card`/`--accent`, and `--border`/`--input` go to
|
||||
16%/18% white because those lines then carry the whole layout.
|
||||
`bg-chrome` is on the tab bar, status bar, transfer bar, the editor/manager
|
||||
header+footer strips and the manager's connection sidebar; panel-internal
|
||||
toolbars deliberately stay on `--background`. Accent tones: one
|
||||
`--tone-h/--tone-c` line per tone + three formula blocks derive `--primary`,
|
||||
`--ring`, `--cursor-tint`; graphite = attribute removed = pristine zinc.
|
||||
`@theme inline` maps tokens to Tailwind utilities (`bg-primary`,
|
||||
`text-icon-folder`, `bg-cursor-tint`). Selected rows are `bg-primary/15`
|
||||
(follow the accent); the cursor row uses `--cursor-tint` so graphite stays
|
||||
pixel-identical to the old neutral hover. Text-size levels set `--row-h`,
|
||||
`--list-font` etc. per `data-text` attribute, consumed via `h-(--row-h)` /
|
||||
`text-(length:--list-font)` arbitrary utilities.
|
||||
pixel-identical to the old neutral hover. **Interface scale** (`settings.uiScale`,
|
||||
0.5–4 in 0.1 steps): one root font-size lever — `applyUiScale` sets
|
||||
`html { font-size: 16px * scale }`, and since EVERY size in the app is rem
|
||||
(panel tokens like `--row-h`, all Tailwind utilities, ng-icon sizes, `--w7-fs`
|
||||
and the 7.css bridge in 7like.css), both skins scale whole, chrome included.
|
||||
Driven by the tab-bar stepper, Ctrl/Cmd +/−/0, and preload-baked for a
|
||||
flash-free first paint. It replaced the 3-tier `data-text` axis; the store
|
||||
migrates old `textSize` values. TS that measures rem-sized CSS converts through
|
||||
`@core/metrics` `remPx()` — never an assumed 16.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -25,7 +25,8 @@ semver-shaped (`0.1.<n>`) for electrobun's config/updater; `BRANDING.buildLabel`
|
||||
|
||||
## Release builds
|
||||
|
||||
`bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64]` (default: host).
|
||||
`bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64|linux-x64]`
|
||||
(default: host).
|
||||
Steps: gen-version → gen-icons → Angular build → `electrobun build
|
||||
--env=stable` → copy/rename artifacts into
|
||||
`release/<target>/miniSCP-Build<n>-<target>.*`.
|
||||
@@ -37,7 +38,8 @@ Facts the script encodes:
|
||||
- **No OS cross-compile.** The CLI builds for its host (its `targets` config
|
||||
field is unread in 1.18.1); packaging needs host tools (codesign/hdiutil on
|
||||
macOS, rcedit/PowerShell on Windows). Hence one script run per artifact, on
|
||||
the matching machine; `win-x64` on a non-Windows host errors out.
|
||||
the matching machine; `win-x64` on a non-Windows host and `linux-x64` on a
|
||||
non-Linux host both error out.
|
||||
- **mac-x64 from the arm64 Mac (Rosetta)**: the script downloads electrobun's
|
||||
x64 CLI binary once (`electrobun-cli-darwin-x64.tar.gz` from the GitHub
|
||||
release matching the installed version, cached at
|
||||
@@ -56,7 +58,16 @@ Facts the script encodes:
|
||||
(self-extracting installer → `%LOCALAPPDATA%`) plus the script's own
|
||||
**portable zip** made from the assembled app folder under
|
||||
`build/stable-win-x64/` (layout to confirm on the first real Windows build —
|
||||
the step warns instead of failing).
|
||||
the step warns instead of failing); Linux → only
|
||||
`stable-linux-x64-miniSCP.tar.zst` + `update.json`, i.e. the updater feed and
|
||||
nothing installable, so the script's **`.tar.gz` of the assembled app folder**
|
||||
under `build/stable-linux-x64/` IS the release artifact (same best-effort
|
||||
warn). No AppImage/.deb/.desktop — unpack and run the `miniSCP.sh` wrapper the
|
||||
script writes beside `bin/launcher` (it applies the NVIDIA/DMABUF workaround,
|
||||
which the app cannot set for itself — see gotchas.md).
|
||||
- **Linux builds need the AppIndicator dev libraries present to RUN** (not to
|
||||
build) — see platform-notes.md; a packaged tarball started on a machine
|
||||
without them dies at `dlopen`.
|
||||
- The script filters artifacts by app name, so stale artifacts from older
|
||||
brandings don't get collected.
|
||||
|
||||
@@ -67,6 +78,55 @@ runner matrix, each running the same release script; inert until act_runners
|
||||
with those labels are registered. Needs `fetch-depth: 0` (the build number is
|
||||
the commit count). Runner prereqs are commented in the workflow file.
|
||||
|
||||
## Download host (`site/` + Caddy)
|
||||
|
||||
`site/` is a self-contained landing page **and** download host, served by Caddy on an
|
||||
internal box (plain HTTP `:80`, no domain/TLS). Deploys to `/srv/miniscp/`.
|
||||
|
||||
- `site/index.html` — pure static, no JS, tinygrad-minimal. Download buttons link to **fixed
|
||||
stable filenames** (`/dl/stable-macos-arm64-miniSCP.dmg`, …) so the page never changes per
|
||||
release. Logo/screenshot `<img>` slots are commented in, images live in `site/assets/`.
|
||||
- `site/Caddyfile` — one flat `/dl/*` folder with `file_server browse` + range requests, and
|
||||
per-type `Cache-Control`: `*.patch` immutable (unique hashed names), `*update.json` `no-cache`
|
||||
(updater must see new versions), installers (`*.dmg *.zip *.tar.zst`) `max-age=300` (stable
|
||||
names overwritten each release). Landing page has **no** SPA fallback → unknown paths 404.
|
||||
Path matcher gotcha: use suffix globs like `*update.json`, not `*/*update.json`.
|
||||
- **One flat folder, no per-build dirs** — this layout IS electrobun's `artifacts/` feed, so it
|
||||
doubles as the (future) updater `baseUrl`. Publishing = drop this machine's `artifacts/stable-*`
|
||||
into `/dl/`; installers overwrite in place, `.patch` files accumulate (never delete them, or
|
||||
older installs lose their incremental-update trail). **Tradeoff:** only the latest full
|
||||
installer is downloadable; old full versions are overwritten.
|
||||
- `site/deploy.sh` — run per build machine after `scripts/release.ts`; copies `artifacts/stable-*`
|
||||
and renames the build-numbered win portable zip to the stable `stable-win-x64-miniSCP-portable.zip`.
|
||||
`MINISCP_DL_DIR` overrides the target (stage locally, then rsync to the server).
|
||||
- `release.baseUrl = https://miniscp.com/dl` is set in `electrobun.config.ts`; `miniscp.com` is
|
||||
fronted by an external proxy for TLS (Caddy itself stays on `:80` behind it). The value is baked
|
||||
into `version.json` at build time, so only builds made after it was added check for updates.
|
||||
With `generatePatch` (default true), release builds now need **build-time network to
|
||||
`miniscp.com/dl`** so electrobun can fetch the currently-published version to diff a `.patch`
|
||||
(the first build after enabling it has nothing to diff and just ships the full artifacts).
|
||||
|
||||
## Auto-updater (app side)
|
||||
|
||||
Wired on top of the download host above. See the "Auto-updater" section in
|
||||
[architecture.md](architecture.md) for the runtime flow.
|
||||
|
||||
- **Backend** ([src/bun/index.ts](../src/bun/index.ts)): `Updater.checkForUpdate()` runs ~5s after launch and
|
||||
every 6h; on `updateAvailable` it pushes `rpc.send.updateStatus({phase:"available",…})`.
|
||||
`Updater.onStatusChange` streams `download-progress` (0–100) → `updateStatus{downloading}`.
|
||||
`checkForUpdate` self-guards the `dev` channel, so `bun run dev` never shows the pill.
|
||||
- **RPC** ([src/shared/rpc.ts](../src/shared/rpc.ts)): requests `checkForUpdate` / `downloadUpdate` /
|
||||
`applyUpdate` (handlers in [rpc-handlers.ts](../src/bun/rpc-handlers.ts) call the electrobun `Updater`);
|
||||
push message `updateStatus`.
|
||||
- **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))
|
||||
is one accent (`bg-primary`) control that announces → fills as a progress bar while downloading →
|
||||
"Restart to apply". `state` is `available → downloading → ready` (ready derived from the
|
||||
`downloadUpdate` response). App-menu "Check for Updates…" ([menu.ts](../src/bun/menu.ts)) routes a
|
||||
`check-for-updates` menu action into `UpdaterService.check()`.
|
||||
- **Unsigned is fine:** `applyUpdate()` strips the macOS quarantine xattr before relaunch, so the
|
||||
swapped bundle isn't Gatekeeper-blocked.
|
||||
|
||||
## Icons
|
||||
|
||||
`scripts/gen-icons.ts` (resvg, dev-time): renders `assets/logo.svg` into the
|
||||
|
||||
@@ -28,6 +28,174 @@ touching the adjacent code.
|
||||
- Tailwind v4 canonical class names: `wrap-break-word` not `break-words`,
|
||||
`accent-primary` not `accent-[var(--primary)]`, `z-60` not `z-[60]`.
|
||||
Every color used in a template must resolve through `@theme inline`.
|
||||
- **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
|
||||
`styles.css` live under `ui/src/` (esbuild emits them hashed into `media/`
|
||||
and rewrites the url), NOT `ui/public/` — a public path fails the build with
|
||||
"Could not resolve".
|
||||
- **Two `class` attributes on one element: the second is silently dropped.**
|
||||
No warning, no build error — the layout classes simply never apply. This
|
||||
collapsed the tab strip and two window headers at once (`fe028a9`), because
|
||||
a scripted edit appended `class="…"` to elements that already had one.
|
||||
When removing a conditional binding, check what the branch *contained*: the
|
||||
bindings there were genuinely dead, but the layout classes inside them were
|
||||
not, and nothing replaced them.
|
||||
- **A component `template:` is a TS template literal, so no backticks inside
|
||||
it** — not even in an HTML comment. A backtick in a comment terminates the
|
||||
literal and the errors point at the prose, not at the quoting.
|
||||
|
||||
## The interface scale (root font-size lever)
|
||||
|
||||
- **electrobun's native page zoom is a silent no-op on Linux — do not reach for
|
||||
it.** `webviewSetPageZoom` in the shipped `libNativeWrapper.so` (v1.18.1, and
|
||||
the `_cef.so`, and the copy in our own build output) is a compiled
|
||||
empty-body stub: the disassembly has no `call` instruction, and
|
||||
`webkit_web_view_set_zoom_level` is not even in the library's imports.
|
||||
`webviewGetPageZoom` returns a hardcoded `1.0` from `.rodata`, so the failure
|
||||
is unobservable through the API — set 2.0, read back "1.0 = success". The
|
||||
JSDoc's "(WebKit only)" means macOS WKWebView. This is why the scale is CSS.
|
||||
- **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
|
||||
three px reservoirs were: the panel-chrome tokens (`--row-h` etc.), 93
|
||||
`text-[11px]` arbitraries, and 7.css itself — 370 px values, zero rem, with
|
||||
`--w7-font` reaching only 15 rules' typography. 7like.css's `skinfix` carries
|
||||
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
|
||||
shadlike context menu's `MENU_W = 224` was a hardcoded mirror of `w-56`
|
||||
(14rem): box grew, math didn't, flip fired at the wrong edge. Convert through
|
||||
`@core/metrics` `remPx()` at use time — it reads the LIVE root font-size,
|
||||
and per call, since the scale changes mid-session. The editor's canvas-based
|
||||
`charWidth` is cached keyed on the computed font string for the same reason.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -48,7 +216,109 @@ touching the adjacent code.
|
||||
- Tray: never call `setImage` after construction on macOS — the template
|
||||
treatment is lost and the icon renders black on dark menu bars.
|
||||
- `ApplicationMenu` is macOS-only in our usage; accelerators on Windows are
|
||||
single-character only. ContextMenu exists on macOS+Windows, not Linux.
|
||||
single-character only.
|
||||
- **`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
|
||||
not supported on Linux" at boot). Because the UI suppresses the webview's own
|
||||
right-click menu, that left EVERY panel context menu dead on Linux — no error,
|
||||
nothing happens. The menu is plain HTML now (`ui/src/app/skins/*/context-menu.ts`) on
|
||||
every platform; don't reintroduce the native one for "nativeness" on mac/win
|
||||
without keeping a Linux path.
|
||||
|
||||
## Linux
|
||||
|
||||
- **`ERR_DLOPEN_FAILED` on `bun run start`** means a missing system library, not
|
||||
broken app code. `libNativeWrapper.so` links `libayatana-appindicator3.so.1`
|
||||
*hard* (for the tray), so the app cannot start at all without it — even though
|
||||
the tray is a minor feature. Diagnose with
|
||||
`ldd build/dev-linux-x64/*/bin/libNativeWrapper.so | grep 'not found'`; the
|
||||
`libasar.so => not found` line in that output is a red herring (it ships beside
|
||||
the wrapper and resolves at load). Install list per distro is in
|
||||
[platform-notes.md](platform-notes.md).
|
||||
- **Black window on NVIDIA + `Failed to create GBM buffer of size WxH: Invalid
|
||||
argument`** (usually with `X11 Error: GLXBadWindow (code 170)`). The app boots,
|
||||
logs "miniSCP started", reports no error — and paints nothing. WebKitGTK's
|
||||
DMABUF renderer can't allocate buffers on the NVIDIA proprietary driver;
|
||||
`WEBKIT_DISABLE_DMABUF_RENDERER=1` is the upstream workaround.
|
||||
- **The app cannot apply this to itself.** WebKit reads the variable when it
|
||||
forks its web process, and **Bun's `process.env` writes do not propagate to
|
||||
child processes** (verified: a spawned child sees it unset). Setting it in
|
||||
`src/bun/index.ts` — even in a module imported before `electrobun/bun` —
|
||||
does nothing. It must be in the environment before the app process starts.
|
||||
- Hence every launch path injects it: `scripts/linux-gpu.ts` (`launchEnv()`)
|
||||
for `bun run dev` / `bun run start`, and the `miniSCP.sh` wrapper that
|
||||
`release.ts` writes beside `bin/launcher` in the tarball. **A new launch
|
||||
path must call `launchEnv()` or it will render black on NVIDIA.**
|
||||
- Gated on `/proc/driver/nvidia` so Intel/AMD keep hardware compositing, and
|
||||
an explicit `WEBKIT_DISABLE_DMABUF_RENDERER` from the user always wins.
|
||||
- A "does it boot?" log check does NOT catch this — grep for the GBM line, or
|
||||
look at the window.
|
||||
- **`bun run start` leaves the old instance running, and
|
||||
`import -window miniSCP` grabs the OLDEST match.** Instances pile up across
|
||||
rebuilds, so every screenshot silently comes from a stale build and the fix
|
||||
you just made looks like it did nothing. Cost three wrong readings in one
|
||||
session, including a "the CSS is in the bundle but does not apply" dead end.
|
||||
Kill first (`pkill -f dev-linux-x64`), confirm with
|
||||
`pgrep -cf 'dev-linux-x64/miniSCP-dev/bin/launcher'`, then launch and capture.
|
||||
Note `pkill -f 'electrobun dev'` also matches the shell running it and kills
|
||||
your own command (exit 144) — match on the build path instead. That is only
|
||||
half the trap: `pkill -f dev-linux-x64` does the same thing, because the
|
||||
pattern appears in the killing command's OWN argv. Break the literal with a
|
||||
character class — `pkill -f 'dev-linux-x[6]4'` — the way `ps | grep [x]yz`
|
||||
has always worked.
|
||||
- **A frameless window cannot be resized, and `preventDefault` will not save
|
||||
you from the drag region.** Undecorating the GTK window (what
|
||||
`titleBarStyle: "hidden"` does) takes the WM's resize borders with it, and
|
||||
electrobun exposes no resize gesture — hence the synthesized grip in
|
||||
`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:
|
||||
`libayatana-appindicator is deprecated. Please use libayatana-appindicator-glib`
|
||||
(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
|
||||
revealed by `showWhenReady` on the webview's first `dom-ready` (800 ms
|
||||
@@ -56,6 +326,33 @@ touching the adjacent code.
|
||||
attributes 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 skin adds a **fourth value to keep in sync**: `buildPreload` also
|
||||
bakes the `.win7` class, and index.html carries a matching `html.win7` arm
|
||||
for all four theme states. Miss it and every win7 window flashes white.
|
||||
- **The skin is a LAUNCH-TIME choice. Never apply one to a running window.**
|
||||
This shipped broken once: `SettingsService.applySkin` toggled the `.win7`
|
||||
class the moment the setting changed, which was harmless when there was one
|
||||
component tree and became a broken window as soon as there were two — 7.css
|
||||
painted onto shadlike markup that has no `.window.glass`, no `.title-bar`, no
|
||||
`menu[role=tablist]` and no `<table>` ListView. Each skin's CSS is written
|
||||
against its OWN tree's markup; the class must only ever be written by
|
||||
`buildPreload`, for the tree that window actually booted.
|
||||
- **A reload does not rescue it.** The preload is a persistent
|
||||
`WebKitUserScript` injected at DOCUMENT_START, so its string is frozen at
|
||||
webview construction — `webview.loadURL()` re-runs it with the OLD
|
||||
`__APP_SKIN`. (`updatePreloadScriptToWebView` exists in the FFI but is
|
||||
reachable only from electrobun's internal `<electrobun-webview>` handler.)
|
||||
- **`titleBarStyle`/`transparent` are construction-time only** — no setters
|
||||
exist, so a live window can never be re-framed. win7 → modern is the
|
||||
dangerous direction: the modern tree draws no title bar, so it would land
|
||||
in a frameless transparent window with no way to move, minimize or close.
|
||||
- Don't "fix" this by recreating the main window either: `exitOnLastWindowClosed`
|
||||
is true, the main `rpc` is captured by six push closures, the tray captures
|
||||
`win`, listeners cannot be removed (no `off()`) — and it would drop every
|
||||
session and tab.
|
||||
- Bun pins it: `initSessionSkin` freezes the skin before any window exists,
|
||||
and `buildPreload`/`chromeOptions` read that, never the live settings. Skip
|
||||
the pin and a window opened after a pending change boots the other skin.
|
||||
- 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
|
||||
@@ -86,6 +383,32 @@ touching the adjacent code.
|
||||
- `panel.touched` gates localDir auto-navigation; `connectToken` guards
|
||||
against a slow connect resolving into a panel the user already reused.
|
||||
|
||||
## i18n
|
||||
|
||||
- **Translations live in `src/shared/i18n/*.ts` (TS, not JSON)** — deliberate:
|
||||
it sidesteps `resolveJsonModule` differences between the Angular esbuild and
|
||||
Bun builds and gives full typing. `en.ts` is the source of truth; other
|
||||
locales are partial and fall back to English then the raw key.
|
||||
- **`i18n.t()` is reactive because it reads the `locale` signal** — that's what
|
||||
makes live language switching work with no pipe. If you cache a translated
|
||||
string in a plain field it WON'T update on switch; call `t()` in the template
|
||||
(or in a signal-reading getter) instead. Same for `i18n.date()/size()`.
|
||||
- Dictionary values are TS string literals: **French/other apostrophes break
|
||||
single-quoted entries** (`'…c'est…'`). Use double quotes for any value
|
||||
containing an apostrophe. Plurals are `{ one, other }` maps (not ICU strings);
|
||||
zh/ja supply only `other`.
|
||||
- **Native menus can't use the Angular service** — `menu.ts` goes through backend
|
||||
`i18n.ts`, and the app menu must be rebuilt when the locale changes (wired in
|
||||
index.ts `settingsChanged`), or it keeps the old language. The context menu no
|
||||
longer has this problem: it is Angular now and reads `I18nService` directly.
|
||||
- `eu`/`zh`/`ja` are AI drafts pending native review (see the i18n README).
|
||||
- **Silent auto-refresh must snapshot sessionId+cwd and discard a mismatched
|
||||
result.** A normal `refresh()` is masked by `busy` (input blocked), but the
|
||||
silent variant (poll/watch) runs while the user can navigate — without the
|
||||
guard, a poll that started in dir A resolves and stamps A's listing over the
|
||||
dir B the user just opened. It also never calls `scrollCursorIntoView`
|
||||
(scroll must stay put) and returns early if `busy()` or already refreshing.
|
||||
|
||||
## Build / tooling
|
||||
|
||||
- Angular CLI rejects Bun's Node-compat version — `scripts/node-version-shim.ts`
|
||||
|
||||
@@ -1,31 +1,162 @@
|
||||
# Platform notes — macOS / Windows behavior matrix
|
||||
# Platform notes — macOS / Windows / Linux behavior matrix
|
||||
|
||||
macOS is the primary target; Windows 11+ x64 is a **beta** target (win-arm64
|
||||
deferred; Linux untargeted — electrobun lacks menus/context menus there).
|
||||
Every platform seam carries a `PORTABILITY:` comment at the branch site.
|
||||
macOS is the primary target; Windows 11+ x64 and Linux x64 are **beta** targets
|
||||
(win-arm64 and linux-arm64 deferred). Every platform seam carries a
|
||||
`PORTABILITY:` comment at the branch site.
|
||||
|
||||
## Seam inventory (where platform branches live)
|
||||
|
||||
| Concern | File | mac | win | other |
|
||||
| Concern | File | mac | win | linux |
|
||||
|---|---|---|---|---|
|
||||
| Window chrome | `src/bun/windows.ts`, `index.ts` | hiddenInset + traffic-light offset, UI drag header | native titlebar, compact header | same as win |
|
||||
| Window chrome (modern skin) | `src/bun/windows.ts`, `index.ts` | 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)` |
|
||||
| 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` |
|
||||
| Editor app picker | `rpc-handlers.ts pickEditorApp` | /Applications, `.app` | %ProgramFiles%, `.exe` | home, any |
|
||||
| Owner/group names | `fs/local.ts resolveId` | `id -nu` + `dscacheutil` | undefined → "—" | `id -nu` + `getent` |
|
||||
| Drive roots | `fs/local.ts listLocalDrives` | empty | `fsutil fsinfo drives` | empty |
|
||||
| chmod/Permissions | context-menu.ts, file-panel, file-actions | full | hidden for LOCAL panels (only flips read-only); SFTP unaffected | full |
|
||||
| Shell wording | `context-menu.ts` | Reveal in Finder / Move to Trash | Show in Explorer / Move to Recycle Bin | Show in File Manager |
|
||||
| chmod/Permissions | context-menu.service.ts, file-panel, file-actions | full | hidden for LOCAL panels (only flips read-only); SFTP unaffected | full |
|
||||
| Shell wording | `ui context-menu.service.ts` | Reveal in Finder / Move to Trash | Show in Explorer / Move to Recycle Bin | Show in File Manager |
|
||||
| Shortcut labels | `ui platform.ts MOD_LABEL/ALT_LABEL` | ⌘ / ⌥ | Ctrl+ / Alt+ | Ctrl+ / Alt+ |
|
||||
| Tray | `src/bun/tray.ts` | 22 pt template PNG | 16 px colored PNG | colored |
|
||||
| App menu | `src/bun/menu.ts` | full menu incl. About | none (early return) — About lives in the options popover | none |
|
||||
| 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) |
|
||||
| connections.json protection | `store/connections.ts` | chmod 0600 | `icacls` current-user ACL (0600 is meaningless) | 0600 |
|
||||
| App data dir | `store/paths.ts` | ~/Library/Application Support | %APPDATA% | $XDG_CONFIG_HOME/.config |
|
||||
| ssh-agent | `fs/sftp.ts` | $SSH_AUTH_SOCK | `\\.\pipe\openssh-ssh-agent` fallback | $SSH_AUTH_SOCK |
|
||||
| Paths in UI | `ui format.ts joinPath/isRootPath` | "/" | separator detected from the dir string; drive-root regex | "/" |
|
||||
|
||||
The **context menu is HTML on every platform** (`ui/src/app/skins/*/context-menu.ts` +
|
||||
`context-menu.service.ts`), not a native electrobun menu — see the Linux section
|
||||
below for why. Only the three shell actions it can't do itself (reveal, open with
|
||||
the default app, copy path) cross to bun, via the `shellItemAction` RPC.
|
||||
|
||||
Remote (SFTP) paths are ALWAYS POSIX regardless of host or server OS
|
||||
(`path.posix` throughout `fs/sftp.ts`).
|
||||
|
||||
### 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)
|
||||
|
||||
Investigated 2026-07 — packaged build. Roughly **~200 MB macOS / ~300 MB
|
||||
Windows** (Linux sits near macOS: WebKitGTK, like WKWebView, is lighter than
|
||||
Chromium), which is normal for a Bun + system-WebView app (30 MB is native /
|
||||
no-web-engine territory; Tauri-class apps run ~80–200 MB, Electron 150–400 MB).
|
||||
Not reducible to tens of MB without dropping the web UI.
|
||||
|
||||
- macOS splits across several "miniSCP" processes: the **Bun main** (~120 MB:
|
||||
backend + the bundled electrobun runtime + live SSH sessions — the largest and
|
||||
least reducible piece), the **`views://app`** WKWebView content process (~60 MB,
|
||||
the Angular UI), and the standard WebKit **Networking** / **Graphics and
|
||||
Media** helpers. All are one app; WebKit just names them per-role.
|
||||
- Windows is higher because **WebView2 is Chromium** (multi-process: browser +
|
||||
GPU + renderer + utility + crashpad), vs macOS's lighter WKWebKit.
|
||||
- Measure against a **packaged** build — `bun run dev` inflates the WebContent
|
||||
heap (ng-serve, unminified, sourcemaps, live-reload) and is not representative.
|
||||
- Verified non-issues: transfers stream (no whole-file buffering), icons
|
||||
tree-shaken to ~33 glyphs, zoneless Angular, 364 KB prod bundle.
|
||||
- Only real growth levers if this ever matters: the file list is **not
|
||||
virtualized** (a directory with tens of thousands of entries retains a DOM row
|
||||
each), and each open tab keeps both panels' full `entries` arrays; terminal
|
||||
transfers are retained until cleared. None affect normal use.
|
||||
|
||||
## Linux runtime dependencies (REQUIRED — the app will not start without them)
|
||||
|
||||
electrobun's `libNativeWrapper.so` links AppIndicator *hard* for the tray, so a
|
||||
missing library is a hard boot failure (`ERR_DLOPEN_FAILED` out of `bun:ffi`
|
||||
`dlopen`), not a degraded tray. The transitive deps (`libdbusmenu-glib`,
|
||||
`libdbusmenu-gtk3`, `libayatana-indicator3`, `libayatana-ido3`) come in as
|
||||
package dependencies:
|
||||
|
||||
| Distro | Install |
|
||||
|---|---|
|
||||
| Arch / CachyOS | `sudo pacman -S libayatana-appindicator` |
|
||||
| Debian / Ubuntu | `sudo apt install libayatana-appindicator3-1` |
|
||||
| Fedora | `sudo dnf install libayatana-appindicator-gtk3` |
|
||||
|
||||
GTK/WebKit itself comes from the distro too — electrobun uses the system webview
|
||||
(`bundleCEF: false`), so `webkit2gtk` must be present.
|
||||
|
||||
Diagnosing a boot failure:
|
||||
|
||||
```sh
|
||||
ldd build/dev-linux-x64/*/bin/libNativeWrapper.so | grep 'not found'
|
||||
```
|
||||
|
||||
`libasar.so => not found` in that output is **expected and harmless** — it ships
|
||||
in the same `bin/` folder and resolves at load time; only an unresolved
|
||||
`libayatana-appindicator3.so.1` is fatal.
|
||||
|
||||
**Tray visibility** is a separate matter from linking: GNOME 3.26+ hides the
|
||||
legacy tray, so the icon needs the
|
||||
[AppIndicator extension](https://extensions.gnome.org/extension/615/appindicator-support/).
|
||||
KDE Plasma, XFCE, MATE and Cinnamon show it out of the box. The app runs fine
|
||||
either way — the tray is a convenience, and every action it offers exists in-app.
|
||||
|
||||
## Linux known issues (upstream electrobun)
|
||||
|
||||
- **No native menus of any kind.** Application menus and `showContextMenu` are
|
||||
both no-ops (electrobun's `dist/api/bun/proc/linux.md`; the app logs
|
||||
"Application menus are not supported on Linux" at boot). The app menubar was
|
||||
already macOS-only, and the panel context menus are now HTML on every platform,
|
||||
so nothing is missing at the UI level — but do not add a feature that depends
|
||||
on a native menu.
|
||||
- **NVIDIA renders black without the DMABUF workaround.** WebKitGTK can't
|
||||
allocate GBM buffers on the proprietary driver, so the window paints nothing
|
||||
while the log looks perfectly healthy. Every launch path sets
|
||||
`WEBKIT_DISABLE_DMABUF_RENDERER=1` when `/proc/driver/nvidia` exists —
|
||||
`scripts/linux-gpu.ts` for dev, the `miniSCP.sh` wrapper in the tarball for
|
||||
releases. The app can't do it itself (Bun's `process.env` writes don't reach
|
||||
child processes); see [gotchas.md](gotchas.md#linux).
|
||||
- **Benign startup noise**: a deprecation warning pointing at
|
||||
`libayatana-appindicator-glib`. Not fatal.
|
||||
- **Unsigned, unpackaged**: the release artifact is a portable tarball — no
|
||||
AppImage, `.deb`, `.desktop` entry or icon installation. Unpack and run
|
||||
`miniSCP.sh` (which execs `bin/launcher`).
|
||||
|
||||
## First-Linux-boot smoke test
|
||||
|
||||
1. Install the AppIndicator package for your distro (table above), then
|
||||
`bun run start`. The window opens **and actually renders the UI** (a black
|
||||
window on NVIDIA means the DMABUF workaround didn't apply — check for
|
||||
`Failed to create GBM buffer` in the log), and the tray icon appears (or
|
||||
doesn't, on bare GNOME — that's the extension caveat, not a failure).
|
||||
2. Right-click a file row: menu appears at the pointer, Send to Other Panel is
|
||||
first, Permissions is present, Escape / click-outside / arrow keys + Enter all
|
||||
work, and a right-click near the bottom-right flips the menu instead of
|
||||
overflowing.
|
||||
3. Right-click empty panel space (New Folder / New File / Refresh / Show in File
|
||||
Manager) and a column header (checkmarks toggle columns live).
|
||||
4. Copy Path puts the path on the clipboard; Show in File Manager opens the
|
||||
distro's file manager; Open (local file) launches the default app via
|
||||
`xdg-open`.
|
||||
5. local↔remote transfers both directions; external editor round-trip
|
||||
(temp download → save → auto-upload) with the inotify watcher.
|
||||
6. Move to Trash lands the item in the desktop's trash; connections.json is 0600.
|
||||
7. `bun scripts/release.ts --target linux-x64` produces
|
||||
`release/linux-x64/miniSCP-Build<N>-linux-x64.tar.gz`; unpack it elsewhere and
|
||||
run `miniSCP.sh`.
|
||||
|
||||
## Windows known issues (upstream electrobun, documented not fixed)
|
||||
|
||||
- **HiDPI blur** — WebView2 renders at 1x on scaled displays
|
||||
@@ -60,8 +191,9 @@ Code paths written blind (no Windows box was available); verify in order:
|
||||
5. External editor: default app opens via `cmd /c start`; the Choose… picker
|
||||
starts in Program Files and accepts an .exe; remote temp-edit → save →
|
||||
auto-upload works (directory watcher on NTFS).
|
||||
6. Context menus: Show in Explorer, Move to Recycle Bin (item restorable from
|
||||
the bin), Copy Path; Permissions ABSENT on local rows, present on remote.
|
||||
6. Context menus: Send to Other Panel is the first row item; Show in Explorer,
|
||||
Move to Recycle Bin (item restorable from the bin), Copy Path; Permissions
|
||||
ABSENT on local rows, present on remote.
|
||||
7. Ctrl+T/W/F/S and Ctrl+digit tab jumps; tooltips read "Ctrl+…".
|
||||
8. connections.json ACL: `icacls connections.json` shows only the current
|
||||
user after saving a connection.
|
||||
|
||||
@@ -7,6 +7,14 @@ export default {
|
||||
identifier: BRANDING.identifier,
|
||||
version: BRANDING.version,
|
||||
},
|
||||
// Auto-updater feed. The flat artifacts land under /dl on the host (see site/);
|
||||
// miniscp.com is fronted by an external proxy for TLS. baseUrl is baked into
|
||||
// version.json at build time, so only builds made after this takes effect check
|
||||
// for updates. generatePatch (default true) needs build-time network to this URL
|
||||
// to diff against the currently-published version.
|
||||
release: {
|
||||
baseUrl: "https://miniscp.com/dl",
|
||||
},
|
||||
build: {
|
||||
bun: {
|
||||
entrypoint: "src/bun/index.ts",
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
"dev:ui": "bun run --cwd ui start",
|
||||
"build:ui": "bun run --cwd ui build",
|
||||
"build": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun build",
|
||||
"start": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun dev",
|
||||
"postinstall": "bun scripts/gen-version.ts",
|
||||
"start": "bun scripts/gen-version.ts && bun run build:ui && bun scripts/start.ts",
|
||||
"postinstall": "bun scripts/gen-version.ts && bun scripts/check-electrobun-resize.ts",
|
||||
"release": "bun scripts/release.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// 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,5 +1,7 @@
|
||||
// Dev orchestrator: starts ng serve (UI hot reload), waits for it, then runs
|
||||
// `electrobun dev --watch` (relaunches the app when bun-side code changes).
|
||||
import { launchEnv } from "./linux-gpu";
|
||||
|
||||
const DEV_SERVER_URL = "http://localhost:4200";
|
||||
|
||||
const ui = Bun.spawn(["bun", "run", "--cwd", "ui", "start"], {
|
||||
@@ -27,7 +29,7 @@ console.log("Angular dev server is up, launching Electrobun ...");
|
||||
const app = Bun.spawn(["bunx", "electrobun", "dev", "--watch"], {
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
env: { ...process.env, DEV_SERVER_URL },
|
||||
env: launchEnv({ DEV_SERVER_URL }),
|
||||
});
|
||||
|
||||
function shutdown() {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// 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");
|
||||
@@ -0,0 +1,33 @@
|
||||
// PORTABILITY: WebKitGTK's DMABUF renderer cannot allocate GBM buffers on the
|
||||
// NVIDIA proprietary driver — the app window paints BLACK and stderr fills with
|
||||
// "Failed to create GBM buffer of size WxH: Invalid argument" (usually with an
|
||||
// "X11 Error: GLXBadWindow" alongside). Disabling that renderer path is the
|
||||
// standard upstream workaround.
|
||||
//
|
||||
// It has to be in the environment BEFORE the app process starts: WebKit reads
|
||||
// it when it forks its web process, and Bun's `process.env` writes do NOT
|
||||
// propagate to children (verified — a child sees the variable unset), so the
|
||||
// app cannot set this for itself from inside src/bun/. Every launch path has to
|
||||
// inject it: the dev scripts here, and the wrapper script that release.ts ships
|
||||
// beside the packaged launcher.
|
||||
//
|
||||
// Scoped to NVIDIA so Intel/AMD keep hardware compositing, and an explicit
|
||||
// value from the user's environment always wins.
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
export function needsDmabufWorkaround(): boolean {
|
||||
return (
|
||||
process.platform === "linux" &&
|
||||
process.env.WEBKIT_DISABLE_DMABUF_RENDERER === undefined &&
|
||||
existsSync("/proc/driver/nvidia")
|
||||
);
|
||||
}
|
||||
|
||||
/** `process.env` plus the workaround, ready to hand to Bun.spawn. */
|
||||
export function launchEnv(extra: Record<string, string> = {}): Record<string, string | undefined> {
|
||||
return {
|
||||
...process.env,
|
||||
...(needsDmabufWorkaround() ? { WEBKIT_DISABLE_DMABUF_RENDERER: "1" } : {}),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// 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");
|
||||
@@ -1,21 +1,22 @@
|
||||
// Build one release artifact for one target, on the matching host:
|
||||
// bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64]
|
||||
// bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64|linux-x64]
|
||||
// Default target = the host. electrobun cannot cross-compile OSes (its
|
||||
// packaging needs host tools: codesign/hdiutil on mac, rcedit/PowerShell on
|
||||
// Windows), so win-x64 must run on a Windows machine. mac-x64 IS buildable on
|
||||
// an arm64 Mac: we fetch electrobun's x64 CLI binary and run it under
|
||||
// Rosetta 2 — it sees an x64 host, pulls the x64 core, and packages mac-x64.
|
||||
// Artifacts land in release/<target>/miniSCP-Build<N>-<target>.<ext>.
|
||||
import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
||||
import { cpSync, existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const ROOT = path.join(import.meta.dir, "..");
|
||||
const TARGETS = ["mac-arm64", "mac-x64", "win-x64"] as const;
|
||||
const TARGETS = ["mac-arm64", "mac-x64", "win-x64", "linux-x64"] as const;
|
||||
type Target = (typeof TARGETS)[number];
|
||||
|
||||
function hostTarget(): Target {
|
||||
if (process.platform === "win32") return "win-x64";
|
||||
if (process.platform === "linux") return "linux-x64";
|
||||
return process.arch === "arm64" ? "mac-arm64" : "mac-x64";
|
||||
}
|
||||
|
||||
@@ -43,6 +44,9 @@ if (!TARGETS.includes(requested)) fail(`unknown target "${requested}" (${TARGETS
|
||||
if (requested === "win-x64" && process.platform !== "win32") {
|
||||
fail("win-x64 must be built on a Windows machine/runner — electrobun cannot cross-compile.");
|
||||
}
|
||||
if (requested === "linux-x64" && process.platform !== "linux") {
|
||||
fail("linux-x64 must be built on a Linux machine/runner — electrobun cannot cross-compile.");
|
||||
}
|
||||
if (requested.startsWith("mac") && process.platform !== "darwin") {
|
||||
fail(`${requested} must be built on macOS.`);
|
||||
}
|
||||
@@ -89,7 +93,12 @@ if (viaRosetta) {
|
||||
|
||||
// ---- collect artifacts ----
|
||||
|
||||
const [osName, archName] = requested === "win-x64" ? ["win", "x64"] : ["macos", requested.slice(4)];
|
||||
const [osName, archName] =
|
||||
requested === "win-x64"
|
||||
? ["win", "x64"]
|
||||
: requested === "linux-x64"
|
||||
? ["linux", "x64"]
|
||||
: ["macos", requested.slice(4)];
|
||||
const prefix = `stable-${osName}-${archName}-`;
|
||||
const artifactsDir = path.join(ROOT, "artifacts");
|
||||
const outDir = path.join(ROOT, "release", requested);
|
||||
@@ -109,15 +118,42 @@ for (const file of produced) {
|
||||
out.push(name);
|
||||
}
|
||||
|
||||
// Windows: also produce a portable zip from the assembled app folder (the
|
||||
// Setup.exe is an installer; portable = unzip anywhere and run). Best-effort:
|
||||
// the folder layout is confirmed by the first real Windows build.
|
||||
if (requested === "win-x64") {
|
||||
// Windows / Linux: package the assembled app folder directly. On Windows that
|
||||
// complements the Setup.exe installer (portable = unzip anywhere and run); on
|
||||
// Linux it is the ONLY artifact, since electrobun emits just the .tar.zst
|
||||
// updater blob there — no installer, AppImage or .deb. Best-effort: the folder
|
||||
// layout is confirmed by the first real build on each OS.
|
||||
if (requested === "win-x64" || requested === "linux-x64") {
|
||||
const appDir = path.join(ROOT, "build", `stable-${osName}-${archName}`, BRANDING.appName);
|
||||
if (existsSync(appDir)) {
|
||||
const portable = path.join(outDir, `${BRANDING.appName}-${buildName}-win-x64-portable.zip`);
|
||||
run(["powershell", "-Command", `Compress-Archive -Force -Path '${appDir}\\*' -DestinationPath '${portable}'`]);
|
||||
out.push(path.basename(portable));
|
||||
if (requested === "win-x64") {
|
||||
const portable = path.join(outDir, `${BRANDING.appName}-${buildName}-win-x64-portable.zip`);
|
||||
run(["powershell", "-Command", `Compress-Archive -Force -Path '${appDir}\\*' -DestinationPath '${portable}'`]);
|
||||
out.push(path.basename(portable));
|
||||
} else {
|
||||
// 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 {
|
||||
console.warn(`[release] WARN: portable skipped — app folder not found at ${appDir}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// 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");
|
||||
@@ -0,0 +1,106 @@
|
||||
// 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}`);
|
||||
@@ -0,0 +1,20 @@
|
||||
// `bun run start`: launch the app against the prebuilt Angular bundle (no ng
|
||||
// serve / hot reload — that's `bun run dev`). Exists as a script rather than a
|
||||
// bare `bunx electrobun dev` in package.json so the Linux GPU workaround can be
|
||||
// injected into the environment before the app process starts.
|
||||
import { launchEnv } from "./linux-gpu";
|
||||
|
||||
const app = Bun.spawn(["bunx", "electrobun", "dev"], {
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
env: launchEnv(),
|
||||
});
|
||||
|
||||
function shutdown(): void {
|
||||
app.kill();
|
||||
process.exit(0);
|
||||
}
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
process.exit(await app.exited);
|
||||
@@ -0,0 +1,34 @@
|
||||
# miniSCP landing page + artifact host.
|
||||
# Local / internal, plain HTTP on :80 (no domain, no TLS).
|
||||
# Deploy target is /srv/miniscp — see site/README.md.
|
||||
|
||||
:80 {
|
||||
root * /srv/miniscp
|
||||
encode zstd gzip
|
||||
|
||||
# Flat artifact feed: these files are BOTH the human downloads AND (later) the
|
||||
# electrobun updater baseUrl. Publishing a build = drop its artifacts/ files here;
|
||||
# stable-named installers overwrite in place, hashed .patch files accumulate.
|
||||
handle /dl/* {
|
||||
# Incremental update patches have unique hashed names → cache forever.
|
||||
@patch path *.patch
|
||||
header @patch Cache-Control "public, max-age=31536000, immutable"
|
||||
|
||||
# The updater feed must always reflect the newest published version.
|
||||
@feed path *update.json
|
||||
header @feed Cache-Control "no-cache"
|
||||
|
||||
# Installers/payloads keep stable names but get overwritten each release.
|
||||
@pkg path *.dmg *.zip *.tar.zst
|
||||
header @pkg Cache-Control "public, max-age=300"
|
||||
|
||||
# browse => directory listing; file_server serves range requests
|
||||
# (resumable large .dmg downloads) by default.
|
||||
file_server browse
|
||||
}
|
||||
|
||||
# Static landing page + assets. No SPA index fallback: unknown paths 404.
|
||||
handle {
|
||||
file_server
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
# miniSCP site
|
||||
|
||||
The all-in-one landing page **and** download host for miniSCP. One static page, one flat
|
||||
download folder, served by [Caddy](https://caddyserver.com). No JS, no build step.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
site/
|
||||
index.html # the landing page (pure static HTML/CSS)
|
||||
Caddyfile # local :80 server config
|
||||
deploy.sh # copy a build's artifacts into the download folder
|
||||
assets/ # images (logo, screenshots) — dropped in as they're ready
|
||||
```
|
||||
|
||||
Deploys to `/srv/miniscp` on the server:
|
||||
|
||||
```
|
||||
/srv/miniscp/
|
||||
index.html
|
||||
assets/
|
||||
dl/ # flat folder of all artifacts (the downloads + future updater feed)
|
||||
```
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
# from the repo root, on the server
|
||||
sudo mkdir -p /srv/miniscp/dl
|
||||
sudo cp site/index.html /srv/miniscp/
|
||||
sudo cp -r site/assets /srv/miniscp/ # once assets exist
|
||||
|
||||
caddy run --config site/Caddyfile --adapter caddyfile
|
||||
# or install as a service: sudo caddy start --config site/Caddyfile
|
||||
```
|
||||
|
||||
Then open `http://<host>/`.
|
||||
|
||||
## Publish a build
|
||||
|
||||
electrobun can't cross-compile, so each OS/arch is built on its own machine. On each build
|
||||
machine, after producing artifacts, drop them into the shared `dl/` folder:
|
||||
|
||||
```bash
|
||||
bun scripts/release.ts # or --target mac-x64 / win-x64
|
||||
site/deploy.sh # -> /srv/miniscp/dl
|
||||
```
|
||||
|
||||
Serving on a different box than you build on? Stage locally, then sync:
|
||||
|
||||
```bash
|
||||
MINISCP_DL_DIR=./_dl site/deploy.sh
|
||||
rsync -av ./_dl/ user@host:/srv/miniscp/dl/
|
||||
```
|
||||
|
||||
That's the whole workflow: **build, drop the artifacts, done.** Installers keep stable names and
|
||||
overwrite in place, so the download links on the page never change.
|
||||
|
||||
> **Don't delete old `*.patch` files.** They're the incremental-update trail for the (upcoming)
|
||||
> built-in updater — removing them forces older installs to re-download the full app.
|
||||
|
||||
## Notes
|
||||
|
||||
- Downloads live under `/dl/` and are browsable at `http://<host>/dl/`.
|
||||
- The page links to fixed, stable filenames (`stable-macos-arm64-miniSCP.dmg`, …) so it stays
|
||||
static and always points at the latest build.
|
||||
- Builds are currently unsigned — the FAQ on the page covers the macOS `xattr` step and the
|
||||
Windows SmartScreen prompt.
|
||||
- `/dl/` is also the intended `release.baseUrl` for the future electrobun auto-updater.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Image assets
|
||||
|
||||
Drop images here as they're ready; the page references them by relative path.
|
||||
|
||||
Planned slots (commented into `../index.html`):
|
||||
|
||||
- `logo.svg` — hero logo above the wordmark (`.logo`, 128×128). Source lives at repo
|
||||
`assets/logo.svg`.
|
||||
- `screenshot.png` — product shot under the intro paragraphs (`.shot`).
|
||||
|
||||
To wire one up, uncomment the matching `<img>` in `index.html`.
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# Publish this machine's built artifacts into the served flat download folder.
|
||||
#
|
||||
# Run from the repo root AFTER building for this host's target, e.g.:
|
||||
# bun scripts/release.ts # or --target mac-x64 / win-x64
|
||||
# site/deploy.sh
|
||||
#
|
||||
# electrobun cannot cross-compile, so each OS/arch is built (and deployed) on its
|
||||
# own machine; every run just adds/overwrites that target's files in one flat dir.
|
||||
# Installers keep stable names (overwrite in place); .patch files accumulate — never
|
||||
# delete old ones, or users on older versions lose their incremental update trail.
|
||||
#
|
||||
# Serving locally? Leave MINISCP_DL_DIR unset (defaults to /srv/miniscp/dl).
|
||||
# Serving on a remote box? Point MINISCP_DL_DIR at a local staging dir, then rsync:
|
||||
# MINISCP_DL_DIR=./_dl site/deploy.sh
|
||||
# rsync -av ./_dl/ user@host:/srv/miniscp/dl/
|
||||
|
||||
dl="${MINISCP_DL_DIR:-/srv/miniscp/dl}"
|
||||
mkdir -p "$dl"
|
||||
|
||||
# electrobun's flat artifacts (stable-<os>-<arch>-*): installers, update payloads,
|
||||
# update.json, and (once release.baseUrl is set) .patch files.
|
||||
if compgen -G 'artifacts/stable-*' >/dev/null; then
|
||||
cp -f artifacts/stable-* "$dl"/
|
||||
else
|
||||
echo "deploy: WARN — no artifacts/stable-* found (did scripts/release.ts run?)" >&2
|
||||
fi
|
||||
|
||||
# Windows portable zip is a release.ts extra (build-numbered) → give it a stable name.
|
||||
port=$(ls -1 release/win-x64/*-win-x64-portable.zip 2>/dev/null | head -n1 || true)
|
||||
if [ -n "$port" ]; then
|
||||
cp -f "$port" "$dl/stable-win-x64-miniSCP-portable.zip"
|
||||
fi
|
||||
|
||||
echo "Published to $dl:"
|
||||
ls -1 "$dl"
|
||||
@@ -0,0 +1,253 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>miniSCP: a dual-pane SFTP file manager</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Lucida Console', Menlo, Consolas, monospace;
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
color: #111;
|
||||
background-color: white;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 0 50px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.landing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
font-size: 64px;
|
||||
font-weight: bold;
|
||||
letter-spacing: -2px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wordmark .lo {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
color: #555;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
/* Image slot for the logo — drop assets/logo.svg in later.
|
||||
<img class="logo" src="assets/logo.svg" alt="miniSCP"> */
|
||||
.logo {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
display: block;
|
||||
margin: 0 auto 10px;
|
||||
}
|
||||
|
||||
.links-container {
|
||||
font-size: 22px;
|
||||
margin: 24px;
|
||||
}
|
||||
|
||||
.links-container a {
|
||||
text-decoration: none;
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
letter-spacing: -9px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
height: 2px;
|
||||
background-color: lightgrey;
|
||||
width: 100%;
|
||||
margin: 40px auto;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.feat {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.feat b {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Screenshot slot — drop assets/screenshot.png in later.
|
||||
<img class="shot" src="assets/screenshot.png" alt="miniSCP screenshot"> */
|
||||
.shot {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin: 20px auto;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.downloads {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dl {
|
||||
flex: 1 1 200px;
|
||||
text-decoration: none;
|
||||
border: 2px solid #111;
|
||||
padding: 14px 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dl:hover {
|
||||
background: #111;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dl small {
|
||||
display: block;
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.dl:hover small {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.browse {
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.faqtable dt {
|
||||
margin-bottom: 0.4em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.faqtable dd {
|
||||
margin-left: 0;
|
||||
margin-bottom: 1.6em;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #f2f2f2;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="landing">
|
||||
<!-- logo image slot goes here later -->
|
||||
<h1 class="wordmark">mini<span class="lo">SCP</span></h1>
|
||||
<p class="tagline">A dual-pane SFTP file manager.<br>Same tool you know, only a little different.</p>
|
||||
<div class="links-container">
|
||||
<a href="#different">what's different</a><span class="separator">|</span>
|
||||
<a href="#download">download</a><span class="separator">|</span>
|
||||
<a href="#faq">FAQ</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h2 id="different">Same tool you know, only a little different</h2>
|
||||
<p>If you've ever used a two-pane file manager to move files over SFTP, you already know how to
|
||||
use miniSCP. Two panels, a connection on each side, send between them. Nothing to relearn.</p>
|
||||
|
||||
<p>The difference is in the corners. Both panels are fully symmetric — either side can be
|
||||
your local disk or any saved server — so sending isn't only up or down. You can move files
|
||||
<b>straight from one server to another</b>, remote to remote, without staging them on your
|
||||
machine first.
|
||||
</p>
|
||||
|
||||
<p>It's the same familiar tool, sanded down where the old ones snag.</p>
|
||||
|
||||
<!-- screenshot slot goes here later -->
|
||||
|
||||
<p><b>What's different:</b></p>
|
||||
|
||||
<p class="feat"><b>Remote → remote.</b> Send straight between two servers — nothing
|
||||
staged on your machine.</p>
|
||||
<p class="feat"><b>Symmetric panels.</b> Either side is your local disk or any saved server. Every
|
||||
panel is equal.</p>
|
||||
<p class="feat"><b>Knows your servers already.</b> Reads the hosts from your
|
||||
<code>~/.ssh/config</code>, and imports the saved sessions from your previous client.
|
||||
</p>
|
||||
<p class="feat"><b>Themes.</b> Light, dark, or follow the system — with a set of accent
|
||||
tones.</p>
|
||||
<p class="feat"><b>macOS and Windows.</b> macOS is the primary target; Windows 11+ (x64) is in
|
||||
beta.</p>
|
||||
|
||||
|
||||
<p><b>What's the same:</b></p>
|
||||
|
||||
<p class="feat"><b>Tabs that keep working.</b> Open several sessions at once; transfers keep
|
||||
running in background tabs.</p>
|
||||
<p class="feat"><b>Edit in place.</b> Open a remote file in your editor — every save uploads
|
||||
itself, automatically, until you disconnect.</p>
|
||||
<p class="feat"><b>Won't eat your files.</b> A multi-layer guard catches when the source and
|
||||
destination are secretly the same file, even behind NAT or split-DNS, before it can truncate
|
||||
anything.</p>
|
||||
<p class="feat"><b>Honest overwrites.</b> Compares size and date, tells you which copy is newer,
|
||||
and lets you replace-all or skip-all in one go.</p>
|
||||
<p class="feat"><b>A queue with an ETA.</b> Per-file speed and estimate, cancel anytime.</p>
|
||||
<p class="feat"><b>Permissions.</b> Rename, delete, create folders and files, and change
|
||||
permissions (chmod) on remote files.</p>
|
||||
|
||||
<hr>
|
||||
<h2 id="download">Download</h2>
|
||||
<p>Always the latest stable build.</p>
|
||||
<div class="downloads">
|
||||
<a class="dl" href="/dl/stable-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/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>
|
||||
</div>
|
||||
<p class="browse">Looking for everything? <a href="/dl/">browse all files</a>.</p>
|
||||
|
||||
<hr>
|
||||
<h2 id="faq">FAQ</h2>
|
||||
<dl class="faqtable">
|
||||
<dt>Which platforms are supported?</dt>
|
||||
<dd>macOS (Apple Silicon and Intel) and Windows 11+ (x64). macOS is the primary target;
|
||||
Windows is currently in beta.</dd>
|
||||
|
||||
<dt>macOS says the app is "damaged and can't be opened".</dt>
|
||||
<dd>The builds aren't code-signed yet, so macOS quarantines them on download. Clear the
|
||||
quarantine flag once and it opens normally:
|
||||
<br><code>xattr -cr /Applications/miniSCP.app</code>
|
||||
</dd>
|
||||
|
||||
<dt>Windows warns me with a blue SmartScreen box.</dt>
|
||||
<dd>The Windows builds aren't signed yet either, so SmartScreen flags them on first run. Choose
|
||||
"More info" → "Run anyway". The installer unpacks to your local app data; the portable
|
||||
zip runs from anywhere.</dd>
|
||||
|
||||
<dt>Can I bring my existing servers over?</dt>
|
||||
<dd>Yes. miniSCP reads the hosts from your <code>~/.ssh/config</code> automatically, and it can
|
||||
import the saved sessions exported from your previous file-transfer client.</dd>
|
||||
|
||||
<dt>Installer or portable on Windows?</dt>
|
||||
<dd>The installer sets miniSCP up in your user profile with a desktop shortcut. The portable zip
|
||||
is just the app — unzip it anywhere and run it, no install.</dd>
|
||||
</dl>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,134 +0,0 @@
|
||||
import { ContextMenu, Utils, type ApplicationMenuItemConfig } from "electrobun/bun";
|
||||
import type { ColumnKey } from "../shared/types";
|
||||
|
||||
/** Optional list columns, in display order (Name is fixed). */
|
||||
const COLUMNS: { key: ColumnKey; label: string }[] = [
|
||||
{ key: "size", label: "Size" },
|
||||
{ key: "mtime", label: "Modified" },
|
||||
{ key: "kind", label: "Kind" },
|
||||
{ key: "rights", label: "Rights" },
|
||||
{ key: "owner", label: "Owner" },
|
||||
{ key: "group", label: "Group" },
|
||||
];
|
||||
|
||||
// PORTABILITY: shell-integration wording per OS
|
||||
const IS_WIN = process.platform === "win32";
|
||||
const REVEAL_LABEL =
|
||||
process.platform === "darwin"
|
||||
? "Reveal in Finder"
|
||||
: IS_WIN
|
||||
? "Show in Explorer"
|
||||
: "Show in File Manager";
|
||||
const TRASH_LABEL = IS_WIN ? "Move to Recycle Bin" : "Move to Trash";
|
||||
|
||||
export interface RowMenuContext {
|
||||
path: string;
|
||||
name: string;
|
||||
type: "file" | "dir" | "symlink";
|
||||
isLocal: boolean;
|
||||
hasEntry: boolean; // false = right-click on empty panel space
|
||||
}
|
||||
|
||||
/**
|
||||
* Native right-click menus for the file panels. Local-only items run directly
|
||||
* here via Utils (reveal, open with default app, copy path); everything else
|
||||
* forwards to the webview through the menuAction channel — the UI kept its own
|
||||
* context (which panel/row was clicked) when it requested the menu.
|
||||
*/
|
||||
export class PanelContextMenu {
|
||||
private context: RowMenuContext | null = null;
|
||||
/** Forwards actions the UI must handle (ctx-open, ctx-delete, ...). */
|
||||
onAction: (action: string) => void = () => {};
|
||||
|
||||
constructor() {
|
||||
ContextMenu.on("context-menu-clicked", (event) => {
|
||||
const action = (event as { data?: { action?: string } }).data?.action;
|
||||
if (!action) return;
|
||||
// column toggles carry no row context
|
||||
if (action.startsWith("col:")) {
|
||||
this.onAction(action);
|
||||
return;
|
||||
}
|
||||
const ctx = this.context;
|
||||
if (!ctx) return;
|
||||
switch (action) {
|
||||
case "ctx-reveal":
|
||||
Utils.showItemInFolder(ctx.path);
|
||||
break;
|
||||
case "ctx-open-default":
|
||||
Utils.openPath(ctx.path);
|
||||
break;
|
||||
case "ctx-copy-path":
|
||||
Utils.clipboardWriteText(ctx.path);
|
||||
break;
|
||||
default:
|
||||
if (action.startsWith("ctx-")) this.onAction(action);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
show(context: RowMenuContext): void {
|
||||
this.context = context;
|
||||
ContextMenu.showContextMenu(this.build(context));
|
||||
}
|
||||
|
||||
/** Column picker for the list headers: checkable toggles per column. */
|
||||
showColumns(visible: ColumnKey[]): void {
|
||||
ContextMenu.showContextMenu(
|
||||
COLUMNS.map(({ key, label }) => ({
|
||||
label,
|
||||
action: `col:${key}`,
|
||||
checked: visible.includes(key),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
private build(ctx: RowMenuContext): ApplicationMenuItemConfig[] {
|
||||
const items: ApplicationMenuItemConfig[] = [];
|
||||
|
||||
if (!ctx.hasEntry || ctx.name === "..") {
|
||||
// empty space (or the parent row): folder-level actions
|
||||
items.push({ label: "New Folder", action: "ctx-new-folder" });
|
||||
items.push({ label: "New File", action: "ctx-new-file" });
|
||||
items.push({ type: "separator" });
|
||||
items.push({ label: "Refresh", action: "ctx-refresh" });
|
||||
if (ctx.isLocal) {
|
||||
items.push({ type: "separator" });
|
||||
items.push({ label: REVEAL_LABEL, action: "ctx-reveal" });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
if (ctx.type === "dir") {
|
||||
items.push({ label: "Open", action: "ctx-open" });
|
||||
} else {
|
||||
if (ctx.isLocal) {
|
||||
items.push({ label: "Open", action: "ctx-open-default", tooltip: "Open with the default application" });
|
||||
}
|
||||
items.push({ label: "Edit", action: "ctx-edit", accelerator: "e" });
|
||||
}
|
||||
items.push({ label: "Copy to Other Panel", action: "ctx-copy" });
|
||||
items.push({ label: "Rename", action: "ctx-rename" });
|
||||
// chmod on Windows local files only flips the read-only bit — hide the lie
|
||||
if (!(ctx.isLocal && IS_WIN)) {
|
||||
items.push({ label: "Permissions", action: "ctx-chmod" });
|
||||
}
|
||||
items.push({ type: "separator" });
|
||||
items.push({ label: "Copy Path", action: "ctx-copy-path" });
|
||||
if (ctx.isLocal) {
|
||||
items.push({ label: REVEAL_LABEL, action: "ctx-reveal" });
|
||||
}
|
||||
items.push({ type: "separator" });
|
||||
if (ctx.isLocal) {
|
||||
items.push({
|
||||
label: TRASH_LABEL,
|
||||
action: "ctx-trash",
|
||||
...(process.platform === "darwin"
|
||||
? { tooltip: "Finder's Restore is not available for items trashed from here" }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
items.push({ label: "Delete", action: "ctx-delete" });
|
||||
return items;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from "../i18n";
|
||||
import { LocalProvider } from "./local";
|
||||
import type { FileSystemProvider } from "./provider";
|
||||
|
||||
@@ -15,7 +16,7 @@ export class SessionRegistry {
|
||||
|
||||
get(sessionId: string): FileSystemProvider {
|
||||
const provider = this.sessions.get(sessionId);
|
||||
if (!provider) throw new Error(`Session "${sessionId}" is not connected`);
|
||||
if (!provider) throw new Error(t("errors.sessionNotConnected", { id: sessionId }));
|
||||
return provider;
|
||||
}
|
||||
|
||||
@@ -27,7 +28,7 @@ export class SessionRegistry {
|
||||
|
||||
async remove(sessionId: string): Promise<void> {
|
||||
if (sessionId === LOCAL_SESSION_ID)
|
||||
throw new Error("The local session cannot be disconnected");
|
||||
throw new Error(t("errors.localCannotDisconnect"));
|
||||
const provider = this.sessions.get(sessionId);
|
||||
if (!provider) return;
|
||||
this.sessions.delete(sessionId);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Client, type ConnectConfig, type SFTPWrapper } from "ssh2";
|
||||
import type { ConnectionConfig, EntryType, FileEntry } from "../../shared/types";
|
||||
import { t } from "../i18n";
|
||||
import { expandTilde } from "../store/ssh-config";
|
||||
import type { FileSystemProvider } from "./provider";
|
||||
|
||||
@@ -63,7 +64,7 @@ export class SftpProvider implements FileSystemProvider {
|
||||
connectConfig.password = config.auth.password;
|
||||
break;
|
||||
case "agent":
|
||||
if (!agentSock) throw new Error("ssh-agent not available (SSH_AUTH_SOCK is not set)");
|
||||
if (!agentSock) throw new Error(t("errors.sshAgentUnavailable"));
|
||||
connectConfig.agent = agentSock;
|
||||
break;
|
||||
case "key": {
|
||||
@@ -73,7 +74,7 @@ export class SftpProvider implements FileSystemProvider {
|
||||
connectConfig.privateKey = readFileSync(expandTilde(config.auth.privateKeyPath));
|
||||
} catch {
|
||||
if (!agentSock)
|
||||
throw new Error(`identity file not found: ${config.auth.privateKeyPath}`);
|
||||
throw new Error(t("errors.identityFileNotFound", { path: config.auth.privateKeyPath }));
|
||||
}
|
||||
if (config.auth.passphrase) connectConfig.passphrase = config.auth.passphrase;
|
||||
else if (agentSock) connectConfig.agent = agentSock;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { type FSWatcher, watch } from "node:fs";
|
||||
|
||||
const DEBOUNCE_MS = 150;
|
||||
|
||||
/**
|
||||
* Watches a set of local directories with node's fs.watch and fires onChange
|
||||
* (debounced per dir — fs.watch emits bursts) when their contents change. Used
|
||||
* to auto-refresh the active tab's local panels instantly; SFTP has no
|
||||
* equivalent, so remote panels are polled UI-side instead.
|
||||
*
|
||||
* onChange is late-bound (assigned after the RPC exists, like AppDeps.events)
|
||||
* so it can push pathRefresh to the main webview.
|
||||
*/
|
||||
export class LocalDirWatcher {
|
||||
onChange: (dir: string) => void = () => {};
|
||||
private readonly handles = new Map<string, FSWatcher>();
|
||||
private readonly debounces = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
/** Replace the watched set: drop handles no longer wanted, open new ones. */
|
||||
setWatched(dirs: string[]): void {
|
||||
const wanted = new Set(dirs);
|
||||
for (const [dir, handle] of this.handles) {
|
||||
if (!wanted.has(dir)) {
|
||||
handle.close();
|
||||
this.handles.delete(dir);
|
||||
}
|
||||
}
|
||||
for (const dir of wanted) {
|
||||
if (this.handles.has(dir)) continue;
|
||||
try {
|
||||
const handle = watch(dir, () => this.fire(dir));
|
||||
// a watched dir that gets deleted/unmounted errors — just drop it
|
||||
handle.on("error", () => this.drop(dir));
|
||||
this.handles.set(dir, handle);
|
||||
} catch {
|
||||
// dir vanished between listing and watching — ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fire(dir: string): void {
|
||||
const pending = this.debounces.get(dir);
|
||||
if (pending) clearTimeout(pending);
|
||||
this.debounces.set(
|
||||
dir,
|
||||
setTimeout(() => {
|
||||
this.debounces.delete(dir);
|
||||
this.onChange(dir);
|
||||
}, DEBOUNCE_MS),
|
||||
);
|
||||
}
|
||||
|
||||
private drop(dir: string): void {
|
||||
this.handles.get(dir)?.close();
|
||||
this.handles.delete(dir);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const handle of this.handles.values()) handle.close();
|
||||
this.handles.clear();
|
||||
for (const pending of this.debounces.values()) clearTimeout(pending);
|
||||
this.debounces.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { DICTS, type Messages, resolveLocale } from "../shared/i18n";
|
||||
import type { AppSettings, SupportedLocale } from "../shared/types";
|
||||
|
||||
// Backend-side translator for the native menus and thrown error messages
|
||||
// (the Angular I18nService can't reach OS chrome). The locale is cached and
|
||||
// refreshed from settings (see index.ts wiring); no signals here.
|
||||
let locale: SupportedLocale = "en";
|
||||
|
||||
/** OS locale for the "system" setting, e.g. "es-ES" -> resolves to "es". */
|
||||
function osLocale(): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat().resolvedOptions().locale;
|
||||
} catch {
|
||||
return "en";
|
||||
}
|
||||
}
|
||||
|
||||
export function setLocale(setting: AppSettings["locale"]): void {
|
||||
locale = resolveLocale(setting, osLocale());
|
||||
}
|
||||
|
||||
function lookup(dict: Messages, key: string): string | undefined {
|
||||
let node: string | Messages | undefined = dict;
|
||||
for (const part of key.split(".")) {
|
||||
if (typeof node !== "object") return undefined;
|
||||
node = node[part];
|
||||
}
|
||||
return typeof node === "string" ? node : undefined;
|
||||
}
|
||||
|
||||
/** Translate a key with {name} interpolation; falls back to English then the key. */
|
||||
export function t(key: string, params?: Record<string, string | number>): string {
|
||||
const value = lookup(DICTS[locale], key) ?? lookup(DICTS.en, key) ?? key;
|
||||
if (!params) return value;
|
||||
return value.replace(/\{(\w+)\}/g, (_, name: string) =>
|
||||
name in params ? String(params[name]) : `{${name}}`,
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,12 @@ import Electrobun, { ApplicationMenu, BrowserWindow, Updater, Utils } from "elec
|
||||
import { BRANDING } from "../shared/branding";
|
||||
import type { EditorOpenContext } from "../shared/rpc";
|
||||
import { effectiveConcurrency, type AppSettings } from "../shared/types";
|
||||
import { PanelContextMenu } from "./context-menu";
|
||||
import { EditManager } from "./edits";
|
||||
import { EditorWindows } from "./editor-windows";
|
||||
import { SessionRegistry } from "./fs/registry";
|
||||
import { LocalDirWatcher } from "./fs/watcher";
|
||||
import { ManagerWindow } from "./manager-window";
|
||||
import { setLocale as setBackendLocale } from "./i18n";
|
||||
import { applyApplicationMenu } from "./menu";
|
||||
import { createAppRPC } from "./rpc-handlers";
|
||||
import { ConnectionStore } from "./store/connections";
|
||||
@@ -14,7 +15,13 @@ import { migrateLegacyDataDir } from "./store/paths";
|
||||
import { SettingsStore } from "./store/settings";
|
||||
import { AppTray } from "./tray";
|
||||
import { TransferEngine } from "./transfers/engine";
|
||||
import { buildPreload, showWhenReady } from "./windows";
|
||||
import {
|
||||
buildPreload,
|
||||
chromeOptions,
|
||||
initSessionSkin,
|
||||
showWhenReady,
|
||||
trackMaximizeState,
|
||||
} from "./windows";
|
||||
|
||||
const PROD_URL = "views://app/index.html";
|
||||
|
||||
@@ -44,7 +51,7 @@ const engine = new TransferEngine(registry);
|
||||
const store = new ConnectionStore();
|
||||
const settings = new SettingsStore();
|
||||
const edits = new EditManager(registry, engine, settings);
|
||||
const contextMenu = new PanelContextMenu();
|
||||
const watcher = new LocalDirWatcher();
|
||||
const events = {
|
||||
sessionClosed: (_info: { sessionId: string; reason: string }) => {},
|
||||
connectionsChanged: () => {},
|
||||
@@ -53,15 +60,33 @@ const events = {
|
||||
openConnectionManager: () => {},
|
||||
showAbout: () => {},
|
||||
};
|
||||
const rpc = createAppRPC({ registry, engine, store, settings, edits, contextMenu, events });
|
||||
// Late-bound for the same reason events is: `win` below takes this rpc in its
|
||||
// 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
|
||||
watcher.onChange = (dir) => rpc.send.pathRefresh({ sessionId: "local", dir });
|
||||
const url = await getMainViewUrl();
|
||||
|
||||
// Loaded before any window exists: the persisted theme is baked into every
|
||||
// window's preload so the first paint is already themed (no flash). Kept
|
||||
// fresh via the saveSettings flow below.
|
||||
let currentSettings = await settings.load();
|
||||
|
||||
const isMac = process.platform === "darwin";
|
||||
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({
|
||||
title: BRANDING.appName,
|
||||
@@ -72,12 +97,9 @@ const win = new BrowserWindow({
|
||||
x: 120,
|
||||
y: 120,
|
||||
},
|
||||
// macOS: fuse the tab bar with the titlebar — traffic lights overlay the
|
||||
// UI's h-10 tab row (offset centers them vertically). Other platforms keep
|
||||
// the native titlebar and the tab bar stays a separate row.
|
||||
...(isMac
|
||||
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
||||
: {}),
|
||||
// Frame decision lives in chromeOptions — see windows.ts. Constructor-only,
|
||||
// and read from the pinned session skin, not from currentSettings.
|
||||
...chromeOptions(),
|
||||
// lets the UI pick the fused layout + theme before first paint
|
||||
preload: buildPreload("main", currentSettings),
|
||||
rpc,
|
||||
@@ -85,6 +107,8 @@ const win = new BrowserWindow({
|
||||
hidden: true,
|
||||
});
|
||||
showWhenReady(win);
|
||||
mainWin = win;
|
||||
trackMaximizeState(win, (maximized) => rpc.send.windowState({ maximized }));
|
||||
|
||||
const editors = new EditorWindows({
|
||||
registry,
|
||||
@@ -111,7 +135,12 @@ const manager = new ManagerWindow({
|
||||
|
||||
events.openEditor = (ctx) => editors.open(ctx);
|
||||
events.settingsChanged = (s) => {
|
||||
const localeChanged = s.locale !== currentSettings.locale;
|
||||
currentSettings = s; // future windows' preloads bake the fresh theme
|
||||
if (localeChanged) {
|
||||
setBackendLocale(s.locale);
|
||||
void applyApplicationMenu(store); // relabel the native menu in the new language
|
||||
}
|
||||
editors.broadcastSettings(s);
|
||||
manager.broadcastSettings(s);
|
||||
};
|
||||
@@ -138,7 +167,6 @@ win.on("close", () => {
|
||||
});
|
||||
|
||||
new AppTray(win);
|
||||
contextMenu.onAction = (action) => rpc.send.menuAction({ action });
|
||||
|
||||
// notify on finished transfers while the window is in the background
|
||||
let windowFocused = true;
|
||||
@@ -234,9 +262,37 @@ ApplicationMenu.on("application-menu-clicked", (event) => {
|
||||
showAbout();
|
||||
return;
|
||||
}
|
||||
if (action === "quit") {
|
||||
// Utils.quit() emits before-quit -> the guard runs (transfers/unsaved).
|
||||
// Handled before the manager check so ⌘Q quits even with it open.
|
||||
Utils.quit();
|
||||
return;
|
||||
}
|
||||
// the manager is modal to the main window — don't drive main behind it
|
||||
if (manager.isOpen()) return;
|
||||
rpc.send.menuAction({ action });
|
||||
});
|
||||
|
||||
// ---- auto-updater: check on launch + every 6h, stream status to the tab bar ----
|
||||
// Updater.checkForUpdate() self-guards the dev channel (returns updateAvailable:
|
||||
// false), so the pill never appears during `bun run dev`.
|
||||
Updater.onStatusChange((entry) => {
|
||||
if (entry.status === "download-progress") {
|
||||
rpc.send.updateStatus({ phase: "downloading", progress: entry.details?.progress ?? 0 });
|
||||
} else if (entry.status === "error") {
|
||||
rpc.send.updateStatus({ phase: "error", error: entry.message });
|
||||
}
|
||||
});
|
||||
|
||||
async function runUpdateCheck(): Promise<void> {
|
||||
try {
|
||||
const r = await Updater.checkForUpdate();
|
||||
if (r.updateAvailable) rpc.send.updateStatus({ phase: "available", version: r.version });
|
||||
} catch {
|
||||
// offline / feed unreachable — stay quiet and retry on the next tick
|
||||
}
|
||||
}
|
||||
setTimeout(() => void runUpdateCheck(), 5_000); // let the window settle first
|
||||
setInterval(() => void runUpdateCheck(), 6 * 60 * 60 * 1000);
|
||||
|
||||
console.log(`${BRANDING.appName} started (${url})`);
|
||||
|
||||
@@ -70,6 +70,8 @@ export class ManagerWindow {
|
||||
this.close();
|
||||
},
|
||||
onClose: () => this.close(),
|
||||
// late-bound: the window takes this rpc in its constructor
|
||||
window: () => this.win ?? undefined,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/bun";
|
||||
import { BRANDING } from "../shared/branding";
|
||||
import type { SavedConnection } from "../shared/types";
|
||||
import { t } from "./i18n";
|
||||
import type { ConnectionStore } from "./store/connections";
|
||||
|
||||
/**
|
||||
@@ -27,37 +28,42 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
|
||||
...looseItems,
|
||||
...stackMenus,
|
||||
...(looseItems.length || stackMenus.length ? [{ type: "separator" } as const] : []),
|
||||
{ label: "Connection Manager…", action: "manage-connections" },
|
||||
{ label: t("menu.connectionManager"), action: "manage-connections" },
|
||||
];
|
||||
|
||||
ApplicationMenu.setApplicationMenu([
|
||||
{
|
||||
submenu: [
|
||||
{ label: `About ${BRANDING.appName}`, action: "about" },
|
||||
{ label: t("menu.about", { app: BRANDING.appName }), action: "about" },
|
||||
{ type: "separator" },
|
||||
{ label: t("menu.checkForUpdates"), action: "check-for-updates" },
|
||||
{ type: "separator" },
|
||||
{ role: "hide" },
|
||||
{ role: "hideOthers" },
|
||||
{ role: "showAll" },
|
||||
{ type: "separator" },
|
||||
{ label: "Quit", role: "quit" },
|
||||
// Custom action (not role:"quit") + explicit ⌘Q: routes through
|
||||
// Utils.quit() so the before-quit guard (active transfers / unsaved
|
||||
// editors) always runs instead of the OS terminating directly.
|
||||
{ label: t("menu.quit"), action: "quit", accelerator: "q" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "File",
|
||||
label: t("menu.file"),
|
||||
submenu: [
|
||||
{ label: "New Tab", action: "new-tab", accelerator: "t" },
|
||||
{ label: "Close Tab", action: "close-tab", accelerator: "w" },
|
||||
{ label: t("menu.newTab"), action: "new-tab", accelerator: "t" },
|
||||
{ label: t("menu.closeTab"), action: "close-tab", accelerator: "w" },
|
||||
{ type: "separator" },
|
||||
{ label: "New Folder", action: "new-folder" },
|
||||
{ label: "New File", action: "new-file" },
|
||||
{ label: t("menu.newFolder"), action: "new-folder" },
|
||||
{ label: t("menu.newFile"), action: "new-file" },
|
||||
{ type: "separator" },
|
||||
{ label: "Edit File", action: "edit-file", accelerator: "e" },
|
||||
{ label: t("menu.editFile"), action: "edit-file", accelerator: "e" },
|
||||
{ type: "separator" },
|
||||
{ label: "Connect", submenu: connectSubmenu },
|
||||
{ label: t("menu.connect"), submenu: connectSubmenu },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
label: t("menu.edit"),
|
||||
submenu: [
|
||||
{ role: "undo" },
|
||||
{ role: "redo" },
|
||||
@@ -71,15 +77,15 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "View",
|
||||
label: t("menu.view"),
|
||||
submenu: [
|
||||
{ label: "Refresh", action: "refresh-active", accelerator: "r" },
|
||||
{ label: t("menu.refresh"), action: "refresh-active", accelerator: "r" },
|
||||
{ type: "separator" },
|
||||
{ role: "toggleFullScreen" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Window",
|
||||
label: t("menu.window"),
|
||||
submenu: [
|
||||
{ role: "minimize" },
|
||||
{ role: "zoom" },
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { BrowserView, Utils } from "electrobun/bun";
|
||||
import { BrowserView, type BrowserWindow, Updater, Utils } from "electrobun/bun";
|
||||
import type { AppRPC, EditorOpenContext } from "../shared/rpc";
|
||||
import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types";
|
||||
import type { PanelContextMenu } from "./context-menu";
|
||||
import { readFileText, writeFileText, type EditManager } from "./edits";
|
||||
import { listLocalDrives } from "./fs/local";
|
||||
import type { SessionRegistry } from "./fs/registry";
|
||||
import type { LocalDirWatcher } from "./fs/watcher";
|
||||
import { SftpProvider } from "./fs/sftp";
|
||||
import type { ConnectionStore } from "./store/connections";
|
||||
import type { SettingsStore } from "./store/settings";
|
||||
@@ -23,7 +23,13 @@ export interface AppDeps {
|
||||
store: ConnectionStore;
|
||||
settings: SettingsStore;
|
||||
edits: EditManager;
|
||||
contextMenu: PanelContextMenu;
|
||||
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). */
|
||||
events: {
|
||||
sessionClosed: (info: { sessionId: string; reason: string }) => void;
|
||||
@@ -43,6 +49,78 @@ 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. */
|
||||
function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void) {
|
||||
return {
|
||||
@@ -120,7 +198,8 @@ export function createAppRPC({
|
||||
store,
|
||||
settings,
|
||||
edits,
|
||||
contextMenu,
|
||||
watcher,
|
||||
mainWindow,
|
||||
events,
|
||||
}: AppDeps) {
|
||||
return BrowserView.defineRPC<AppRPC>({
|
||||
@@ -160,6 +239,12 @@ export function createAppRPC({
|
||||
return { path: dir, entries: await fs.list(dir) };
|
||||
}),
|
||||
|
||||
setWatchedDirs: ({ dirs }) =>
|
||||
wrap(async () => {
|
||||
watcher.setWatched(dirs);
|
||||
return {};
|
||||
}),
|
||||
|
||||
mkdir: ({ sessionId, path }) =>
|
||||
wrap(async () => {
|
||||
await registry.get(sessionId).mkdir(path);
|
||||
@@ -212,6 +297,9 @@ export function createAppRPC({
|
||||
return {};
|
||||
}),
|
||||
|
||||
// ---- custom window chrome (win7 skin) ----
|
||||
...windowControlHandlers(mainWindow),
|
||||
|
||||
// ---- connection store ----
|
||||
...connectionStoreHandlers(store, () => events.connectionsChanged()),
|
||||
|
||||
@@ -273,10 +361,12 @@ export function createAppRPC({
|
||||
return {};
|
||||
}),
|
||||
|
||||
// ---- context menu + local shell integration ----
|
||||
showRowMenu: (params) =>
|
||||
// ---- local shell integration (menu itself is HTML, see ui/) ----
|
||||
shellItemAction: ({ action, path }) =>
|
||||
wrap(async () => {
|
||||
contextMenu.show(params);
|
||||
if (action === "reveal") Utils.showItemInFolder(path);
|
||||
else if (action === "open") Utils.openPath(path);
|
||||
else Utils.clipboardWriteText(path);
|
||||
return {};
|
||||
}),
|
||||
|
||||
@@ -286,12 +376,6 @@ export function createAppRPC({
|
||||
return {};
|
||||
}),
|
||||
|
||||
showColumnsMenu: ({ visible }) =>
|
||||
wrap(async () => {
|
||||
contextMenu.showColumns(visible);
|
||||
return {};
|
||||
}),
|
||||
|
||||
// ---- editor windows ----
|
||||
openEditor: (ctx) =>
|
||||
wrap(async () => {
|
||||
@@ -313,6 +397,25 @@ export function createAppRPC({
|
||||
}),
|
||||
|
||||
listDrives: () => wrap(async () => ({ drives: listLocalDrives() })),
|
||||
|
||||
// ---- auto-updater ----
|
||||
checkForUpdate: () =>
|
||||
wrap(async () => {
|
||||
const r = await Updater.checkForUpdate();
|
||||
return { updateAvailable: r.updateAvailable, version: r.version };
|
||||
}),
|
||||
|
||||
downloadUpdate: () =>
|
||||
wrap(async () => {
|
||||
await Updater.downloadUpdate();
|
||||
return {};
|
||||
}),
|
||||
|
||||
applyUpdate: () =>
|
||||
wrap(async () => {
|
||||
await Updater.applyUpdate(); // relaunches + quits; may never resolve
|
||||
return {};
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -326,6 +429,8 @@ export interface EditorRpcHooks {
|
||||
onSaved: () => void;
|
||||
/** Webview resolved its dirty prompt: close without the native fallback. */
|
||||
onCloseConfirmed: () => void;
|
||||
/** This window, for the win7 title-bar controls (late-bound like the rest). */
|
||||
window: () => BrowserWindow | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -361,6 +466,12 @@ export function createEditorRPC(
|
||||
hooks.onCloseConfirmed();
|
||||
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: {
|
||||
editorState: (state) => hooks.onDraft(state),
|
||||
@@ -376,6 +487,8 @@ export interface ManagerRpcHooks {
|
||||
onConnect: (id: string) => void;
|
||||
onOpenStack: (id: string) => void;
|
||||
onClose: () => void;
|
||||
/** This window, for the win7 title-bar controls (late-bound like the rest). */
|
||||
window: () => BrowserWindow | undefined;
|
||||
}
|
||||
|
||||
/** RPC for the single connection-manager window (per-window instance). */
|
||||
@@ -408,6 +521,10 @@ export function createManagerRPC(
|
||||
hooks.onClose();
|
||||
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()),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { DEFAULT_SETTINGS, type AppSettings } from "../../shared/types";
|
||||
import { clampUiScale, DEFAULT_SETTINGS, type AppSettings } from "../../shared/types";
|
||||
import { appDataDir } from "./paths";
|
||||
|
||||
/** The retired 3-tier axis, mapped onto the scale that replaced it. */
|
||||
const LEGACY_TEXT_SIZE: Record<string, number> = { compact: 0.9, default: 1, large: 1.2 };
|
||||
|
||||
/** App settings persisted bun-side (WKWebView localStorage on views:// is unreliable). */
|
||||
export class SettingsStore {
|
||||
private readonly file = path.join(appDataDir(), "settings.json");
|
||||
@@ -10,7 +13,18 @@ export class SettingsStore {
|
||||
async load(): Promise<AppSettings> {
|
||||
try {
|
||||
const data = JSON.parse(await fs.readFile(this.file, "utf8"));
|
||||
return { ...DEFAULT_SETTINGS, ...data };
|
||||
const settings: AppSettings = { ...DEFAULT_SETTINGS, ...data };
|
||||
// migrate the retired textSize tier into the scale that replaced it —
|
||||
// but never over an explicit uiScale (a file carrying both has already
|
||||
// been migrated once and then adjusted)
|
||||
if (data.uiScale === undefined && typeof data.textSize === "string") {
|
||||
settings.uiScale = LEGACY_TEXT_SIZE[data.textSize] ?? 1;
|
||||
}
|
||||
// clamp ALWAYS, not just on migration: buildPreload writes this value
|
||||
// into every window's root font-size, so a hand-edited settings.json
|
||||
// (uiScale: 100, "2", NaN) must not be able to render the UI unusable
|
||||
settings.uiScale = clampUiScale(Number(settings.uiScale));
|
||||
return settings;
|
||||
} catch {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BRANDING } from "../../shared/branding";
|
||||
import type { EntryType, TransferSnapshot, TransferState } from "../../shared/types";
|
||||
import type { FileSystemProvider } from "../fs/provider";
|
||||
import type { SessionRegistry } from "../fs/registry";
|
||||
import { t } from "../i18n";
|
||||
import { walkTree } from "./walk";
|
||||
|
||||
const PROGRESS_INTERVAL_MS = 100;
|
||||
@@ -59,7 +60,7 @@ async function ensureDir(fs: FileSystemProvider, path: string): Promise<void> {
|
||||
try {
|
||||
const st = await fs.stat(path);
|
||||
if (st.type === "dir") return;
|
||||
throw new Error(`"${path}" exists and is not a directory`);
|
||||
throw new Error(t("errors.notADirectory", { path }));
|
||||
} catch {
|
||||
await fs.mkdir(path);
|
||||
}
|
||||
@@ -235,7 +236,7 @@ export class TransferEngine {
|
||||
if (sameFs) {
|
||||
for (const file of files) {
|
||||
if (file.srcPath === file.dstPath) {
|
||||
throw new Error(`"${file.name}": source and destination are the same file`);
|
||||
throw new Error(t("errors.sameFileTransfer", { name: file.name }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,10 +344,10 @@ export class TransferEngine {
|
||||
|
||||
if (samePath) {
|
||||
throw new Error(
|
||||
isDir ? "Cannot copy a folder onto itself" : "Source and destination are the same file",
|
||||
isDir ? t("errors.folderOntoItself") : t("errors.sameFileNoName"),
|
||||
);
|
||||
}
|
||||
throw new Error("Cannot copy a folder into itself");
|
||||
throw new Error(t("errors.folderIntoItself"));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
import { BrowserWindow } from "electrobun/bun";
|
||||
import type { AppSettings } from "../shared/types";
|
||||
import { DEFAULT_SETTINGS, type AppSettings } from "../shared/types";
|
||||
|
||||
const isMac = process.platform === "darwin";
|
||||
/**
|
||||
* 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
|
||||
@@ -19,22 +39,70 @@ export function buildPreload(
|
||||
settings: AppSettings,
|
||||
ctx?: unknown,
|
||||
): string {
|
||||
// sessionSkin, NOT settings.skin — a pending skin change must not leak into
|
||||
// a window opened before the restart. See initSessionSkin.
|
||||
let script =
|
||||
`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)};`;
|
||||
|
||||
// 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> = {};
|
||||
if (settings.theme !== "system") dataset["theme"] = settings.theme;
|
||||
if (settings.darkTone !== "dark") dataset["darkTone"] = settings.darkTone;
|
||||
if (settings.accent !== "graphite") dataset["accent"] = settings.accent;
|
||||
if (settings.textSize !== "default") dataset["text"] = settings.textSize;
|
||||
// interface scale: mirrors SettingsService.applyUiScale so the FIRST paint
|
||||
// is already scaled — without this every window opens at 100% and snaps.
|
||||
// Written as an inline style, not a data attribute: the scale is continuous.
|
||||
if (settings.uiScale !== 1) {
|
||||
script += `document.documentElement.style.fontSize=${JSON.stringify(`${16 * settings.uiScale}px`)};`;
|
||||
}
|
||||
for (const [key, value] of Object.entries(dataset)) {
|
||||
script += `document.documentElement.dataset[${JSON.stringify(key)}]=${JSON.stringify(value)};`;
|
||||
}
|
||||
return script;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native chrome options for a window. The ONE place the frame decision is made —
|
||||
* both the main window (src/bun/index.ts) and openSecondaryWindow spread this.
|
||||
*
|
||||
* Both values are BrowserWindow CONSTRUCTOR options — there is no API to
|
||||
* re-decorate a live window — which is half of why the skin is a launch-time
|
||||
* choice. Takes no argument on purpose: it reads the pinned sessionSkin, so no
|
||||
* call site can accidentally frame a window from a pending setting.
|
||||
*/
|
||||
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"
|
||||
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
||||
: {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows are created hidden and revealed on the webview's first dom-ready —
|
||||
* showing them earlier paints the native (white) surface before the themed
|
||||
@@ -55,6 +123,31 @@ 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 {
|
||||
title: string;
|
||||
/** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */
|
||||
@@ -70,21 +163,15 @@ export interface SecondaryWindowOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* App-chromed secondary window.
|
||||
*
|
||||
* PORTABILITY: the only spot that branches on platform for secondary-window
|
||||
* chrome. macOS fuses the titlebar (traffic lights overlay the UI's h-10
|
||||
* header row, same treatment as the main window); Windows/Linux keep the
|
||||
* native titlebar and the UI renders a compact non-drag header instead.
|
||||
* App-chromed secondary window. The frame decision is delegated to
|
||||
* chromeOptions (the single frame decision).
|
||||
*/
|
||||
export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow {
|
||||
const win = new BrowserWindow({
|
||||
title: opts.title,
|
||||
url: opts.url,
|
||||
frame: opts.frame,
|
||||
...(isMac
|
||||
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
||||
: {}),
|
||||
...chromeOptions(),
|
||||
preload: buildPreload(opts.mode, opts.settings, opts.ctx),
|
||||
rpc: opts.rpc,
|
||||
hidden: true,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Translations
|
||||
|
||||
`en.ts` is the **source of truth**. Every other locale mirrors its key shape;
|
||||
any missing key falls back to English at lookup time (see `index.ts` +
|
||||
`I18nService`/backend `i18n.ts`), so partial translations are safe.
|
||||
|
||||
## Adding / editing strings
|
||||
|
||||
1. Add the key to `en.ts` (nested namespace, `{param}` interpolation). For
|
||||
plurals, use a `{ one, other }` map instead of a string — the runtime picks
|
||||
the CLDR category via `Intl.PluralRules` (languages without plural, e.g. zh
|
||||
and ja, only need `other`).
|
||||
2. Reference it as `i18n.t('ns.key', { param })` in the UI (or backend `t(...)`
|
||||
for native menus / thrown errors).
|
||||
3. Add the translation to the other locale files as convenient — until then
|
||||
English shows through.
|
||||
|
||||
The format is kept **Transloco-compatible** (nested namespaces, `{param}`) so
|
||||
swapping in a real i18n library later is a drop-in of these dictionaries.
|
||||
|
||||
## Review status
|
||||
|
||||
| Locale | File | Status |
|
||||
|---|---|---|
|
||||
| English | `en.ts` | Source |
|
||||
| Spanish | `es.ts` | Full draft |
|
||||
| French | `fr.ts` | Full draft |
|
||||
| German | `de.ts` | Full draft |
|
||||
| Chinese (Simplified) | `zh.ts` | **AI draft — needs native review** |
|
||||
| Japanese | `ja.ts` | **AI draft — needs native review** |
|
||||
| Basque | `eu.ts` | **AI draft — needs native review (highest priority; machine/AI Basque is unreliable)** |
|
||||
|
||||
Native-speaker review is especially important for Basque and the CJK locales:
|
||||
grammatical agreement, counters/measure words, and UI-context word choice are
|
||||
easy to get subtly wrong.
|
||||
@@ -0,0 +1,315 @@
|
||||
// German translations. Missing keys fall back to English (see en.ts / index.ts).
|
||||
export const de = {
|
||||
common: {
|
||||
cancel: "Abbrechen",
|
||||
save: "Speichern",
|
||||
close: "Schließen",
|
||||
delete: "Löschen",
|
||||
rename: "Umbenennen",
|
||||
create: "Erstellen",
|
||||
apply: "Anwenden",
|
||||
choose: "Wählen…",
|
||||
connect: "Verbinden",
|
||||
local: "Lokal",
|
||||
},
|
||||
app: {
|
||||
notConnectedTitle: "Nicht mit der App verbunden",
|
||||
notConnectedBody: "Diese Seite läuft in einem normalen Browser. Starte die Desktop-App, um sie zu verwenden.",
|
||||
managerOpen: "Der Verbindungsmanager ist geöffnet.",
|
||||
showConnections: "Verbindungen anzeigen",
|
||||
},
|
||||
tabBar: {
|
||||
connections: "Verbindungen",
|
||||
manageConnections: "Verbindungen verwalten",
|
||||
newTab: "Neuer Tab ({mod}T)",
|
||||
closeTab: "Tab schließen ({mod}W)",
|
||||
options: "Optionen",
|
||||
update: "Update verfügbar",
|
||||
updating: "Aktualisiere",
|
||||
restartToUpdate: "Zum Anwenden neu starten",
|
||||
updateTooltip: "Update verfügbar: miniSCP {version}",
|
||||
},
|
||||
options: {
|
||||
title: "Optionen",
|
||||
language: "Sprache",
|
||||
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" },
|
||||
tone: { label: "Ton", dark: "Dunkel", darker: "Dunkler" },
|
||||
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" },
|
||||
speedUnit: { label: "Geschwindigkeitseinheit" },
|
||||
transfers: { label: "Übertragungen", single: "Einzeln", multi: "Mehrere" },
|
||||
atOnce: "Gleichzeitig",
|
||||
editor: { label: "Editor", builtin: "Integriert", custom: "Benutzerdefiniert" },
|
||||
systemDefault: "Systemstandard",
|
||||
resetDefault: "Auf Standard zurücksetzen",
|
||||
finishedTransfers: { label: "Abgeschlossene Übertragungen", autoClear: "Auto-löschen", keep: "Behalten" },
|
||||
},
|
||||
kinds: {
|
||||
folder: "Ordner",
|
||||
symlink: "Symbolischer Link",
|
||||
file: "Datei",
|
||||
image: "Bild",
|
||||
audio: "Audio",
|
||||
video: "Video",
|
||||
archive: "Archiv",
|
||||
code: "Code",
|
||||
document: "Dokument",
|
||||
},
|
||||
columns: {
|
||||
name: "Name",
|
||||
size: "Größe",
|
||||
mtime: "Geändert",
|
||||
kind: "Typ",
|
||||
rights: "Rechte",
|
||||
owner: "Eigentümer",
|
||||
group: "Gruppe",
|
||||
},
|
||||
panel: {
|
||||
choosePanel: "Wählen, was dieses Panel anzeigt",
|
||||
copy: "Senden",
|
||||
copyToRight: "An rechtes Panel senden (F5)",
|
||||
copyToLeft: "An linkes Panel senden (F5)",
|
||||
edit: "Bearbeiten",
|
||||
editFile: "Datei bearbeiten (F4)",
|
||||
rename: "Umbenennen (F2)",
|
||||
newFolder: "Neuer Ordner (F7)",
|
||||
newFile: "Neue Datei",
|
||||
permissions: "Berechtigungen",
|
||||
delete: "Löschen",
|
||||
deleteHint: "Löschen (F8 / Entf)",
|
||||
parentDir: "Übergeordneter Ordner (Rücktaste)",
|
||||
refresh: "Aktualisieren",
|
||||
clearFilter: "Filter löschen (Esc)",
|
||||
sshConfig: "~/.ssh/config",
|
||||
connectingTo: "Verbinde mit {name}…",
|
||||
emptyDir: "Leerer Ordner",
|
||||
noMatches: "Keine Treffer",
|
||||
matches: { one: "{count} Treffer · Esc", other: "{count} Treffer · Esc" },
|
||||
},
|
||||
connMgr: {
|
||||
title: "Verbindungen",
|
||||
importWinscp: "Aus WinSCP importieren…",
|
||||
importWinscpHint: "Sitzungen aus einem WinSCP.ini-Export importieren",
|
||||
newConnection: "Verbindung",
|
||||
newConnectionHint: "Neue Verbindung",
|
||||
newStack: "Gruppe",
|
||||
newStackHint: "Neue Gruppe",
|
||||
expand: "Aufklappen",
|
||||
collapse: "Zuklappen",
|
||||
renameStack: "Gruppe umbenennen",
|
||||
deleteStack: "Gruppe löschen",
|
||||
openStack: "Gruppe in Tabs öffnen",
|
||||
empty: "leer",
|
||||
connectionsHeader: "Verbindungen",
|
||||
noneTitle: "Noch keine gespeicherten Verbindungen.",
|
||||
noneBody: "Erstelle eine oder importiere deine WinSCP-Sitzungen.",
|
||||
name: "Name",
|
||||
namePlaceholder: "mein Server",
|
||||
host: "Host",
|
||||
port: "Port",
|
||||
stack: "Gruppe",
|
||||
stackNone: "(keine)",
|
||||
username: "Benutzername",
|
||||
remotePath: "Entfernter Pfad (optional)",
|
||||
remotePathPlaceholder: "Home-Verzeichnis",
|
||||
auth: "Authentifizierung",
|
||||
authPassword: "Passwort",
|
||||
authKey: "Privater Schlüssel",
|
||||
password: "Passwort",
|
||||
privateKeyFile: "Datei mit privatem Schlüssel",
|
||||
passphrase: "Passphrase (optional)",
|
||||
localDir: "Lokales Verzeichnis (optional)",
|
||||
localDirPlaceholder: "~/projekte/mein-server",
|
||||
localDirHint: "Das andere Panel öffnet sich hier, wenn es auf Lokal steht und nicht navigiert wurde.",
|
||||
more: "Mehr…",
|
||||
less: "Weniger…",
|
||||
selectPrompt: "Wähle eine Verbindung oder erstelle eine neue.",
|
||||
newStackTitle: "Neue Gruppe",
|
||||
newStackPrompt: "Gruppenname",
|
||||
renameStackTitle: "Gruppe umbenennen",
|
||||
deleteStackTitle: "Gruppe löschen",
|
||||
deleteStackBody: 'Gruppe „{name}" löschen? Ihre Verbindungen bleiben, ohne Gruppe.',
|
||||
deleteConnectionTitle: "Verbindung löschen",
|
||||
deleteConnectionBody: '„{name}" löschen?',
|
||||
},
|
||||
editor: {
|
||||
noFileTitle: "Keine Datei zum Bearbeiten",
|
||||
noFileBody: "Dieses Fenster wurde ohne Editor-Kontext geöffnet. Öffne Dateien aus der App.",
|
||||
wrap: "Umbruch",
|
||||
wrapHint: "Lange Zeilen umbrechen — blendet Zeilennummern aus ({alt}Z)",
|
||||
findHint: "Suchen ({mod}F)",
|
||||
findPlaceholder: "In Datei suchen",
|
||||
prevMatch: "Vorheriger Treffer (⇧↵)",
|
||||
nextMatch: "Nächster Treffer (↵)",
|
||||
closeHint: "Schließen (Esc)",
|
||||
disconnected: "Verbindung zu {name} getrennt — Speichern schlägt fehl, aber dein Text bleibt in diesem Fenster erhalten.",
|
||||
cantOpen: "{name} kann nicht geöffnet werden",
|
||||
closeWindow: "Fenster schließen",
|
||||
shortcuts: "{mod}S speichern · {mod}F suchen · {mod}W schließen",
|
||||
lines: { one: "{count} Zeile", other: "{count} Zeilen" },
|
||||
close: "Schließen",
|
||||
save: "Speichern",
|
||||
saveChangesTitle: 'Änderungen an „{name}" speichern?',
|
||||
saveChangesBody: "Deine Änderungen gehen verloren, wenn du sie nicht speicherst.",
|
||||
dontSave: "Nicht speichern",
|
||||
countLabel: "{current} von {total}",
|
||||
noMatches: "Keine Treffer",
|
||||
},
|
||||
transfer: {
|
||||
summary: "{active} aktiv · {finished} abgeschlossen",
|
||||
clearFinished: "Abgeschlossene löschen",
|
||||
cancel: "Abbrechen",
|
||||
dismiss: "Verwerfen",
|
||||
showDetails: "Übertragungsdetails anzeigen",
|
||||
copying: "Kopiere {name} · {percent}%{speed}",
|
||||
queued: "{count} in Warteschlange",
|
||||
failed: { one: "{count} Übertragung fehlgeschlagen", other: "{count} Übertragungen fehlgeschlagen" },
|
||||
finished: "Übertragungen abgeschlossen",
|
||||
ready: "Bereit",
|
||||
items: "{count} Elemente",
|
||||
selected: "{count} ausgewählt",
|
||||
queuedState: "In Warteschlange",
|
||||
scanning: "Analysiere…",
|
||||
failedState: "Fehlgeschlagen",
|
||||
cancelled: "Abgebrochen",
|
||||
completedFiles: { one: "{count} Datei · {size}", other: "{count} Dateien · {size}" },
|
||||
left: "{eta} übrig",
|
||||
},
|
||||
dialogs: {
|
||||
chmodTitle: "Berechtigungen",
|
||||
chmodItems: "{count} Elemente",
|
||||
read: "Lesen",
|
||||
write: "Schreiben",
|
||||
execute: "Ausführen",
|
||||
roleOwner: "Eigentümer",
|
||||
roleGroup: "Gruppe",
|
||||
roleOthers: "Andere",
|
||||
octal: "Oktal",
|
||||
mergeFolder: "Ordner zusammenführen",
|
||||
replace: "Ersetzen",
|
||||
merge: "Zusammenführen",
|
||||
mergeAll: "Alle zusammenführen",
|
||||
replaceAll: "Alle ersetzen",
|
||||
skip: "Überspringen",
|
||||
skipAll: "Alle überspringen",
|
||||
ofTotal: "{index} von {total}",
|
||||
overwriteDirBody: "Am Ziel existiert bereits ein Ordner mit diesem Namen. Zusammenführen kann Dateien darin ersetzen.",
|
||||
overwriteFileBody: "Am Ziel existiert bereits eine Datei mit diesem Namen.",
|
||||
source: "Quelle",
|
||||
destination: "Ziel",
|
||||
newer: "neuer",
|
||||
},
|
||||
toasts: {
|
||||
movedToTrash: '„{name}" in den Papierkorb verschoben',
|
||||
editingExternal: "Bearbeite {name} — Speichern lädt automatisch hoch",
|
||||
nothingToCopy: "Nichts zu kopieren (symbolische Links werden übersprungen)",
|
||||
nothingToCopyAllSkipped: "Nichts zu kopieren — alle Konflikte wurden übersprungen",
|
||||
sameFolder: "Beide Panels zeigen denselben Ordner",
|
||||
sameFile: '„{name}" übersprungen — es ist dieselbe Datei',
|
||||
typeMismatch: '„{name}" übersprungen — ein anderer Elementtyp ist im Weg',
|
||||
transferFailed: 'Übertragung „{name}" fehlgeschlagen: {error}',
|
||||
unknownError: "unbekannter Fehler",
|
||||
saved: "{name} gespeichert",
|
||||
disconnectedReason: "{title}: getrennt ({reason})",
|
||||
imported: { one: "{count} Verbindung importiert", other: "{count} Verbindungen importiert" },
|
||||
importedIntoStacks: { one: " in {count} neue Gruppe", other: " in {count} neue Gruppen" },
|
||||
importedSkipped: " ({skipped} übersprungen)",
|
||||
leftoverTitle: "Übrig gebliebene bearbeitete Dateien",
|
||||
leftoverMore: ", +{count} weitere",
|
||||
leftoverBody: {
|
||||
one: "Eine frühere Sitzung hinterließ {count} temporäre bearbeitete Datei ({names}). Löschen?",
|
||||
other: "Eine frühere Sitzung hinterließ {count} temporäre bearbeitete Dateien ({names}). Löschen?",
|
||||
},
|
||||
leftoverClear: "Löschen",
|
||||
},
|
||||
fileActions: {
|
||||
deleteTitle: "Löschen",
|
||||
deleteBody: "{what} löschen? Ordner werden rekursiv gelöscht. Dies kann nicht rückgängig gemacht werden.",
|
||||
deleteConfirm: "Löschen",
|
||||
renameTitle: '„{name}" umbenennen',
|
||||
renameConfirm: "Umbenennen",
|
||||
newFolderTitle: "Neuer Ordner",
|
||||
newFolderPlaceholder: "Ordnername",
|
||||
newFileTitle: "Neue Datei",
|
||||
newFilePlaceholder: "Dateiname",
|
||||
createConfirm: "Erstellen",
|
||||
itemsCount: "{count} Elemente",
|
||||
},
|
||||
menu: {
|
||||
about: "Über {app}",
|
||||
checkForUpdates: "Nach Updates suchen…",
|
||||
quit: "Beenden",
|
||||
file: "Datei",
|
||||
newTab: "Neuer Tab",
|
||||
closeTab: "Tab schließen",
|
||||
newFolder: "Neuer Ordner",
|
||||
newFile: "Neue Datei",
|
||||
editFile: "Datei bearbeiten",
|
||||
connect: "Verbinden",
|
||||
connectionManager: "Verbindungsmanager…",
|
||||
edit: "Bearbeiten",
|
||||
view: "Ansicht",
|
||||
refresh: "Aktualisieren",
|
||||
window: "Fenster",
|
||||
},
|
||||
ctx: {
|
||||
size: "Größe",
|
||||
modified: "Geändert",
|
||||
kind: "Typ",
|
||||
rights: "Rechte",
|
||||
owner: "Eigentümer",
|
||||
group: "Gruppe",
|
||||
revealFinder: "Im Finder anzeigen",
|
||||
revealExplorer: "Im Explorer anzeigen",
|
||||
revealFileManager: "Im Dateimanager anzeigen",
|
||||
moveToTrash: "In den Papierkorb",
|
||||
moveToRecycleBin: "In den Papierkorb",
|
||||
newFolder: "Neuer Ordner",
|
||||
newFile: "Neue Datei",
|
||||
refresh: "Aktualisieren",
|
||||
open: "Öffnen",
|
||||
openDefault: "Mit Standard-App öffnen",
|
||||
edit: "Bearbeiten",
|
||||
copyToOther: "An anderes Panel senden",
|
||||
rename: "Umbenennen",
|
||||
permissions: "Berechtigungen",
|
||||
copyPath: "Pfad kopieren",
|
||||
delete: "Löschen",
|
||||
columns: "Spalten",
|
||||
tabNew: "Neuer Tab",
|
||||
tabClose: "Tab schließen",
|
||||
tabCloseOthers: "Andere Tabs schließen",
|
||||
tabCloseRight: "Tabs rechts schließen",
|
||||
tabDisconnect: "Trennen",
|
||||
trashTooltip: "Finders Wiederherstellen ist für von hier gelöschte Elemente nicht verfügbar",
|
||||
},
|
||||
errors: {
|
||||
sshAgentUnavailable: "ssh-agent nicht verfügbar (SSH_AUTH_SOCK ist nicht gesetzt)",
|
||||
identityFileNotFound: "Identitätsdatei nicht gefunden: {path}",
|
||||
sessionNotConnected: 'Sitzung „{id}" ist nicht verbunden',
|
||||
localCannotDisconnect: "Die lokale Sitzung kann nicht getrennt werden",
|
||||
notADirectory: '„{path}" existiert und ist kein Ordner',
|
||||
sameFileTransfer: '„{name}": Quelle und Ziel sind dieselbe Datei',
|
||||
sameFileNoName: "Quelle und Ziel sind dieselbe Datei",
|
||||
folderOntoItself: "Ein Ordner kann nicht auf sich selbst kopiert werden",
|
||||
folderIntoItself: "Ein Ordner kann nicht in sich selbst kopiert werden",
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,319 @@
|
||||
// English is the source of truth. Other locales mirror this shape; missing keys
|
||||
// fall back to English at lookup time. Placeholders use {name} interpolation.
|
||||
// Keep Transloco-compatible (nested namespaces, {param}) so a later migration
|
||||
// to a real i18n library is a drop-in of these dictionaries.
|
||||
|
||||
export const en = {
|
||||
common: {
|
||||
cancel: "Cancel",
|
||||
save: "Save",
|
||||
close: "Close",
|
||||
delete: "Delete",
|
||||
rename: "Rename",
|
||||
create: "Create",
|
||||
apply: "Apply",
|
||||
choose: "Choose…",
|
||||
connect: "Connect",
|
||||
local: "Local",
|
||||
},
|
||||
app: {
|
||||
notConnectedTitle: "Not connected to the app",
|
||||
notConnectedBody: "This page is running in a plain browser. Launch the desktop app to use it.",
|
||||
managerOpen: "The connection manager is open.",
|
||||
showConnections: "Show Connections",
|
||||
},
|
||||
tabBar: {
|
||||
connections: "Connections",
|
||||
manageConnections: "Manage connections",
|
||||
newTab: "New tab ({mod}T)",
|
||||
closeTab: "Close tab ({mod}W)",
|
||||
options: "Options",
|
||||
update: "Update available",
|
||||
updating: "Updating",
|
||||
restartToUpdate: "Restart to apply",
|
||||
updateTooltip: "Update available: miniSCP {version}",
|
||||
},
|
||||
options: {
|
||||
title: "Options",
|
||||
language: "Language",
|
||||
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" },
|
||||
tone: { label: "Tone", dark: "Dark", darker: "Darker" },
|
||||
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%" },
|
||||
speedUnit: { label: "Speed unit" },
|
||||
transfers: { label: "Transfers", single: "Single", multi: "Multi" },
|
||||
atOnce: "At once",
|
||||
editor: { label: "Editor", builtin: "Built-in", custom: "Custom" },
|
||||
systemDefault: "System default",
|
||||
resetDefault: "Reset to system default",
|
||||
finishedTransfers: { label: "Finished transfers", autoClear: "Auto-clear", keep: "Keep" },
|
||||
},
|
||||
kinds: {
|
||||
folder: "Folder",
|
||||
symlink: "Symlink",
|
||||
file: "File",
|
||||
image: "Image",
|
||||
audio: "Audio",
|
||||
video: "Video",
|
||||
archive: "Archive",
|
||||
code: "Code",
|
||||
document: "Document",
|
||||
},
|
||||
columns: {
|
||||
name: "Name",
|
||||
size: "Size",
|
||||
mtime: "Modified",
|
||||
kind: "Kind",
|
||||
rights: "Rights",
|
||||
owner: "Owner",
|
||||
group: "Group",
|
||||
},
|
||||
panel: {
|
||||
choosePanel: "Choose what this panel shows",
|
||||
copy: "Send",
|
||||
copyToRight: "Send to right panel (F5)",
|
||||
copyToLeft: "Send to left panel (F5)",
|
||||
edit: "Edit",
|
||||
editFile: "Edit file (F4)",
|
||||
rename: "Rename (F2)",
|
||||
newFolder: "New folder (F7)",
|
||||
newFile: "New file",
|
||||
permissions: "Permissions",
|
||||
delete: "Delete",
|
||||
deleteHint: "Delete (F8 / Del)",
|
||||
parentDir: "Parent directory (Backspace)",
|
||||
refresh: "Refresh",
|
||||
clearFilter: "Clear filter (Esc)",
|
||||
sshConfig: "~/.ssh/config",
|
||||
connectingTo: "Connecting to {name}…",
|
||||
emptyDir: "Empty directory",
|
||||
noMatches: "No matches",
|
||||
matches: { one: "{count} match · Esc", other: "{count} matches · Esc" },
|
||||
},
|
||||
connMgr: {
|
||||
title: "Connections",
|
||||
importWinscp: "Import from WinSCP…",
|
||||
importWinscpHint: "Import sessions from a WinSCP.ini export",
|
||||
newConnection: "Connection",
|
||||
newConnectionHint: "New connection",
|
||||
newStack: "Stack",
|
||||
newStackHint: "New stack",
|
||||
expand: "Expand",
|
||||
collapse: "Collapse",
|
||||
renameStack: "Rename stack",
|
||||
deleteStack: "Delete stack",
|
||||
openStack: "Open stack in tabs",
|
||||
empty: "empty",
|
||||
connectionsHeader: "Connections",
|
||||
noneTitle: "No saved connections yet.",
|
||||
noneBody: "Create one, or import your WinSCP sessions.",
|
||||
name: "Name",
|
||||
namePlaceholder: "my server",
|
||||
host: "Host",
|
||||
port: "Port",
|
||||
stack: "Stack",
|
||||
stackNone: "(none)",
|
||||
username: "Username",
|
||||
remotePath: "Remote path (optional)",
|
||||
remotePathPlaceholder: "home directory",
|
||||
auth: "Auth",
|
||||
authPassword: "Password",
|
||||
authKey: "Private key",
|
||||
password: "Password",
|
||||
privateKeyFile: "Private key file",
|
||||
passphrase: "Passphrase (optional)",
|
||||
localDir: "Local directory (optional)",
|
||||
localDirPlaceholder: "~/projects/my-server",
|
||||
localDirHint: "The other panel opens here when it's on Local and hasn't been navigated.",
|
||||
more: "More…",
|
||||
less: "Less…",
|
||||
selectPrompt: "Select a connection or create a new one.",
|
||||
newStackTitle: "New stack",
|
||||
newStackPrompt: "stack name",
|
||||
renameStackTitle: "Rename stack",
|
||||
deleteStackTitle: "Delete stack",
|
||||
deleteStackBody: 'Delete stack "{name}"? Its connections stay, ungrouped.',
|
||||
deleteConnectionTitle: "Delete connection",
|
||||
deleteConnectionBody: 'Delete "{name}"?',
|
||||
},
|
||||
editor: {
|
||||
noFileTitle: "No file to edit",
|
||||
noFileBody: "This window was opened without an editor context. Launch files from the app.",
|
||||
wrap: "Wrap",
|
||||
wrapHint: "Wrap long lines — hides line numbers ({alt}Z)",
|
||||
findHint: "Find ({mod}F)",
|
||||
findPlaceholder: "Find in file",
|
||||
prevMatch: "Previous match (⇧↵)",
|
||||
nextMatch: "Next match (↵)",
|
||||
closeHint: "Close (Esc)",
|
||||
disconnected: "Disconnected from {name} — saving will fail, but your text is preserved in this window.",
|
||||
cantOpen: "Can't open {name}",
|
||||
closeWindow: "Close Window",
|
||||
shortcuts: "{mod}S save · {mod}F find · {mod}W close",
|
||||
lines: { one: "{count} line", other: "{count} lines" },
|
||||
close: "Close",
|
||||
save: "Save",
|
||||
saveChangesTitle: 'Save changes to "{name}"?',
|
||||
saveChangesBody: "Your changes will be lost if you don't save them.",
|
||||
dontSave: "Don't Save",
|
||||
countLabel: "{current} of {total}",
|
||||
noMatches: "No matches",
|
||||
},
|
||||
transfer: {
|
||||
summary: "{active} active · {finished} finished",
|
||||
clearFinished: "Clear finished",
|
||||
cancel: "Cancel",
|
||||
dismiss: "Dismiss",
|
||||
showDetails: "Show transfer details",
|
||||
copying: "Copying {name} · {percent}%{speed}",
|
||||
queued: "{count} queued",
|
||||
failed: { one: "{count} transfer failed", other: "{count} transfers failed" },
|
||||
finished: "Transfers finished",
|
||||
ready: "Ready",
|
||||
items: "{count} items",
|
||||
selected: "{count} selected",
|
||||
queuedState: "Queued",
|
||||
scanning: "Scanning…",
|
||||
failedState: "Failed",
|
||||
cancelled: "Cancelled",
|
||||
completedFiles: { one: "{count} file · {size}", other: "{count} files · {size}" },
|
||||
left: "{eta} left",
|
||||
},
|
||||
dialogs: {
|
||||
chmodTitle: "Permissions",
|
||||
chmodItems: "{count} items",
|
||||
read: "Read",
|
||||
write: "Write",
|
||||
execute: "Execute",
|
||||
roleOwner: "Owner",
|
||||
roleGroup: "Group",
|
||||
roleOthers: "Others",
|
||||
octal: "Octal",
|
||||
mergeFolder: "Merge folder",
|
||||
replace: "Replace",
|
||||
merge: "Merge",
|
||||
mergeAll: "Merge all",
|
||||
replaceAll: "Replace all",
|
||||
skip: "Skip",
|
||||
skipAll: "Skip all",
|
||||
ofTotal: "{index} of {total}",
|
||||
overwriteDirBody: "The destination already has a folder with this name. Merging may replace files inside it.",
|
||||
overwriteFileBody: "The destination already has a file with this name.",
|
||||
source: "Source",
|
||||
destination: "Destination",
|
||||
newer: "newer",
|
||||
},
|
||||
toasts: {
|
||||
movedToTrash: 'Moved "{name}" to Trash',
|
||||
editingExternal: "Editing {name} — saves upload automatically",
|
||||
nothingToCopy: "Nothing to copy (symlinks are skipped)",
|
||||
nothingToCopyAllSkipped: "Nothing to copy — all conflicting items were skipped",
|
||||
sameFolder: "Both panels show the same folder",
|
||||
sameFile: 'Skipped "{name}" — it is the same file',
|
||||
typeMismatch: 'Skipped "{name}" — a different kind of item is in the way',
|
||||
transferFailed: 'Transfer "{name}" failed: {error}',
|
||||
unknownError: "unknown error",
|
||||
saved: "Saved {name}",
|
||||
disconnectedReason: "{title}: disconnected ({reason})",
|
||||
imported: { one: "Imported {count} connection", other: "Imported {count} connections" },
|
||||
importedIntoStacks: { one: " into {count} new stack", other: " into {count} new stacks" },
|
||||
importedSkipped: " ({skipped} skipped)",
|
||||
leftoverTitle: "Leftover edited files",
|
||||
leftoverBody: {
|
||||
one: "A previous session left {count} temporary edited file behind ({names}). Clear them?",
|
||||
other: "A previous session left {count} temporary edited files behind ({names}). Clear them?",
|
||||
},
|
||||
leftoverMore: ", +{count} more",
|
||||
leftoverClear: "Clear",
|
||||
},
|
||||
fileActions: {
|
||||
deleteTitle: "Delete",
|
||||
deleteBody: "Delete {what}? Folders are deleted recursively. This cannot be undone.",
|
||||
deleteConfirm: "Delete",
|
||||
renameTitle: 'Rename "{name}"',
|
||||
renameConfirm: "Rename",
|
||||
newFolderTitle: "New folder",
|
||||
newFolderPlaceholder: "folder name",
|
||||
newFileTitle: "New file",
|
||||
newFilePlaceholder: "file name",
|
||||
createConfirm: "Create",
|
||||
itemsCount: "{count} items",
|
||||
},
|
||||
menu: {
|
||||
about: "About {app}",
|
||||
checkForUpdates: "Check for Updates…",
|
||||
quit: "Quit",
|
||||
file: "File",
|
||||
newTab: "New Tab",
|
||||
closeTab: "Close Tab",
|
||||
newFolder: "New Folder",
|
||||
newFile: "New File",
|
||||
editFile: "Edit File",
|
||||
connect: "Connect",
|
||||
connectionManager: "Connection Manager…",
|
||||
edit: "Edit",
|
||||
view: "View",
|
||||
refresh: "Refresh",
|
||||
window: "Window",
|
||||
},
|
||||
ctx: {
|
||||
size: "Size",
|
||||
modified: "Modified",
|
||||
kind: "Kind",
|
||||
rights: "Rights",
|
||||
owner: "Owner",
|
||||
group: "Group",
|
||||
revealFinder: "Reveal in Finder",
|
||||
revealExplorer: "Show in Explorer",
|
||||
revealFileManager: "Show in File Manager",
|
||||
moveToTrash: "Move to Trash",
|
||||
moveToRecycleBin: "Move to Recycle Bin",
|
||||
newFolder: "New Folder",
|
||||
newFile: "New File",
|
||||
refresh: "Refresh",
|
||||
open: "Open",
|
||||
openDefault: "Open with the default application",
|
||||
edit: "Edit",
|
||||
copyToOther: "Send to Other Panel",
|
||||
rename: "Rename",
|
||||
permissions: "Permissions",
|
||||
copyPath: "Copy Path",
|
||||
delete: "Delete",
|
||||
columns: "Columns",
|
||||
tabNew: "New Tab",
|
||||
tabClose: "Close Tab",
|
||||
tabCloseOthers: "Close Other Tabs",
|
||||
tabCloseRight: "Close Tabs to the Right",
|
||||
tabDisconnect: "Disconnect",
|
||||
trashTooltip: "Finder's Restore is not available for items trashed from here",
|
||||
},
|
||||
errors: {
|
||||
sshAgentUnavailable: "ssh-agent not available (SSH_AUTH_SOCK is not set)",
|
||||
identityFileNotFound: "identity file not found: {path}",
|
||||
sessionNotConnected: 'Session "{id}" is not connected',
|
||||
localCannotDisconnect: "The local session cannot be disconnected",
|
||||
notADirectory: '"{path}" exists and is not a directory',
|
||||
sameFileTransfer: '"{name}": source and destination are the same file',
|
||||
sameFileNoName: "Source and destination are the same file",
|
||||
folderOntoItself: "Cannot copy a folder onto itself",
|
||||
folderIntoItself: "Cannot copy a folder into itself",
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,315 @@
|
||||
// Spanish translations. Missing keys fall back to English (see en.ts / index.ts).
|
||||
export const es = {
|
||||
common: {
|
||||
cancel: "Cancelar",
|
||||
save: "Guardar",
|
||||
close: "Cerrar",
|
||||
delete: "Eliminar",
|
||||
rename: "Renombrar",
|
||||
create: "Crear",
|
||||
apply: "Aplicar",
|
||||
choose: "Elegir…",
|
||||
connect: "Conectar",
|
||||
local: "Local",
|
||||
},
|
||||
app: {
|
||||
notConnectedTitle: "No conectado a la aplicación",
|
||||
notConnectedBody: "Esta página se ejecuta en un navegador normal. Abre la aplicación de escritorio para usarla.",
|
||||
managerOpen: "El gestor de conexiones está abierto.",
|
||||
showConnections: "Mostrar conexiones",
|
||||
},
|
||||
tabBar: {
|
||||
connections: "Conexiones",
|
||||
manageConnections: "Gestionar conexiones",
|
||||
newTab: "Nueva pestaña ({mod}T)",
|
||||
closeTab: "Cerrar pestaña ({mod}W)",
|
||||
options: "Opciones",
|
||||
update: "Actualización disponible",
|
||||
updating: "Actualizando",
|
||||
restartToUpdate: "Reiniciar para aplicar",
|
||||
updateTooltip: "Actualización disponible: miniSCP {version}",
|
||||
},
|
||||
options: {
|
||||
title: "Opciones",
|
||||
language: "Idioma",
|
||||
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" },
|
||||
tone: { label: "Tono", dark: "Oscuro", darker: "Más oscuro" },
|
||||
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%" },
|
||||
speedUnit: { label: "Unidad de velocidad" },
|
||||
transfers: { label: "Transferencias", single: "Una", multi: "Varias" },
|
||||
atOnce: "A la vez",
|
||||
editor: { label: "Editor", builtin: "Integrado", custom: "Personalizado" },
|
||||
systemDefault: "Predeterminado del sistema",
|
||||
resetDefault: "Restablecer al predeterminado",
|
||||
finishedTransfers: { label: "Transferencias completadas", autoClear: "Auto-limpiar", keep: "Mantener" },
|
||||
},
|
||||
kinds: {
|
||||
folder: "Carpeta",
|
||||
symlink: "Enlace simbólico",
|
||||
file: "Archivo",
|
||||
image: "Imagen",
|
||||
audio: "Audio",
|
||||
video: "Vídeo",
|
||||
archive: "Archivo comprimido",
|
||||
code: "Código",
|
||||
document: "Documento",
|
||||
},
|
||||
columns: {
|
||||
name: "Nombre",
|
||||
size: "Tamaño",
|
||||
mtime: "Modificado",
|
||||
kind: "Tipo",
|
||||
rights: "Permisos",
|
||||
owner: "Propietario",
|
||||
group: "Grupo",
|
||||
},
|
||||
panel: {
|
||||
choosePanel: "Elige qué muestra este panel",
|
||||
copy: "Enviar",
|
||||
copyToRight: "Enviar al panel derecho (F5)",
|
||||
copyToLeft: "Enviar al panel izquierdo (F5)",
|
||||
edit: "Editar",
|
||||
editFile: "Editar archivo (F4)",
|
||||
rename: "Renombrar (F2)",
|
||||
newFolder: "Nueva carpeta (F7)",
|
||||
newFile: "Nuevo archivo",
|
||||
permissions: "Permisos",
|
||||
delete: "Eliminar",
|
||||
deleteHint: "Eliminar (F8 / Supr)",
|
||||
parentDir: "Carpeta superior (Retroceso)",
|
||||
refresh: "Actualizar",
|
||||
clearFilter: "Borrar filtro (Esc)",
|
||||
sshConfig: "~/.ssh/config",
|
||||
connectingTo: "Conectando con {name}…",
|
||||
emptyDir: "Carpeta vacía",
|
||||
noMatches: "Sin coincidencias",
|
||||
matches: { one: "{count} coincidencia · Esc", other: "{count} coincidencias · Esc" },
|
||||
},
|
||||
connMgr: {
|
||||
title: "Conexiones",
|
||||
importWinscp: "Importar de WinSCP…",
|
||||
importWinscpHint: "Importar sesiones de una exportación WinSCP.ini",
|
||||
newConnection: "Conexión",
|
||||
newConnectionHint: "Nueva conexión",
|
||||
newStack: "Grupo",
|
||||
newStackHint: "Nuevo grupo",
|
||||
expand: "Expandir",
|
||||
collapse: "Contraer",
|
||||
renameStack: "Renombrar grupo",
|
||||
deleteStack: "Eliminar grupo",
|
||||
openStack: "Abrir grupo en pestañas",
|
||||
empty: "vacío",
|
||||
connectionsHeader: "Conexiones",
|
||||
noneTitle: "Aún no hay conexiones guardadas.",
|
||||
noneBody: "Crea una o importa tus sesiones de WinSCP.",
|
||||
name: "Nombre",
|
||||
namePlaceholder: "mi servidor",
|
||||
host: "Servidor",
|
||||
port: "Puerto",
|
||||
stack: "Grupo",
|
||||
stackNone: "(ninguno)",
|
||||
username: "Usuario",
|
||||
remotePath: "Ruta remota (opcional)",
|
||||
remotePathPlaceholder: "carpeta de inicio",
|
||||
auth: "Autenticación",
|
||||
authPassword: "Contraseña",
|
||||
authKey: "Clave privada",
|
||||
password: "Contraseña",
|
||||
privateKeyFile: "Archivo de clave privada",
|
||||
passphrase: "Frase de contraseña (opcional)",
|
||||
localDir: "Carpeta local (opcional)",
|
||||
localDirPlaceholder: "~/proyectos/mi-servidor",
|
||||
localDirHint: "El otro panel se abre aquí cuando está en Local y no se ha navegado.",
|
||||
more: "Más…",
|
||||
less: "Menos…",
|
||||
selectPrompt: "Selecciona una conexión o crea una nueva.",
|
||||
newStackTitle: "Nuevo grupo",
|
||||
newStackPrompt: "nombre del grupo",
|
||||
renameStackTitle: "Renombrar grupo",
|
||||
deleteStackTitle: "Eliminar grupo",
|
||||
deleteStackBody: '¿Eliminar el grupo "{name}"? Sus conexiones se mantienen, sin agrupar.',
|
||||
deleteConnectionTitle: "Eliminar conexión",
|
||||
deleteConnectionBody: '¿Eliminar "{name}"?',
|
||||
},
|
||||
editor: {
|
||||
noFileTitle: "Ningún archivo que editar",
|
||||
noFileBody: "Esta ventana se abrió sin contexto de editor. Abre archivos desde la aplicación.",
|
||||
wrap: "Ajustar",
|
||||
wrapHint: "Ajustar líneas largas — oculta los números de línea ({alt}Z)",
|
||||
findHint: "Buscar ({mod}F)",
|
||||
findPlaceholder: "Buscar en el archivo",
|
||||
prevMatch: "Coincidencia anterior (⇧↵)",
|
||||
nextMatch: "Coincidencia siguiente (↵)",
|
||||
closeHint: "Cerrar (Esc)",
|
||||
disconnected: "Desconectado de {name} — no se podrá guardar, pero tu texto se conserva en esta ventana.",
|
||||
cantOpen: "No se puede abrir {name}",
|
||||
closeWindow: "Cerrar ventana",
|
||||
shortcuts: "{mod}S guardar · {mod}F buscar · {mod}W cerrar",
|
||||
lines: { one: "{count} línea", other: "{count} líneas" },
|
||||
close: "Cerrar",
|
||||
save: "Guardar",
|
||||
saveChangesTitle: '¿Guardar los cambios en "{name}"?',
|
||||
saveChangesBody: "Perderás tus cambios si no los guardas.",
|
||||
dontSave: "No guardar",
|
||||
countLabel: "{current} de {total}",
|
||||
noMatches: "Sin coincidencias",
|
||||
},
|
||||
transfer: {
|
||||
summary: "{active} activas · {finished} completadas",
|
||||
clearFinished: "Limpiar completadas",
|
||||
cancel: "Cancelar",
|
||||
dismiss: "Descartar",
|
||||
showDetails: "Ver detalles de transferencias",
|
||||
copying: "Copiando {name} · {percent}%{speed}",
|
||||
queued: "{count} en cola",
|
||||
failed: { one: "{count} transferencia fallida", other: "{count} transferencias fallidas" },
|
||||
finished: "Transferencias completadas",
|
||||
ready: "Listo",
|
||||
items: "{count} elementos",
|
||||
selected: "{count} seleccionados",
|
||||
queuedState: "En cola",
|
||||
scanning: "Analizando…",
|
||||
failedState: "Fallida",
|
||||
cancelled: "Cancelada",
|
||||
completedFiles: { one: "{count} archivo · {size}", other: "{count} archivos · {size}" },
|
||||
left: "{eta} restante",
|
||||
},
|
||||
dialogs: {
|
||||
chmodTitle: "Permisos",
|
||||
chmodItems: "{count} elementos",
|
||||
read: "Lectura",
|
||||
write: "Escritura",
|
||||
execute: "Ejecución",
|
||||
roleOwner: "Propietario",
|
||||
roleGroup: "Grupo",
|
||||
roleOthers: "Otros",
|
||||
octal: "Octal",
|
||||
mergeFolder: "Combinar carpeta",
|
||||
replace: "Reemplazar",
|
||||
merge: "Combinar",
|
||||
mergeAll: "Combinar todo",
|
||||
replaceAll: "Reemplazar todo",
|
||||
skip: "Omitir",
|
||||
skipAll: "Omitir todo",
|
||||
ofTotal: "{index} de {total}",
|
||||
overwriteDirBody: "El destino ya tiene una carpeta con este nombre. Combinar puede reemplazar archivos dentro.",
|
||||
overwriteFileBody: "El destino ya tiene un archivo con este nombre.",
|
||||
source: "Origen",
|
||||
destination: "Destino",
|
||||
newer: "más reciente",
|
||||
},
|
||||
toasts: {
|
||||
movedToTrash: 'Se movió "{name}" a la papelera',
|
||||
editingExternal: "Editando {name} — los cambios se suben automáticamente",
|
||||
nothingToCopy: "Nada que copiar (los enlaces simbólicos se omiten)",
|
||||
nothingToCopyAllSkipped: "Nada que copiar — se omitieron todos los elementos en conflicto",
|
||||
sameFolder: "Ambos paneles muestran la misma carpeta",
|
||||
sameFile: 'Se omitió "{name}" — es el mismo archivo',
|
||||
typeMismatch: 'Se omitió "{name}" — hay otro tipo de elemento en el camino',
|
||||
transferFailed: 'La transferencia "{name}" falló: {error}',
|
||||
unknownError: "error desconocido",
|
||||
saved: "Se guardó {name}",
|
||||
disconnectedReason: "{title}: desconectado ({reason})",
|
||||
imported: { one: "Se importó {count} conexión", other: "Se importaron {count} conexiones" },
|
||||
importedIntoStacks: { one: " en {count} grupo nuevo", other: " en {count} grupos nuevos" },
|
||||
importedSkipped: " ({skipped} omitidas)",
|
||||
leftoverTitle: "Archivos editados sobrantes",
|
||||
leftoverMore: ", +{count} más",
|
||||
leftoverBody: {
|
||||
one: "Una sesión anterior dejó {count} archivo editado temporal ({names}). ¿Eliminarlo?",
|
||||
other: "Una sesión anterior dejó {count} archivos editados temporales ({names}). ¿Eliminarlos?",
|
||||
},
|
||||
leftoverClear: "Eliminar",
|
||||
},
|
||||
fileActions: {
|
||||
deleteTitle: "Eliminar",
|
||||
deleteBody: "¿Eliminar {what}? Las carpetas se eliminan de forma recursiva. Esto no se puede deshacer.",
|
||||
deleteConfirm: "Eliminar",
|
||||
renameTitle: 'Renombrar "{name}"',
|
||||
renameConfirm: "Renombrar",
|
||||
newFolderTitle: "Nueva carpeta",
|
||||
newFolderPlaceholder: "nombre de la carpeta",
|
||||
newFileTitle: "Nuevo archivo",
|
||||
newFilePlaceholder: "nombre del archivo",
|
||||
createConfirm: "Crear",
|
||||
itemsCount: "{count} elementos",
|
||||
},
|
||||
menu: {
|
||||
about: "Acerca de {app}",
|
||||
checkForUpdates: "Buscar actualizaciones…",
|
||||
quit: "Salir",
|
||||
file: "Archivo",
|
||||
newTab: "Nueva pestaña",
|
||||
closeTab: "Cerrar pestaña",
|
||||
newFolder: "Nueva carpeta",
|
||||
newFile: "Nuevo archivo",
|
||||
editFile: "Editar archivo",
|
||||
connect: "Conectar",
|
||||
connectionManager: "Gestor de conexiones…",
|
||||
edit: "Edición",
|
||||
view: "Ver",
|
||||
refresh: "Actualizar",
|
||||
window: "Ventana",
|
||||
},
|
||||
ctx: {
|
||||
size: "Tamaño",
|
||||
modified: "Modificado",
|
||||
kind: "Tipo",
|
||||
rights: "Permisos",
|
||||
owner: "Propietario",
|
||||
group: "Grupo",
|
||||
revealFinder: "Mostrar en Finder",
|
||||
revealExplorer: "Mostrar en el Explorador",
|
||||
revealFileManager: "Mostrar en el gestor de archivos",
|
||||
moveToTrash: "Mover a la papelera",
|
||||
moveToRecycleBin: "Mover a la papelera de reciclaje",
|
||||
newFolder: "Nueva carpeta",
|
||||
newFile: "Nuevo archivo",
|
||||
refresh: "Actualizar",
|
||||
open: "Abrir",
|
||||
openDefault: "Abrir con la aplicación predeterminada",
|
||||
edit: "Editar",
|
||||
copyToOther: "Enviar al otro panel",
|
||||
rename: "Renombrar",
|
||||
permissions: "Permisos",
|
||||
copyPath: "Copiar ruta",
|
||||
delete: "Eliminar",
|
||||
columns: "Columnas",
|
||||
tabNew: "Nueva pestaña",
|
||||
tabClose: "Cerrar pestaña",
|
||||
tabCloseOthers: "Cerrar las demás pestañas",
|
||||
tabCloseRight: "Cerrar pestañas a la derecha",
|
||||
tabDisconnect: "Desconectar",
|
||||
trashTooltip: "La opción Restaurar de Finder no está disponible para elementos enviados a la papelera desde aquí",
|
||||
},
|
||||
errors: {
|
||||
sshAgentUnavailable: "ssh-agent no disponible (SSH_AUTH_SOCK no está definido)",
|
||||
identityFileNotFound: "archivo de identidad no encontrado: {path}",
|
||||
sessionNotConnected: 'La sesión "{id}" no está conectada',
|
||||
localCannotDisconnect: "La sesión local no se puede desconectar",
|
||||
notADirectory: '"{path}" existe y no es una carpeta',
|
||||
sameFileTransfer: '"{name}": el origen y el destino son el mismo archivo',
|
||||
sameFileNoName: "El origen y el destino son el mismo archivo",
|
||||
folderOntoItself: "No se puede copiar una carpeta sobre sí misma",
|
||||
folderIntoItself: "No se puede copiar una carpeta dentro de sí misma",
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,317 @@
|
||||
// Basque (Euskara) translations. Fall back to English for missing keys.
|
||||
// TODO(i18n): AI draft — REQUIRES native-speaker review (Basque is error-prone
|
||||
// for machine/AI translation; treat every string as provisional).
|
||||
export const eu = {
|
||||
common: {
|
||||
cancel: "Utzi",
|
||||
save: "Gorde",
|
||||
close: "Itxi",
|
||||
delete: "Ezabatu",
|
||||
rename: "Berrizendatu",
|
||||
create: "Sortu",
|
||||
apply: "Aplikatu",
|
||||
choose: "Aukeratu…",
|
||||
connect: "Konektatu",
|
||||
local: "Lokala",
|
||||
},
|
||||
app: {
|
||||
notConnectedTitle: "Ez dago aplikaziora konektatuta",
|
||||
notConnectedBody: "Orri hau nabigatzaile arrunt batean exekutatzen ari da. Ireki mahaigaineko aplikazioa erabiltzeko.",
|
||||
managerOpen: "Konexio-kudeatzailea irekita dago.",
|
||||
showConnections: "Erakutsi konexioak",
|
||||
},
|
||||
tabBar: {
|
||||
connections: "Konexioak",
|
||||
manageConnections: "Kudeatu konexioak",
|
||||
newTab: "Fitxa berria ({mod}T)",
|
||||
closeTab: "Itxi fitxa ({mod}W)",
|
||||
options: "Aukerak",
|
||||
update: "Eguneratzea eskuragarri",
|
||||
updating: "Eguneratzen",
|
||||
restartToUpdate: "Berrabiarazi aplikatzeko",
|
||||
updateTooltip: "Eguneratzea eskuragarri: miniSCP {version}",
|
||||
},
|
||||
options: {
|
||||
title: "Aukerak",
|
||||
language: "Hizkuntza",
|
||||
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" },
|
||||
tone: { label: "Tonua", dark: "Iluna", darker: "Ilunagoa" },
|
||||
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" },
|
||||
speedUnit: { label: "Abiadura-unitatea" },
|
||||
transfers: { label: "Transferentziak", single: "Banaka", multi: "Anitz" },
|
||||
atOnce: "Aldi berean",
|
||||
editor: { label: "Editorea", builtin: "Barnekoa", custom: "Pertsonalizatua" },
|
||||
systemDefault: "Sistemaren lehenetsia",
|
||||
resetDefault: "Berrezarri lehenetsira",
|
||||
finishedTransfers: { label: "Amaitutako transferentziak", autoClear: "Garbitu", keep: "Mantendu" },
|
||||
},
|
||||
kinds: {
|
||||
folder: "Karpeta",
|
||||
symlink: "Esteka sinbolikoa",
|
||||
file: "Fitxategia",
|
||||
image: "Irudia",
|
||||
audio: "Audioa",
|
||||
video: "Bideoa",
|
||||
archive: "Artxiboa",
|
||||
code: "Kodea",
|
||||
document: "Dokumentua",
|
||||
},
|
||||
columns: {
|
||||
name: "Izena",
|
||||
size: "Tamaina",
|
||||
mtime: "Aldatze-data",
|
||||
kind: "Mota",
|
||||
rights: "Baimenak",
|
||||
owner: "Jabea",
|
||||
group: "Taldea",
|
||||
},
|
||||
panel: {
|
||||
choosePanel: "Aukeratu panel honek zer erakusten duen",
|
||||
copy: "Bidali",
|
||||
copyToRight: "Bidali eskuineko panelera (F5)",
|
||||
copyToLeft: "Bidali ezkerreko panelera (F5)",
|
||||
edit: "Editatu",
|
||||
editFile: "Editatu fitxategia (F4)",
|
||||
rename: "Berrizendatu (F2)",
|
||||
newFolder: "Karpeta berria (F7)",
|
||||
newFile: "Fitxategi berria",
|
||||
permissions: "Baimenak",
|
||||
delete: "Ezabatu",
|
||||
deleteHint: "Ezabatu (F8 / Del)",
|
||||
parentDir: "Karpeta gurasoa (Atzera-tekla)",
|
||||
refresh: "Freskatu",
|
||||
clearFilter: "Garbitu iragazkia (Esc)",
|
||||
sshConfig: "~/.ssh/config",
|
||||
connectingTo: "{name}(e)ra konektatzen…",
|
||||
emptyDir: "Karpeta hutsa",
|
||||
noMatches: "Bat-etortzerik ez",
|
||||
matches: { one: "bat-etortze {count} · Esc", other: "{count} bat-etortze · Esc" },
|
||||
},
|
||||
connMgr: {
|
||||
title: "Konexioak",
|
||||
importWinscp: "Inportatu WinSCPtik…",
|
||||
importWinscpHint: "Inportatu saioak WinSCP.ini esportazio batetik",
|
||||
newConnection: "Konexioa",
|
||||
newConnectionHint: "Konexio berria",
|
||||
newStack: "Taldea",
|
||||
newStackHint: "Talde berria",
|
||||
expand: "Zabaldu",
|
||||
collapse: "Tolestu",
|
||||
renameStack: "Berrizendatu taldea",
|
||||
deleteStack: "Ezabatu taldea",
|
||||
openStack: "Ireki taldea fitxetan",
|
||||
empty: "hutsik",
|
||||
connectionsHeader: "Konexioak",
|
||||
noneTitle: "Oraindik ez dago gordetako konexiorik.",
|
||||
noneBody: "Sortu bat edo inportatu zure WinSCP saioak.",
|
||||
name: "Izena",
|
||||
namePlaceholder: "nire zerbitzaria",
|
||||
host: "Ostalaria",
|
||||
port: "Ataka",
|
||||
stack: "Taldea",
|
||||
stackNone: "(bat ere ez)",
|
||||
username: "Erabiltzailea",
|
||||
remotePath: "Urruneko bidea (aukerakoa)",
|
||||
remotePathPlaceholder: "hasierako karpeta",
|
||||
auth: "Autentifikazioa",
|
||||
authPassword: "Pasahitza",
|
||||
authKey: "Gako pribatua",
|
||||
password: "Pasahitza",
|
||||
privateKeyFile: "Gako pribatuaren fitxategia",
|
||||
passphrase: "Pasaesaldia (aukerakoa)",
|
||||
localDir: "Karpeta lokala (aukerakoa)",
|
||||
localDirPlaceholder: "~/proiektuak/nire-zerbitzaria",
|
||||
localDirHint: "Beste panela hemen irekiko da Lokalean dagoenean eta nabigatu ez denean.",
|
||||
more: "Gehiago…",
|
||||
less: "Gutxiago…",
|
||||
selectPrompt: "Hautatu konexio bat edo sortu berri bat.",
|
||||
newStackTitle: "Talde berria",
|
||||
newStackPrompt: "taldearen izena",
|
||||
renameStackTitle: "Berrizendatu taldea",
|
||||
deleteStackTitle: "Ezabatu taldea",
|
||||
deleteStackBody: 'Ezabatu "{name}" taldea? Bere konexioak mantendu egingo dira, taldekatu gabe.',
|
||||
deleteConnectionTitle: "Ezabatu konexioa",
|
||||
deleteConnectionBody: 'Ezabatu "{name}"?',
|
||||
},
|
||||
editor: {
|
||||
noFileTitle: "Ez dago editatzeko fitxategirik",
|
||||
noFileBody: "Leiho hau editore-testuingururik gabe ireki da. Ireki fitxategiak aplikaziotik.",
|
||||
wrap: "Doitu",
|
||||
wrapHint: "Doitu lerro luzeak — lerro-zenbakiak ezkutatzen ditu ({alt}Z)",
|
||||
findHint: "Bilatu ({mod}F)",
|
||||
findPlaceholder: "Bilatu fitxategian",
|
||||
prevMatch: "Aurreko bat-etortzea (⇧↵)",
|
||||
nextMatch: "Hurrengo bat-etortzea (↵)",
|
||||
closeHint: "Itxi (Esc)",
|
||||
disconnected: "{name}(e)tik deskonektatuta — gordetzeak huts egingo du, baina zure testua leiho honetan gordeta dago.",
|
||||
cantOpen: "Ezin da {name} ireki",
|
||||
closeWindow: "Itxi leihoa",
|
||||
shortcuts: "{mod}S gorde · {mod}F bilatu · {mod}W itxi",
|
||||
lines: { one: "lerro {count}", other: "{count} lerro" },
|
||||
close: "Itxi",
|
||||
save: "Gorde",
|
||||
saveChangesTitle: 'Gorde "{name}"(r)en aldaketak?',
|
||||
saveChangesBody: "Zure aldaketak galduko dira gordetzen ez badituzu.",
|
||||
dontSave: "Ez gorde",
|
||||
countLabel: "{total}(e)tik {current}",
|
||||
noMatches: "Bat-etortzerik ez",
|
||||
},
|
||||
transfer: {
|
||||
summary: "{active} aktibo · {finished} amaituta",
|
||||
clearFinished: "Garbitu amaituak",
|
||||
cancel: "Utzi",
|
||||
dismiss: "Baztertu",
|
||||
showDetails: "Erakutsi transferentzien xehetasunak",
|
||||
copying: "{name} kopiatzen · %{percent}{speed}",
|
||||
queued: "{count} ilaran",
|
||||
failed: { one: "transferentzia {count}(e)k huts egin du", other: "{count} transferentziak huts egin dute" },
|
||||
finished: "Transferentziak amaituta",
|
||||
ready: "Prest",
|
||||
items: "{count} elementu",
|
||||
selected: "{count} hautatuta",
|
||||
queuedState: "Ilaran",
|
||||
scanning: "Aztertzen…",
|
||||
failedState: "Huts egin du",
|
||||
cancelled: "Bertan behera utzita",
|
||||
completedFiles: { one: "fitxategi {count} · {size}", other: "{count} fitxategi · {size}" },
|
||||
left: "{eta} geratzen da",
|
||||
},
|
||||
dialogs: {
|
||||
chmodTitle: "Baimenak",
|
||||
chmodItems: "{count} elementu",
|
||||
read: "Irakurri",
|
||||
write: "Idatzi",
|
||||
execute: "Exekutatu",
|
||||
roleOwner: "Jabea",
|
||||
roleGroup: "Taldea",
|
||||
roleOthers: "Besteak",
|
||||
octal: "Zortzitarra",
|
||||
mergeFolder: "Batu karpeta",
|
||||
replace: "Ordeztu",
|
||||
merge: "Batu",
|
||||
mergeAll: "Batu guztiak",
|
||||
replaceAll: "Ordeztu guztiak",
|
||||
skip: "Saltatu",
|
||||
skipAll: "Saltatu guztiak",
|
||||
ofTotal: "{total}(e)tik {index}",
|
||||
overwriteDirBody: "Helmugan izen bereko karpeta bat dago jada. Batzeak barneko fitxategiak ordeztu ditzake.",
|
||||
overwriteFileBody: "Helmugan izen bereko fitxategi bat dago jada.",
|
||||
source: "Iturburua",
|
||||
destination: "Helmuga",
|
||||
newer: "berriagoa",
|
||||
},
|
||||
toasts: {
|
||||
movedToTrash: '"{name}" zakarrontzira eraman da',
|
||||
editingExternal: "{name} editatzen — gordetzean automatikoki igotzen da",
|
||||
nothingToCopy: "Ez dago kopiatzeko ezer (esteka sinbolikoak saltatu egiten dira)",
|
||||
nothingToCopyAllSkipped: "Ez dago kopiatzeko ezer — gatazkan zeuden elementu guztiak saltatu dira",
|
||||
sameFolder: "Bi panelek karpeta bera erakusten dute",
|
||||
sameFile: '"{name}" saltatu da — fitxategi bera da',
|
||||
typeMismatch: '"{name}" saltatu da — beste mota bateko elementu bat dago bidean',
|
||||
transferFailed: '"{name}" transferentziak huts egin du: {error}',
|
||||
unknownError: "errore ezezaguna",
|
||||
saved: "{name} gorde da",
|
||||
disconnectedReason: "{title}: deskonektatuta ({reason})",
|
||||
imported: { one: "konexio {count} inportatu da", other: "{count} konexio inportatu dira" },
|
||||
importedIntoStacks: { one: " talde berri {count}ean", other: " {count} talde berritan" },
|
||||
importedSkipped: " ({skipped} saltatuta)",
|
||||
leftoverTitle: "Geratutako editatutako fitxategiak",
|
||||
leftoverMore: ", +{count} gehiago",
|
||||
leftoverBody: {
|
||||
one: "Aurreko saio batek editatutako aldi baterako fitxategi {count} utzi zuen ({names}). Garbitu?",
|
||||
other: "Aurreko saio batek editatutako aldi baterako {count} fitxategi utzi zituen ({names}). Garbitu?",
|
||||
},
|
||||
leftoverClear: "Garbitu",
|
||||
},
|
||||
fileActions: {
|
||||
deleteTitle: "Ezabatu",
|
||||
deleteBody: "Ezabatu {what}? Karpetak errekurtsiboki ezabatzen dira. Ezin da desegin.",
|
||||
deleteConfirm: "Ezabatu",
|
||||
renameTitle: 'Berrizendatu "{name}"',
|
||||
renameConfirm: "Berrizendatu",
|
||||
newFolderTitle: "Karpeta berria",
|
||||
newFolderPlaceholder: "karpetaren izena",
|
||||
newFileTitle: "Fitxategi berria",
|
||||
newFilePlaceholder: "fitxategiaren izena",
|
||||
createConfirm: "Sortu",
|
||||
itemsCount: "{count} elementu",
|
||||
},
|
||||
menu: {
|
||||
about: "{app} (r)i buruz",
|
||||
checkForUpdates: "Bilatu eguneratzeak…",
|
||||
quit: "Irten",
|
||||
file: "Fitxategia",
|
||||
newTab: "Fitxa berria",
|
||||
closeTab: "Itxi fitxa",
|
||||
newFolder: "Karpeta berria",
|
||||
newFile: "Fitxategi berria",
|
||||
editFile: "Editatu fitxategia",
|
||||
connect: "Konektatu",
|
||||
connectionManager: "Konexio-kudeatzailea…",
|
||||
edit: "Editatu",
|
||||
view: "Ikusi",
|
||||
refresh: "Freskatu",
|
||||
window: "Leihoa",
|
||||
},
|
||||
ctx: {
|
||||
size: "Tamaina",
|
||||
modified: "Aldatze-data",
|
||||
kind: "Mota",
|
||||
rights: "Baimenak",
|
||||
owner: "Jabea",
|
||||
group: "Taldea",
|
||||
revealFinder: "Erakutsi Finder-en",
|
||||
revealExplorer: "Erakutsi Explorer-en",
|
||||
revealFileManager: "Erakutsi fitxategi-kudeatzailean",
|
||||
moveToTrash: "Eraman zakarrontzira",
|
||||
moveToRecycleBin: "Eraman birziklatze-ontzira",
|
||||
newFolder: "Karpeta berria",
|
||||
newFile: "Fitxategi berria",
|
||||
refresh: "Freskatu",
|
||||
open: "Ireki",
|
||||
openDefault: "Ireki lehenetsitako aplikazioarekin",
|
||||
edit: "Editatu",
|
||||
copyToOther: "Bidali beste panelera",
|
||||
rename: "Berrizendatu",
|
||||
permissions: "Baimenak",
|
||||
copyPath: "Kopiatu bidea",
|
||||
delete: "Ezabatu",
|
||||
columns: "Zutabeak",
|
||||
tabNew: "Fitxa berria",
|
||||
tabClose: "Itxi fitxa",
|
||||
tabCloseOthers: "Itxi beste fitxak",
|
||||
tabCloseRight: "Itxi eskuineko fitxak",
|
||||
tabDisconnect: "Deskonektatu",
|
||||
trashTooltip: "Finder-en «Jarri berriro» ez dago erabilgarri hemendik zakarrontzira botatako elementuentzat",
|
||||
},
|
||||
errors: {
|
||||
sshAgentUnavailable: "ssh-agent ez dago erabilgarri (SSH_AUTH_SOCK ez dago ezarrita)",
|
||||
identityFileNotFound: "identitate-fitxategia ez da aurkitu: {path}",
|
||||
sessionNotConnected: '"{id}" saioa ez dago konektatuta',
|
||||
localCannotDisconnect: "Saio lokala ezin da deskonektatu",
|
||||
notADirectory: '"{path}" badago eta ez da karpeta bat',
|
||||
sameFileTransfer: '"{name}": iturburua eta helmuga fitxategi bera dira',
|
||||
sameFileNoName: "Iturburua eta helmuga fitxategi bera dira",
|
||||
folderOntoItself: "Ezin da karpeta bat bere gainean kopiatu",
|
||||
folderIntoItself: "Ezin da karpeta bat bere barnean kopiatu",
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,315 @@
|
||||
// French translations. Missing keys fall back to English (see en.ts / index.ts).
|
||||
export const fr = {
|
||||
common: {
|
||||
cancel: "Annuler",
|
||||
save: "Enregistrer",
|
||||
close: "Fermer",
|
||||
delete: "Supprimer",
|
||||
rename: "Renommer",
|
||||
create: "Créer",
|
||||
apply: "Appliquer",
|
||||
choose: "Choisir…",
|
||||
connect: "Connecter",
|
||||
local: "Local",
|
||||
},
|
||||
app: {
|
||||
notConnectedTitle: "Non connecté à l'application",
|
||||
notConnectedBody: "Cette page s'exécute dans un navigateur classique. Lancez l'application de bureau pour l'utiliser.",
|
||||
managerOpen: "Le gestionnaire de connexions est ouvert.",
|
||||
showConnections: "Afficher les connexions",
|
||||
},
|
||||
tabBar: {
|
||||
connections: "Connexions",
|
||||
manageConnections: "Gérer les connexions",
|
||||
newTab: "Nouvel onglet ({mod}T)",
|
||||
closeTab: "Fermer l'onglet ({mod}W)",
|
||||
options: "Options",
|
||||
update: "Mise à jour disponible",
|
||||
updating: "Mise à jour",
|
||||
restartToUpdate: "Redémarrer pour appliquer",
|
||||
updateTooltip: "Mise à jour disponible : miniSCP {version}",
|
||||
},
|
||||
options: {
|
||||
title: "Options",
|
||||
language: "Langue",
|
||||
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" },
|
||||
tone: { label: "Ton", dark: "Sombre", darker: "Plus sombre" },
|
||||
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%" },
|
||||
speedUnit: { label: "Unité de vitesse" },
|
||||
transfers: { label: "Transferts", single: "Un", multi: "Plusieurs" },
|
||||
atOnce: "Simultanés",
|
||||
editor: { label: "Éditeur", builtin: "Intégré", custom: "Personnalisé" },
|
||||
systemDefault: "Valeur par défaut du système",
|
||||
resetDefault: "Réinitialiser par défaut",
|
||||
finishedTransfers: { label: "Transferts terminés", autoClear: "Effacer auto.", keep: "Garder" },
|
||||
},
|
||||
kinds: {
|
||||
folder: "Dossier",
|
||||
symlink: "Lien symbolique",
|
||||
file: "Fichier",
|
||||
image: "Image",
|
||||
audio: "Audio",
|
||||
video: "Vidéo",
|
||||
archive: "Archive",
|
||||
code: "Code",
|
||||
document: "Document",
|
||||
},
|
||||
columns: {
|
||||
name: "Nom",
|
||||
size: "Taille",
|
||||
mtime: "Modifié",
|
||||
kind: "Type",
|
||||
rights: "Droits",
|
||||
owner: "Propriétaire",
|
||||
group: "Groupe",
|
||||
},
|
||||
panel: {
|
||||
choosePanel: "Choisir ce qu'affiche ce panneau",
|
||||
copy: "Envoyer",
|
||||
copyToRight: "Envoyer vers le panneau droit (F5)",
|
||||
copyToLeft: "Envoyer vers le panneau gauche (F5)",
|
||||
edit: "Éditer",
|
||||
editFile: "Éditer le fichier (F4)",
|
||||
rename: "Renommer (F2)",
|
||||
newFolder: "Nouveau dossier (F7)",
|
||||
newFile: "Nouveau fichier",
|
||||
permissions: "Autorisations",
|
||||
delete: "Supprimer",
|
||||
deleteHint: "Supprimer (F8 / Suppr)",
|
||||
parentDir: "Dossier parent (Retour arrière)",
|
||||
refresh: "Actualiser",
|
||||
clearFilter: "Effacer le filtre (Échap)",
|
||||
sshConfig: "~/.ssh/config",
|
||||
connectingTo: "Connexion à {name}…",
|
||||
emptyDir: "Dossier vide",
|
||||
noMatches: "Aucun résultat",
|
||||
matches: { one: "{count} résultat · Échap", other: "{count} résultats · Échap" },
|
||||
},
|
||||
connMgr: {
|
||||
title: "Connexions",
|
||||
importWinscp: "Importer depuis WinSCP…",
|
||||
importWinscpHint: "Importer des sessions depuis un export WinSCP.ini",
|
||||
newConnection: "Connexion",
|
||||
newConnectionHint: "Nouvelle connexion",
|
||||
newStack: "Groupe",
|
||||
newStackHint: "Nouveau groupe",
|
||||
expand: "Développer",
|
||||
collapse: "Réduire",
|
||||
renameStack: "Renommer le groupe",
|
||||
deleteStack: "Supprimer le groupe",
|
||||
openStack: "Ouvrir le groupe dans des onglets",
|
||||
empty: "vide",
|
||||
connectionsHeader: "Connexions",
|
||||
noneTitle: "Aucune connexion enregistrée.",
|
||||
noneBody: "Créez-en une ou importez vos sessions WinSCP.",
|
||||
name: "Nom",
|
||||
namePlaceholder: "mon serveur",
|
||||
host: "Hôte",
|
||||
port: "Port",
|
||||
stack: "Groupe",
|
||||
stackNone: "(aucun)",
|
||||
username: "Utilisateur",
|
||||
remotePath: "Chemin distant (facultatif)",
|
||||
remotePathPlaceholder: "dossier personnel",
|
||||
auth: "Authentification",
|
||||
authPassword: "Mot de passe",
|
||||
authKey: "Clé privée",
|
||||
password: "Mot de passe",
|
||||
privateKeyFile: "Fichier de clé privée",
|
||||
passphrase: "Phrase secrète (facultative)",
|
||||
localDir: "Dossier local (facultatif)",
|
||||
localDirPlaceholder: "~/projets/mon-serveur",
|
||||
localDirHint: "L'autre panneau s'ouvre ici lorsqu'il est sur Local et n'a pas été parcouru.",
|
||||
more: "Plus…",
|
||||
less: "Moins…",
|
||||
selectPrompt: "Sélectionnez une connexion ou créez-en une nouvelle.",
|
||||
newStackTitle: "Nouveau groupe",
|
||||
newStackPrompt: "nom du groupe",
|
||||
renameStackTitle: "Renommer le groupe",
|
||||
deleteStackTitle: "Supprimer le groupe",
|
||||
deleteStackBody: 'Supprimer le groupe « {name} » ? Ses connexions sont conservées, sans groupe.',
|
||||
deleteConnectionTitle: "Supprimer la connexion",
|
||||
deleteConnectionBody: 'Supprimer « {name} » ?',
|
||||
},
|
||||
editor: {
|
||||
noFileTitle: "Aucun fichier à éditer",
|
||||
noFileBody: "Cette fenêtre a été ouverte sans contexte d'éditeur. Ouvrez des fichiers depuis l'application.",
|
||||
wrap: "Retour",
|
||||
wrapHint: "Retour à la ligne — masque les numéros de ligne ({alt}Z)",
|
||||
findHint: "Rechercher ({mod}F)",
|
||||
findPlaceholder: "Rechercher dans le fichier",
|
||||
prevMatch: "Résultat précédent (⇧↵)",
|
||||
nextMatch: "Résultat suivant (↵)",
|
||||
closeHint: "Fermer (Échap)",
|
||||
disconnected: "Déconnecté de {name} — l'enregistrement échouera, mais votre texte est conservé dans cette fenêtre.",
|
||||
cantOpen: "Impossible d'ouvrir {name}",
|
||||
closeWindow: "Fermer la fenêtre",
|
||||
shortcuts: "{mod}S enregistrer · {mod}F rechercher · {mod}W fermer",
|
||||
lines: { one: "{count} ligne", other: "{count} lignes" },
|
||||
close: "Fermer",
|
||||
save: "Enregistrer",
|
||||
saveChangesTitle: 'Enregistrer les modifications de « {name} » ?',
|
||||
saveChangesBody: "Vos modifications seront perdues si vous ne les enregistrez pas.",
|
||||
dontSave: "Ne pas enregistrer",
|
||||
countLabel: "{current} sur {total}",
|
||||
noMatches: "Aucun résultat",
|
||||
},
|
||||
transfer: {
|
||||
summary: "{active} en cours · {finished} terminés",
|
||||
clearFinished: "Effacer les terminés",
|
||||
cancel: "Annuler",
|
||||
dismiss: "Ignorer",
|
||||
showDetails: "Afficher les détails des transferts",
|
||||
copying: "Copie de {name} · {percent}%{speed}",
|
||||
queued: "{count} en file",
|
||||
failed: { one: "{count} transfert échoué", other: "{count} transferts échoués" },
|
||||
finished: "Transferts terminés",
|
||||
ready: "Prêt",
|
||||
items: "{count} éléments",
|
||||
selected: "{count} sélectionnés",
|
||||
queuedState: "En file",
|
||||
scanning: "Analyse…",
|
||||
failedState: "Échec",
|
||||
cancelled: "Annulé",
|
||||
completedFiles: { one: "{count} fichier · {size}", other: "{count} fichiers · {size}" },
|
||||
left: "{eta} restant",
|
||||
},
|
||||
dialogs: {
|
||||
chmodTitle: "Autorisations",
|
||||
chmodItems: "{count} éléments",
|
||||
read: "Lecture",
|
||||
write: "Écriture",
|
||||
execute: "Exécution",
|
||||
roleOwner: "Propriétaire",
|
||||
roleGroup: "Groupe",
|
||||
roleOthers: "Autres",
|
||||
octal: "Octal",
|
||||
mergeFolder: "Fusionner le dossier",
|
||||
replace: "Remplacer",
|
||||
merge: "Fusionner",
|
||||
mergeAll: "Tout fusionner",
|
||||
replaceAll: "Tout remplacer",
|
||||
skip: "Ignorer",
|
||||
skipAll: "Tout ignorer",
|
||||
ofTotal: "{index} sur {total}",
|
||||
overwriteDirBody: "La destination contient déjà un dossier de ce nom. La fusion peut remplacer des fichiers.",
|
||||
overwriteFileBody: "La destination contient déjà un fichier de ce nom.",
|
||||
source: "Source",
|
||||
destination: "Destination",
|
||||
newer: "plus récent",
|
||||
},
|
||||
toasts: {
|
||||
movedToTrash: '« {name} » mis à la corbeille',
|
||||
editingExternal: "Édition de {name} — les enregistrements sont envoyés automatiquement",
|
||||
nothingToCopy: "Rien à copier (les liens symboliques sont ignorés)",
|
||||
nothingToCopyAllSkipped: "Rien à copier — tous les éléments en conflit ont été ignorés",
|
||||
sameFolder: "Les deux panneaux affichent le même dossier",
|
||||
sameFile: "« {name} » ignoré — c'est le même fichier",
|
||||
typeMismatch: "« {name} » ignoré — un autre type d'élément est déjà là",
|
||||
transferFailed: 'Le transfert « {name} » a échoué : {error}',
|
||||
unknownError: "erreur inconnue",
|
||||
saved: "{name} enregistré",
|
||||
disconnectedReason: "{title} : déconnecté ({reason})",
|
||||
imported: { one: "{count} connexion importée", other: "{count} connexions importées" },
|
||||
importedIntoStacks: { one: " dans {count} nouveau groupe", other: " dans {count} nouveaux groupes" },
|
||||
importedSkipped: " ({skipped} ignorées)",
|
||||
leftoverTitle: "Fichiers édités restants",
|
||||
leftoverMore: ", +{count} de plus",
|
||||
leftoverBody: {
|
||||
one: "Une session précédente a laissé {count} fichier édité temporaire ({names}). Les supprimer ?",
|
||||
other: "Une session précédente a laissé {count} fichiers édités temporaires ({names}). Les supprimer ?",
|
||||
},
|
||||
leftoverClear: "Supprimer",
|
||||
},
|
||||
fileActions: {
|
||||
deleteTitle: "Supprimer",
|
||||
deleteBody: "Supprimer {what} ? Les dossiers sont supprimés récursivement. Action irréversible.",
|
||||
deleteConfirm: "Supprimer",
|
||||
renameTitle: 'Renommer « {name} »',
|
||||
renameConfirm: "Renommer",
|
||||
newFolderTitle: "Nouveau dossier",
|
||||
newFolderPlaceholder: "nom du dossier",
|
||||
newFileTitle: "Nouveau fichier",
|
||||
newFilePlaceholder: "nom du fichier",
|
||||
createConfirm: "Créer",
|
||||
itemsCount: "{count} éléments",
|
||||
},
|
||||
menu: {
|
||||
about: "À propos de {app}",
|
||||
checkForUpdates: "Rechercher des mises à jour…",
|
||||
quit: "Quitter",
|
||||
file: "Fichier",
|
||||
newTab: "Nouvel onglet",
|
||||
closeTab: "Fermer l'onglet",
|
||||
newFolder: "Nouveau dossier",
|
||||
newFile: "Nouveau fichier",
|
||||
editFile: "Éditer le fichier",
|
||||
connect: "Connecter",
|
||||
connectionManager: "Gestionnaire de connexions…",
|
||||
edit: "Édition",
|
||||
view: "Affichage",
|
||||
refresh: "Actualiser",
|
||||
window: "Fenêtre",
|
||||
},
|
||||
ctx: {
|
||||
size: "Taille",
|
||||
modified: "Modifié",
|
||||
kind: "Type",
|
||||
rights: "Droits",
|
||||
owner: "Propriétaire",
|
||||
group: "Groupe",
|
||||
revealFinder: "Afficher dans le Finder",
|
||||
revealExplorer: "Afficher dans l'Explorateur",
|
||||
revealFileManager: "Afficher dans le gestionnaire de fichiers",
|
||||
moveToTrash: "Mettre à la corbeille",
|
||||
moveToRecycleBin: "Mettre à la corbeille",
|
||||
newFolder: "Nouveau dossier",
|
||||
newFile: "Nouveau fichier",
|
||||
refresh: "Actualiser",
|
||||
open: "Ouvrir",
|
||||
openDefault: "Ouvrir avec l'application par défaut",
|
||||
edit: "Éditer",
|
||||
copyToOther: "Envoyer vers l'autre panneau",
|
||||
rename: "Renommer",
|
||||
permissions: "Autorisations",
|
||||
copyPath: "Copier le chemin",
|
||||
delete: "Supprimer",
|
||||
columns: "Colonnes",
|
||||
tabNew: "Nouvel onglet",
|
||||
tabClose: "Fermer l'onglet",
|
||||
tabCloseOthers: "Fermer les autres onglets",
|
||||
tabCloseRight: "Fermer les onglets à droite",
|
||||
tabDisconnect: "Déconnecter",
|
||||
trashTooltip: "La restauration du Finder n'est pas disponible pour les éléments mis à la corbeille d'ici",
|
||||
},
|
||||
errors: {
|
||||
sshAgentUnavailable: "ssh-agent indisponible (SSH_AUTH_SOCK non défini)",
|
||||
identityFileNotFound: "fichier d'identité introuvable : {path}",
|
||||
sessionNotConnected: "La session « {id} » n'est pas connectée",
|
||||
localCannotDisconnect: "La session locale ne peut pas être déconnectée",
|
||||
notADirectory: "« {path} » existe et n'est pas un dossier",
|
||||
sameFileTransfer: '« {name} » : la source et la destination sont le même fichier',
|
||||
sameFileNoName: "La source et la destination sont le même fichier",
|
||||
folderOntoItself: "Impossible de copier un dossier sur lui-même",
|
||||
folderIntoItself: "Impossible de copier un dossier dans lui-même",
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { SupportedLocale } from "../types";
|
||||
import { de } from "./de";
|
||||
import { en } from "./en";
|
||||
import { es } from "./es";
|
||||
import { eu } from "./eu";
|
||||
import { fr } from "./fr";
|
||||
import { ja } from "./ja";
|
||||
import { zh } from "./zh";
|
||||
|
||||
/** A translation tree: leaves are strings; a leaf may be a plural map. */
|
||||
export type Messages = { readonly [key: string]: string | Messages };
|
||||
|
||||
export const DICTS: Record<SupportedLocale, Messages> = { en, es, eu, fr, de, zh, ja };
|
||||
|
||||
/** Order shown in the language picker. */
|
||||
export const SUPPORTED: readonly SupportedLocale[] = ["en", "es", "eu", "fr", "de", "zh", "ja"];
|
||||
|
||||
/** Each language named in itself (endonyms) for the picker. */
|
||||
export const LOCALE_LABELS: Record<SupportedLocale, string> = {
|
||||
en: "English",
|
||||
es: "Español",
|
||||
eu: "Euskara",
|
||||
fr: "Français",
|
||||
de: "Deutsch",
|
||||
zh: "中文",
|
||||
ja: "日本語",
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the effective locale: an explicit choice wins; "system" maps the OS
|
||||
* BCP-47 tag (e.g. "es-ES", "zh-Hans-CN") to a supported language, else English.
|
||||
*/
|
||||
export function resolveLocale(
|
||||
setting: "system" | SupportedLocale,
|
||||
osLocale: string | undefined,
|
||||
): SupportedLocale {
|
||||
if (setting !== "system") return setting;
|
||||
const tag = (osLocale ?? "en").toLowerCase();
|
||||
const primary = tag.split("-")[0];
|
||||
return (SUPPORTED as readonly string[]).includes(primary)
|
||||
? (primary as SupportedLocale)
|
||||
: "en";
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
// Japanese translations. Fall back to English for missing keys.
|
||||
// TODO(i18n): AI draft — needs native-speaker review.
|
||||
export const ja = {
|
||||
common: {
|
||||
cancel: "キャンセル",
|
||||
save: "保存",
|
||||
close: "閉じる",
|
||||
delete: "削除",
|
||||
rename: "名前を変更",
|
||||
create: "作成",
|
||||
apply: "適用",
|
||||
choose: "選択…",
|
||||
connect: "接続",
|
||||
local: "ローカル",
|
||||
},
|
||||
app: {
|
||||
notConnectedTitle: "アプリに接続されていません",
|
||||
notConnectedBody: "このページは通常のブラウザで動作しています。デスクトップアプリを起動して使用してください。",
|
||||
managerOpen: "接続マネージャーが開いています。",
|
||||
showConnections: "接続を表示",
|
||||
},
|
||||
tabBar: {
|
||||
connections: "接続",
|
||||
manageConnections: "接続を管理",
|
||||
newTab: "新しいタブ ({mod}T)",
|
||||
closeTab: "タブを閉じる ({mod}W)",
|
||||
options: "オプション",
|
||||
update: "アップデートあり",
|
||||
updating: "更新中",
|
||||
restartToUpdate: "再起動して適用",
|
||||
updateTooltip: "アップデートがあります: miniSCP {version}",
|
||||
},
|
||||
options: {
|
||||
title: "オプション",
|
||||
language: "言語",
|
||||
system: "システム",
|
||||
skin: {
|
||||
label: "外観",
|
||||
modern: "モダン",
|
||||
win7: "Windows 7",
|
||||
restartHint: "次回 miniSCP を起動したときに適用されます",
|
||||
},
|
||||
theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" },
|
||||
tone: { label: "トーン", dark: "ダーク", darker: "より暗く" },
|
||||
accent: { label: "アクセント" },
|
||||
accentNames: {
|
||||
graphite: "グラファイト",
|
||||
blue: "ブルー",
|
||||
indigo: "インディゴ",
|
||||
purple: "パープル",
|
||||
rose: "ローズ",
|
||||
orange: "オレンジ",
|
||||
green: "グリーン",
|
||||
teal: "ティール",
|
||||
},
|
||||
fileIcons: { label: "ファイルアイコン", plain: "シンプル", multi: "カラー" },
|
||||
interface: { label: "インターフェース", smaller: "縮小", larger: "拡大", reset: "100%に戻す" },
|
||||
speedUnit: { label: "速度の単位" },
|
||||
transfers: { label: "転送", single: "1件ずつ", multi: "複数" },
|
||||
atOnce: "同時数",
|
||||
editor: { label: "エディタ", builtin: "内蔵", custom: "カスタム" },
|
||||
systemDefault: "システムの既定",
|
||||
resetDefault: "既定に戻す",
|
||||
finishedTransfers: { label: "完了した転送", autoClear: "自動クリア", keep: "保持" },
|
||||
},
|
||||
kinds: {
|
||||
folder: "フォルダ",
|
||||
symlink: "シンボリックリンク",
|
||||
file: "ファイル",
|
||||
image: "画像",
|
||||
audio: "オーディオ",
|
||||
video: "動画",
|
||||
archive: "アーカイブ",
|
||||
code: "コード",
|
||||
document: "ドキュメント",
|
||||
},
|
||||
columns: {
|
||||
name: "名前",
|
||||
size: "サイズ",
|
||||
mtime: "更新日時",
|
||||
kind: "種類",
|
||||
rights: "権限",
|
||||
owner: "所有者",
|
||||
group: "グループ",
|
||||
},
|
||||
panel: {
|
||||
choosePanel: "このパネルに表示する内容を選択",
|
||||
copy: "送信",
|
||||
copyToRight: "右のパネルへ送信 (F5)",
|
||||
copyToLeft: "左のパネルへ送信 (F5)",
|
||||
edit: "編集",
|
||||
editFile: "ファイルを編集 (F4)",
|
||||
rename: "名前を変更 (F2)",
|
||||
newFolder: "新規フォルダ (F7)",
|
||||
newFile: "新規ファイル",
|
||||
permissions: "アクセス権",
|
||||
delete: "削除",
|
||||
deleteHint: "削除 (F8 / Del)",
|
||||
parentDir: "親フォルダ (Backspace)",
|
||||
refresh: "更新",
|
||||
clearFilter: "フィルターをクリア (Esc)",
|
||||
sshConfig: "~/.ssh/config",
|
||||
connectingTo: "{name} に接続中…",
|
||||
emptyDir: "空のフォルダ",
|
||||
noMatches: "一致なし",
|
||||
matches: { other: "{count} 件一致 · Esc" },
|
||||
},
|
||||
connMgr: {
|
||||
title: "接続",
|
||||
importWinscp: "WinSCP からインポート…",
|
||||
importWinscpHint: "WinSCP.ini エクスポートからセッションをインポート",
|
||||
newConnection: "接続",
|
||||
newConnectionHint: "新規接続",
|
||||
newStack: "グループ",
|
||||
newStackHint: "新規グループ",
|
||||
expand: "展開",
|
||||
collapse: "折りたたむ",
|
||||
renameStack: "グループ名を変更",
|
||||
deleteStack: "グループを削除",
|
||||
openStack: "グループをタブで開く",
|
||||
empty: "空",
|
||||
connectionsHeader: "接続",
|
||||
noneTitle: "保存された接続はまだありません。",
|
||||
noneBody: "作成するか、WinSCP セッションをインポートしてください。",
|
||||
name: "名前",
|
||||
namePlaceholder: "マイサーバー",
|
||||
host: "ホスト",
|
||||
port: "ポート",
|
||||
stack: "グループ",
|
||||
stackNone: "(なし)",
|
||||
username: "ユーザー名",
|
||||
remotePath: "リモートパス(任意)",
|
||||
remotePathPlaceholder: "ホームディレクトリ",
|
||||
auth: "認証",
|
||||
authPassword: "パスワード",
|
||||
authKey: "秘密鍵",
|
||||
password: "パスワード",
|
||||
privateKeyFile: "秘密鍵ファイル",
|
||||
passphrase: "パスフレーズ(任意)",
|
||||
localDir: "ローカルディレクトリ(任意)",
|
||||
localDirPlaceholder: "~/projects/my-server",
|
||||
localDirHint: "もう一方のパネルがローカルで未操作のとき、ここが開きます。",
|
||||
more: "詳細…",
|
||||
less: "閉じる…",
|
||||
selectPrompt: "接続を選択するか、新しく作成してください。",
|
||||
newStackTitle: "新規グループ",
|
||||
newStackPrompt: "グループ名",
|
||||
renameStackTitle: "グループ名を変更",
|
||||
deleteStackTitle: "グループを削除",
|
||||
deleteStackBody: 'グループ "{name}" を削除しますか?含まれる接続は残り、グループ解除されます。',
|
||||
deleteConnectionTitle: "接続を削除",
|
||||
deleteConnectionBody: '"{name}" を削除しますか?',
|
||||
},
|
||||
editor: {
|
||||
noFileTitle: "編集するファイルがありません",
|
||||
noFileBody: "このウィンドウはエディタコンテキストなしで開かれました。アプリからファイルを開いてください。",
|
||||
wrap: "折り返し",
|
||||
wrapHint: "長い行を折り返す — 行番号を非表示 ({alt}Z)",
|
||||
findHint: "検索 ({mod}F)",
|
||||
findPlaceholder: "ファイル内を検索",
|
||||
prevMatch: "前の一致 (⇧↵)",
|
||||
nextMatch: "次の一致 (↵)",
|
||||
closeHint: "閉じる (Esc)",
|
||||
disconnected: "{name} から切断されました — 保存は失敗しますが、テキストはこのウィンドウに保持されます。",
|
||||
cantOpen: "{name} を開けません",
|
||||
closeWindow: "ウィンドウを閉じる",
|
||||
shortcuts: "{mod}S 保存 · {mod}F 検索 · {mod}W 閉じる",
|
||||
lines: { other: "{count} 行" },
|
||||
close: "閉じる",
|
||||
save: "保存",
|
||||
saveChangesTitle: '"{name}" への変更を保存しますか?',
|
||||
saveChangesBody: "保存しないと変更は失われます。",
|
||||
dontSave: "保存しない",
|
||||
countLabel: "{total} 件中 {current} 件目",
|
||||
noMatches: "一致なし",
|
||||
},
|
||||
transfer: {
|
||||
summary: "{active} 件実行中 · {finished} 件完了",
|
||||
clearFinished: "完了分をクリア",
|
||||
cancel: "キャンセル",
|
||||
dismiss: "閉じる",
|
||||
showDetails: "転送の詳細を表示",
|
||||
copying: "{name} をコピー中 · {percent}%{speed}",
|
||||
queued: "{count} 件待機中",
|
||||
failed: { other: "{count} 件の転送が失敗" },
|
||||
finished: "転送が完了しました",
|
||||
ready: "準備完了",
|
||||
items: "{count} 項目",
|
||||
selected: "{count} 件選択中",
|
||||
queuedState: "待機中",
|
||||
scanning: "スキャン中…",
|
||||
failedState: "失敗",
|
||||
cancelled: "キャンセル済み",
|
||||
completedFiles: { other: "{count} 個のファイル · {size}" },
|
||||
left: "残り {eta}",
|
||||
},
|
||||
dialogs: {
|
||||
chmodTitle: "アクセス権",
|
||||
chmodItems: "{count} 項目",
|
||||
read: "読み取り",
|
||||
write: "書き込み",
|
||||
execute: "実行",
|
||||
roleOwner: "所有者",
|
||||
roleGroup: "グループ",
|
||||
roleOthers: "その他",
|
||||
octal: "8進数",
|
||||
mergeFolder: "フォルダを結合",
|
||||
replace: "置き換える",
|
||||
merge: "結合",
|
||||
mergeAll: "すべて結合",
|
||||
replaceAll: "すべて置き換える",
|
||||
skip: "スキップ",
|
||||
skipAll: "すべてスキップ",
|
||||
ofTotal: "{total} 件中 {index} 件目",
|
||||
overwriteDirBody: "コピー先に同名のフォルダが既にあります。結合すると中のファイルが置き換わる可能性があります。",
|
||||
overwriteFileBody: "コピー先に同名のファイルが既にあります。",
|
||||
source: "コピー元",
|
||||
destination: "コピー先",
|
||||
newer: "新しい",
|
||||
},
|
||||
toasts: {
|
||||
movedToTrash: '"{name}" をゴミ箱に移動しました',
|
||||
editingExternal: "{name} を編集中 — 保存すると自動でアップロードされます",
|
||||
nothingToCopy: "コピーする項目がありません(シンボリックリンクはスキップされます)",
|
||||
nothingToCopyAllSkipped: "コピーする項目がありません — 競合する項目はすべてスキップされました",
|
||||
sameFolder: "両方のパネルが同じフォルダを表示しています",
|
||||
sameFile: '"{name}" をスキップしました — 同じファイルです',
|
||||
typeMismatch: '"{name}" をスキップしました — 別の種類の項目があります',
|
||||
transferFailed: '転送 "{name}" が失敗しました: {error}',
|
||||
unknownError: "不明なエラー",
|
||||
saved: "{name} を保存しました",
|
||||
disconnectedReason: "{title}: 切断されました ({reason})",
|
||||
imported: { other: "{count} 件の接続をインポートしました" },
|
||||
importedIntoStacks: { other: "({count} 件の新規グループに)" },
|
||||
importedSkipped: "({skipped} 件スキップ)",
|
||||
leftoverTitle: "残った編集済みファイル",
|
||||
leftoverMore: "、他 {count} 件",
|
||||
leftoverBody: {
|
||||
other: "前回のセッションで {count} 件の一時編集ファイルが残っています({names})。クリアしますか?",
|
||||
},
|
||||
leftoverClear: "クリア",
|
||||
},
|
||||
fileActions: {
|
||||
deleteTitle: "削除",
|
||||
deleteBody: "{what} を削除しますか?フォルダは再帰的に削除されます。この操作は取り消せません。",
|
||||
deleteConfirm: "削除",
|
||||
renameTitle: '"{name}" の名前を変更',
|
||||
renameConfirm: "名前を変更",
|
||||
newFolderTitle: "新規フォルダ",
|
||||
newFolderPlaceholder: "フォルダ名",
|
||||
newFileTitle: "新規ファイル",
|
||||
newFilePlaceholder: "ファイル名",
|
||||
createConfirm: "作成",
|
||||
itemsCount: "{count} 項目",
|
||||
},
|
||||
menu: {
|
||||
about: "{app} について",
|
||||
checkForUpdates: "アップデートを確認…",
|
||||
quit: "終了",
|
||||
file: "ファイル",
|
||||
newTab: "新しいタブ",
|
||||
closeTab: "タブを閉じる",
|
||||
newFolder: "新規フォルダ",
|
||||
newFile: "新規ファイル",
|
||||
editFile: "ファイルを編集",
|
||||
connect: "接続",
|
||||
connectionManager: "接続マネージャー…",
|
||||
edit: "編集",
|
||||
view: "表示",
|
||||
refresh: "更新",
|
||||
window: "ウィンドウ",
|
||||
},
|
||||
ctx: {
|
||||
size: "サイズ",
|
||||
modified: "更新日時",
|
||||
kind: "種類",
|
||||
rights: "権限",
|
||||
owner: "所有者",
|
||||
group: "グループ",
|
||||
revealFinder: "Finder で表示",
|
||||
revealExplorer: "エクスプローラーで表示",
|
||||
revealFileManager: "ファイルマネージャーで表示",
|
||||
moveToTrash: "ゴミ箱に移動",
|
||||
moveToRecycleBin: "ごみ箱に移動",
|
||||
newFolder: "新規フォルダ",
|
||||
newFile: "新規ファイル",
|
||||
refresh: "更新",
|
||||
open: "開く",
|
||||
openDefault: "既定のアプリで開く",
|
||||
edit: "編集",
|
||||
copyToOther: "もう一方のパネルへ送信",
|
||||
rename: "名前を変更",
|
||||
permissions: "アクセス権",
|
||||
copyPath: "パスをコピー",
|
||||
delete: "削除",
|
||||
columns: "列",
|
||||
tabNew: "新しいタブ",
|
||||
tabClose: "タブを閉じる",
|
||||
tabCloseOthers: "他のタブを閉じる",
|
||||
tabCloseRight: "右側のタブを閉じる",
|
||||
tabDisconnect: "切断",
|
||||
trashTooltip: "ここからゴミ箱に入れた項目は Finder の「戻す」を利用できません",
|
||||
},
|
||||
errors: {
|
||||
sshAgentUnavailable: "ssh-agent が利用できません(SSH_AUTH_SOCK が未設定)",
|
||||
identityFileNotFound: "認証鍵ファイルが見つかりません: {path}",
|
||||
sessionNotConnected: 'セッション "{id}" は接続されていません',
|
||||
localCannotDisconnect: "ローカルセッションは切断できません",
|
||||
notADirectory: '"{path}" は存在し、フォルダではありません',
|
||||
sameFileTransfer: '"{name}": コピー元とコピー先が同じファイルです',
|
||||
sameFileNoName: "コピー元とコピー先が同じファイルです",
|
||||
folderOntoItself: "フォルダをそれ自身の上にコピーすることはできません",
|
||||
folderIntoItself: "フォルダをそれ自身の中にコピーすることはできません",
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,315 @@
|
||||
// Simplified Chinese translations. Fall back to English for missing keys.
|
||||
// TODO(i18n): AI draft — needs native-speaker review.
|
||||
export const zh = {
|
||||
common: {
|
||||
cancel: "取消",
|
||||
save: "保存",
|
||||
close: "关闭",
|
||||
delete: "删除",
|
||||
rename: "重命名",
|
||||
create: "创建",
|
||||
apply: "应用",
|
||||
choose: "选择…",
|
||||
connect: "连接",
|
||||
local: "本地",
|
||||
},
|
||||
app: {
|
||||
notConnectedTitle: "未连接到应用",
|
||||
notConnectedBody: "此页面运行在普通浏览器中。请启动桌面应用来使用。",
|
||||
managerOpen: "连接管理器已打开。",
|
||||
showConnections: "显示连接",
|
||||
},
|
||||
tabBar: {
|
||||
connections: "连接",
|
||||
manageConnections: "管理连接",
|
||||
newTab: "新建标签页 ({mod}T)",
|
||||
closeTab: "关闭标签页 ({mod}W)",
|
||||
options: "选项",
|
||||
update: "有可用更新",
|
||||
updating: "正在更新",
|
||||
restartToUpdate: "重启以应用",
|
||||
updateTooltip: "有可用更新:miniSCP {version}",
|
||||
},
|
||||
options: {
|
||||
title: "选项",
|
||||
language: "语言",
|
||||
system: "系统",
|
||||
skin: {
|
||||
label: "外观",
|
||||
modern: "现代",
|
||||
win7: "Windows 7",
|
||||
restartHint: "下次启动 miniSCP 时生效",
|
||||
},
|
||||
theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" },
|
||||
tone: { label: "色调", dark: "深色", darker: "更深" },
|
||||
accent: { label: "强调色" },
|
||||
accentNames: {
|
||||
graphite: "石墨",
|
||||
blue: "蓝色",
|
||||
indigo: "靛蓝",
|
||||
purple: "紫色",
|
||||
rose: "玫红",
|
||||
orange: "橙色",
|
||||
green: "绿色",
|
||||
teal: "青色",
|
||||
},
|
||||
fileIcons: { label: "文件图标", plain: "简约", multi: "彩色" },
|
||||
interface: { label: "界面", smaller: "缩小", larger: "放大", reset: "重置为100%" },
|
||||
speedUnit: { label: "速度单位" },
|
||||
transfers: { label: "传输", single: "单个", multi: "多个" },
|
||||
atOnce: "同时",
|
||||
editor: { label: "编辑器", builtin: "内置", custom: "自定义" },
|
||||
systemDefault: "系统默认",
|
||||
resetDefault: "恢复为系统默认",
|
||||
finishedTransfers: { label: "已完成传输", autoClear: "自动清除", keep: "保留" },
|
||||
},
|
||||
kinds: {
|
||||
folder: "文件夹",
|
||||
symlink: "符号链接",
|
||||
file: "文件",
|
||||
image: "图像",
|
||||
audio: "音频",
|
||||
video: "视频",
|
||||
archive: "压缩包",
|
||||
code: "代码",
|
||||
document: "文档",
|
||||
},
|
||||
columns: {
|
||||
name: "名称",
|
||||
size: "大小",
|
||||
mtime: "修改时间",
|
||||
kind: "类型",
|
||||
rights: "权限",
|
||||
owner: "所有者",
|
||||
group: "组",
|
||||
},
|
||||
panel: {
|
||||
choosePanel: "选择此面板显示的内容",
|
||||
copy: "发送",
|
||||
copyToRight: "发送到右侧面板 (F5)",
|
||||
copyToLeft: "发送到左侧面板 (F5)",
|
||||
edit: "编辑",
|
||||
editFile: "编辑文件 (F4)",
|
||||
rename: "重命名 (F2)",
|
||||
newFolder: "新建文件夹 (F7)",
|
||||
newFile: "新建文件",
|
||||
permissions: "权限",
|
||||
delete: "删除",
|
||||
deleteHint: "删除 (F8 / Del)",
|
||||
parentDir: "上级目录 (退格)",
|
||||
refresh: "刷新",
|
||||
clearFilter: "清除筛选 (Esc)",
|
||||
sshConfig: "~/.ssh/config",
|
||||
connectingTo: "正在连接 {name}…",
|
||||
emptyDir: "空目录",
|
||||
noMatches: "无匹配项",
|
||||
matches: { other: "{count} 个匹配 · Esc" },
|
||||
},
|
||||
connMgr: {
|
||||
title: "连接",
|
||||
importWinscp: "从 WinSCP 导入…",
|
||||
importWinscpHint: "从 WinSCP.ini 导出文件导入会话",
|
||||
newConnection: "连接",
|
||||
newConnectionHint: "新建连接",
|
||||
newStack: "分组",
|
||||
newStackHint: "新建分组",
|
||||
expand: "展开",
|
||||
collapse: "折叠",
|
||||
renameStack: "重命名分组",
|
||||
deleteStack: "删除分组",
|
||||
openStack: "在标签页中打开分组",
|
||||
empty: "空",
|
||||
connectionsHeader: "连接",
|
||||
noneTitle: "尚无已保存的连接。",
|
||||
noneBody: "创建一个,或导入你的 WinSCP 会话。",
|
||||
name: "名称",
|
||||
namePlaceholder: "我的服务器",
|
||||
host: "主机",
|
||||
port: "端口",
|
||||
stack: "分组",
|
||||
stackNone: "(无)",
|
||||
username: "用户名",
|
||||
remotePath: "远程路径(可选)",
|
||||
remotePathPlaceholder: "主目录",
|
||||
auth: "认证",
|
||||
authPassword: "密码",
|
||||
authKey: "私钥",
|
||||
password: "密码",
|
||||
privateKeyFile: "私钥文件",
|
||||
passphrase: "密钥口令(可选)",
|
||||
localDir: "本地目录(可选)",
|
||||
localDirPlaceholder: "~/projects/my-server",
|
||||
localDirHint: "当另一个面板处于本地且未浏览过时,将在此打开。",
|
||||
more: "更多…",
|
||||
less: "收起…",
|
||||
selectPrompt: "选择一个连接或新建一个。",
|
||||
newStackTitle: "新建分组",
|
||||
newStackPrompt: "分组名称",
|
||||
renameStackTitle: "重命名分组",
|
||||
deleteStackTitle: "删除分组",
|
||||
deleteStackBody: '删除分组 "{name}"?其中的连接将保留但不再分组。',
|
||||
deleteConnectionTitle: "删除连接",
|
||||
deleteConnectionBody: '删除 "{name}"?',
|
||||
},
|
||||
editor: {
|
||||
noFileTitle: "没有要编辑的文件",
|
||||
noFileBody: "此窗口在没有编辑器上下文的情况下打开。请从应用中打开文件。",
|
||||
wrap: "换行",
|
||||
wrapHint: "长行自动换行 — 隐藏行号 ({alt}Z)",
|
||||
findHint: "查找 ({mod}F)",
|
||||
findPlaceholder: "在文件中查找",
|
||||
prevMatch: "上一个匹配 (⇧↵)",
|
||||
nextMatch: "下一个匹配 (↵)",
|
||||
closeHint: "关闭 (Esc)",
|
||||
disconnected: "已从 {name} 断开 — 保存将失败,但你的文本会保留在此窗口中。",
|
||||
cantOpen: "无法打开 {name}",
|
||||
closeWindow: "关闭窗口",
|
||||
shortcuts: "{mod}S 保存 · {mod}F 查找 · {mod}W 关闭",
|
||||
lines: { other: "{count} 行" },
|
||||
close: "关闭",
|
||||
save: "保存",
|
||||
saveChangesTitle: '保存对 "{name}" 的更改?',
|
||||
saveChangesBody: "如果不保存,你的更改将丢失。",
|
||||
dontSave: "不保存",
|
||||
countLabel: "第 {current} / {total}",
|
||||
noMatches: "无匹配项",
|
||||
},
|
||||
transfer: {
|
||||
summary: "{active} 进行中 · {finished} 已完成",
|
||||
clearFinished: "清除已完成",
|
||||
cancel: "取消",
|
||||
dismiss: "忽略",
|
||||
showDetails: "显示传输详情",
|
||||
copying: "正在复制 {name} · {percent}%{speed}",
|
||||
queued: "{count} 个排队中",
|
||||
failed: { other: "{count} 个传输失败" },
|
||||
finished: "传输已完成",
|
||||
ready: "就绪",
|
||||
items: "{count} 项",
|
||||
selected: "已选 {count} 项",
|
||||
queuedState: "排队中",
|
||||
scanning: "扫描中…",
|
||||
failedState: "失败",
|
||||
cancelled: "已取消",
|
||||
completedFiles: { other: "{count} 个文件 · {size}" },
|
||||
left: "剩余 {eta}",
|
||||
},
|
||||
dialogs: {
|
||||
chmodTitle: "权限",
|
||||
chmodItems: "{count} 项",
|
||||
read: "读",
|
||||
write: "写",
|
||||
execute: "执行",
|
||||
roleOwner: "所有者",
|
||||
roleGroup: "组",
|
||||
roleOthers: "其他",
|
||||
octal: "八进制",
|
||||
mergeFolder: "合并文件夹",
|
||||
replace: "替换",
|
||||
merge: "合并",
|
||||
mergeAll: "全部合并",
|
||||
replaceAll: "全部替换",
|
||||
skip: "跳过",
|
||||
skipAll: "全部跳过",
|
||||
ofTotal: "第 {index} / {total}",
|
||||
overwriteDirBody: "目标位置已存在同名文件夹。合并可能会替换其中的文件。",
|
||||
overwriteFileBody: "目标位置已存在同名文件。",
|
||||
source: "源",
|
||||
destination: "目标",
|
||||
newer: "较新",
|
||||
},
|
||||
toasts: {
|
||||
movedToTrash: '已将 "{name}" 移到废纸篓',
|
||||
editingExternal: "正在编辑 {name} — 保存后会自动上传",
|
||||
nothingToCopy: "没有可复制的内容(符号链接将被跳过)",
|
||||
nothingToCopyAllSkipped: "没有可复制的内容 — 所有冲突项都被跳过",
|
||||
sameFolder: "两个面板显示的是同一个文件夹",
|
||||
sameFile: '已跳过 "{name}" — 它是同一个文件',
|
||||
typeMismatch: '已跳过 "{name}" — 目标处是不同类型的项目',
|
||||
transferFailed: '传输 "{name}" 失败:{error}',
|
||||
unknownError: "未知错误",
|
||||
saved: "已保存 {name}",
|
||||
disconnectedReason: "{title}:已断开({reason})",
|
||||
imported: { other: "已导入 {count} 个连接" },
|
||||
importedIntoStacks: { other: ",归入 {count} 个新分组" },
|
||||
importedSkipped: "(跳过 {skipped} 个)",
|
||||
leftoverTitle: "残留的已编辑文件",
|
||||
leftoverMore: ",还有 {count} 个",
|
||||
leftoverBody: {
|
||||
other: "上一次会话遗留了 {count} 个临时编辑文件({names})。要清除吗?",
|
||||
},
|
||||
leftoverClear: "清除",
|
||||
},
|
||||
fileActions: {
|
||||
deleteTitle: "删除",
|
||||
deleteBody: "删除 {what}?文件夹将被递归删除。此操作无法撤销。",
|
||||
deleteConfirm: "删除",
|
||||
renameTitle: '重命名 "{name}"',
|
||||
renameConfirm: "重命名",
|
||||
newFolderTitle: "新建文件夹",
|
||||
newFolderPlaceholder: "文件夹名称",
|
||||
newFileTitle: "新建文件",
|
||||
newFilePlaceholder: "文件名称",
|
||||
createConfirm: "创建",
|
||||
itemsCount: "{count} 项",
|
||||
},
|
||||
menu: {
|
||||
about: "关于 {app}",
|
||||
checkForUpdates: "检查更新…",
|
||||
quit: "退出",
|
||||
file: "文件",
|
||||
newTab: "新建标签页",
|
||||
closeTab: "关闭标签页",
|
||||
newFolder: "新建文件夹",
|
||||
newFile: "新建文件",
|
||||
editFile: "编辑文件",
|
||||
connect: "连接",
|
||||
connectionManager: "连接管理器…",
|
||||
edit: "编辑",
|
||||
view: "查看",
|
||||
refresh: "刷新",
|
||||
window: "窗口",
|
||||
},
|
||||
ctx: {
|
||||
size: "大小",
|
||||
modified: "修改时间",
|
||||
kind: "类型",
|
||||
rights: "权限",
|
||||
owner: "所有者",
|
||||
group: "组",
|
||||
revealFinder: "在访达中显示",
|
||||
revealExplorer: "在资源管理器中显示",
|
||||
revealFileManager: "在文件管理器中显示",
|
||||
moveToTrash: "移到废纸篓",
|
||||
moveToRecycleBin: "移到回收站",
|
||||
newFolder: "新建文件夹",
|
||||
newFile: "新建文件",
|
||||
refresh: "刷新",
|
||||
open: "打开",
|
||||
openDefault: "用默认应用打开",
|
||||
edit: "编辑",
|
||||
copyToOther: "发送到另一个面板",
|
||||
rename: "重命名",
|
||||
permissions: "权限",
|
||||
copyPath: "复制路径",
|
||||
delete: "删除",
|
||||
columns: "列",
|
||||
tabNew: "新建标签页",
|
||||
tabClose: "关闭标签页",
|
||||
tabCloseOthers: "关闭其他标签页",
|
||||
tabCloseRight: "关闭右侧标签页",
|
||||
tabDisconnect: "断开连接",
|
||||
trashTooltip: "从此处移入废纸篓的项目无法使用访达的放回原处功能",
|
||||
},
|
||||
errors: {
|
||||
sshAgentUnavailable: "ssh-agent 不可用(未设置 SSH_AUTH_SOCK)",
|
||||
identityFileNotFound: "找不到身份文件:{path}",
|
||||
sessionNotConnected: '会话 "{id}" 未连接',
|
||||
localCannotDisconnect: "无法断开本地会话",
|
||||
notADirectory: '"{path}" 已存在且不是文件夹',
|
||||
sameFileTransfer: '"{name}":源和目标是同一个文件',
|
||||
sameFileNoName: "源和目标是同一个文件",
|
||||
folderOntoItself: "无法将文件夹复制到其自身之上",
|
||||
folderIntoItself: "无法将文件夹复制到其自身内部",
|
||||
},
|
||||
} as const;
|
||||
@@ -1,6 +1,5 @@
|
||||
import type {
|
||||
AppSettings,
|
||||
ColumnKey,
|
||||
ConnectionConfig,
|
||||
ConnectionsFile,
|
||||
EntryType,
|
||||
@@ -44,6 +43,9 @@ export type AppRPC = {
|
||||
params: { sessionId: string; path: string | null }; // null -> home dir
|
||||
response: Result<{ path: string; entries: FileEntry[] }>;
|
||||
};
|
||||
// Local dirs to fs.watch for auto-refresh (the active tab's local panels);
|
||||
// a change pushes pathRefresh. Remote panels are polled UI-side instead.
|
||||
setWatchedDirs: { params: { dirs: string[] }; response: Result };
|
||||
mkdir: { params: { sessionId: string; path: string }; response: Result };
|
||||
createFile: { params: { sessionId: string; path: string }; response: Result };
|
||||
rename: {
|
||||
@@ -123,20 +125,18 @@ export type AppRPC = {
|
||||
response: Result<{ names: string[] }>;
|
||||
};
|
||||
clearEditLeftovers: { params: Record<string, never>; response: Result };
|
||||
// context menu + local shell integration
|
||||
showRowMenu: {
|
||||
params: {
|
||||
path: string;
|
||||
name: string;
|
||||
type: EntryType;
|
||||
isLocal: boolean;
|
||||
hasEntry: boolean;
|
||||
};
|
||||
// local shell integration
|
||||
/**
|
||||
* Shell actions the context menu triggers on a LOCAL path. The menu
|
||||
* itself is HTML (ui/src/app/context-menu.ts) — electrobun's native
|
||||
* context menu is a no-op on Linux — but these three still need the
|
||||
* bun side's Utils.
|
||||
*/
|
||||
shellItemAction: {
|
||||
params: { action: "reveal" | "open" | "copyPath"; path: string };
|
||||
response: Result;
|
||||
};
|
||||
moveToTrash: { params: { path: string }; response: Result };
|
||||
/** Native column-picker menu (right-click on the list headers). */
|
||||
showColumnsMenu: { params: { visible: ColumnKey[] }; response: Result };
|
||||
/** Native About box (name, Build n, commit, engine versions). */
|
||||
showAbout: { params: Record<string, never>; response: Result };
|
||||
/** Windows drive roots for the local panel ("C:\", …); empty elsewhere. */
|
||||
@@ -154,6 +154,45 @@ export type AppRPC = {
|
||||
managerConnect: { params: { id: string }; response: Result };
|
||||
managerOpenStack: { params: { id: string }; response: Result };
|
||||
managerClose: { params: Record<string, never>; response: Result };
|
||||
// custom window chrome (win7 skin — the window is frameless, so the
|
||||
// webview's title bar owns the controls). Served by every window kind.
|
||||
windowMinimize: { params: Record<string, never>; response: Result };
|
||||
/** Maximize/restore in one call; responds with the resulting state. */
|
||||
windowToggleMaximize: {
|
||||
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)
|
||||
/** Main window: re-run the update check on demand (menu item). */
|
||||
checkForUpdate: {
|
||||
params: Record<string, never>;
|
||||
response: Result<{ updateAvailable: boolean; version: string }>;
|
||||
};
|
||||
/** Download the pending update (patch-chain, else full). Progress arrives via updateStatus. */
|
||||
downloadUpdate: { params: Record<string, never>; response: Result };
|
||||
/** Swap in the downloaded update and relaunch — quits the app, so may not resolve. */
|
||||
applyUpdate: { params: Record<string, never>; response: Result };
|
||||
};
|
||||
messages: {
|
||||
/** Editor → bun draft mirror; safety net for the non-cancellable native close. */
|
||||
@@ -174,8 +213,25 @@ export type AppRPC = {
|
||||
editorCloseRequest: Record<string, never>;
|
||||
/** bun → main: the modal connection manager opened/closed. */
|
||||
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. */
|
||||
connectionsChanged: Record<string, never>;
|
||||
/**
|
||||
* bun → main: auto-updater lifecycle, drives the tab-bar update pill.
|
||||
* "ready" is not a phase — the UI derives it from downloadUpdate resolving ok.
|
||||
*/
|
||||
updateStatus: {
|
||||
phase: "available" | "downloading" | "error";
|
||||
version?: string;
|
||||
progress?: number;
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -62,6 +62,9 @@ export interface ConnectionsFile {
|
||||
connections: SavedConnection[];
|
||||
}
|
||||
|
||||
/** UI languages the app ships translations for. */
|
||||
export type SupportedLocale = "en" | "es" | "eu" | "fr" | "de" | "zh" | "ja";
|
||||
|
||||
export type AccentTone =
|
||||
| "graphite"
|
||||
| "blue"
|
||||
@@ -73,6 +76,20 @@ export type AccentTone =
|
||||
| "teal";
|
||||
|
||||
export interface AppSettings {
|
||||
/** UI language; "system" resolves to the OS locale (fallback English). */
|
||||
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";
|
||||
/** Dark appearance: grey ("dark") or near-black AMOLED ("darker"). */
|
||||
darkTone: "dark" | "darker";
|
||||
@@ -83,8 +100,13 @@ export interface AppSettings {
|
||||
/** Visible optional file-list columns (both panels; Name always shows). */
|
||||
columns: ColumnKey[];
|
||||
speedUnit: "MBps" | "Mbps";
|
||||
/** Size of the panel interface: file list, path bar, header controls. */
|
||||
textSize: "compact" | "default" | "large";
|
||||
/**
|
||||
* Interface scale factor, 0.5–4 in 0.1 steps (shown as 50%–400%). Works by
|
||||
* setting the root font-size — every size in the UI is rem, so one lever
|
||||
* scales BOTH skins whole. Replaced the 3-tier `textSize`
|
||||
* (compact|default|large); SettingsStore.load migrates old files.
|
||||
*/
|
||||
uiScale: number;
|
||||
/** Run transfer jobs one at a time, or several in parallel. */
|
||||
transferMode: "single" | "multi";
|
||||
/** How many jobs run at once when transferMode is "multi". */
|
||||
@@ -95,23 +117,47 @@ export interface AppSettings {
|
||||
editor: "builtin" | "custom";
|
||||
/** Full path to the external editor .app; empty = OS default application. */
|
||||
editorApp: string;
|
||||
// Auto-refresh the active tab's panels (local watched, remote polled). Always
|
||||
// on for now; uncomment (+ the settings.service and tab-bar breadcrumbs, and
|
||||
// gate the poll/watch in panels.service) to expose a toggle later.
|
||||
// autoRefresh: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AppSettings = {
|
||||
locale: "system",
|
||||
skin: "modern",
|
||||
theme: "system",
|
||||
darkTone: "dark",
|
||||
accent: "blue",
|
||||
iconColors: "multi",
|
||||
columns: ["size", "mtime"],
|
||||
speedUnit: "MBps",
|
||||
textSize: "default",
|
||||
uiScale: 1,
|
||||
transferMode: "single",
|
||||
transferConcurrency: 3,
|
||||
autoClearCompleted: true,
|
||||
editor: "builtin",
|
||||
editorApp: "",
|
||||
// autoRefresh: true,
|
||||
};
|
||||
|
||||
export const UI_SCALE_MIN = 0.5;
|
||||
export const UI_SCALE_MAX = 4;
|
||||
export const UI_SCALE_STEP = 0.1;
|
||||
|
||||
/**
|
||||
* Clamp + snap a scale factor to the legal range and step grid. Shared by the
|
||||
* store (a hand-edited settings.json must not be able to render the UI
|
||||
* unusable), the settings service and the stepper — one definition of "valid",
|
||||
* same as effectiveConcurrency below.
|
||||
*/
|
||||
export function clampUiScale(value: number): number {
|
||||
if (!Number.isFinite(value)) return 1;
|
||||
const clamped = Math.min(UI_SCALE_MAX, Math.max(UI_SCALE_MIN, value));
|
||||
// snap to the step grid; toFixed strips float noise (0.7000000000000001 -> 0.7)
|
||||
return Number((Math.round(clamped / UI_SCALE_STEP) * UI_SCALE_STEP).toFixed(2));
|
||||
}
|
||||
|
||||
export function effectiveConcurrency(settings: AppSettings): number {
|
||||
if (settings.transferMode === "single") return 1;
|
||||
return Math.max(1, Math.min(8, Math.round(settings.transferConcurrency) || 1));
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "bun"
|
||||
"packageManager": "bun",
|
||||
"analytics": false
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
@@ -25,7 +26,9 @@
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": ["src/styles.css"]
|
||||
"styles": [
|
||||
"src/styles.css"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
@@ -66,4 +69,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
<!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>
|
||||
@@ -9,6 +9,7 @@
|
||||
"private": true,
|
||||
"packageManager": "bun@1.3.13",
|
||||
"dependencies": {
|
||||
"7.css": "^0.21.1",
|
||||
"@angular/common": "^22.0.0",
|
||||
"@angular/compiler": "^22.0.0",
|
||||
"@angular/core": "^22.0.0",
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import type { SavedConnection, SshConfigHost, Stack } from '@shared/types';
|
||||
import { RpcService } from './rpc.service';
|
||||
import { I18nService } from './i18n.service';
|
||||
import { ToastService } from './toast.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ConnectionsService {
|
||||
private readonly i18n = inject(I18nService);
|
||||
private readonly rpc = inject(RpcService);
|
||||
private readonly toast = inject(ToastService);
|
||||
|
||||
@@ -86,11 +88,10 @@ export class ConnectionsService {
|
||||
return;
|
||||
}
|
||||
if (res.connections || res.stacks || res.skipped) {
|
||||
const skipped = res.skipped ? ` (${res.skipped} skipped)` : '';
|
||||
const skipped = res.skipped ? this.i18n.t('toasts.importedSkipped', { skipped: res.skipped }) : '';
|
||||
const stacks = res.stacks ? this.i18n.t('toasts.importedIntoStacks', { count: res.stacks }) : '';
|
||||
this.toast.success(
|
||||
`Imported ${res.connections} connection${res.connections === 1 ? '' : 's'}` +
|
||||
(res.stacks ? ` into ${res.stacks} new stack${res.stacks === 1 ? '' : 's'}` : '') +
|
||||
skipped,
|
||||
this.i18n.t('toasts.imported', { count: res.connections }) + stacks + skipped,
|
||||
);
|
||||
}
|
||||
await this.reload();
|
||||
@@ -0,0 +1,177 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import type { ColumnKey, EntryType } from '@shared/types';
|
||||
import { I18nService } from './i18n.service';
|
||||
import { IS_MAC, IS_WINDOWS, MOD_LABEL } from './platform';
|
||||
import { RpcService } from './rpc.service';
|
||||
|
||||
/** Optional list columns, in display order (Name is fixed). */
|
||||
const COLUMN_KEYS: ColumnKey[] = ['size', 'mtime', 'kind', 'rights', 'owner', 'group'];
|
||||
|
||||
export interface MenuItem {
|
||||
type?: 'separator';
|
||||
label?: string;
|
||||
/** Emitted verbatim to the action dispatcher (`ctx-*` / `col:*` / `tab:*`). */
|
||||
action?: string;
|
||||
checked?: boolean;
|
||||
tooltip?: string;
|
||||
/** Shortcut hint, rendered right-aligned. */
|
||||
accelerator?: string;
|
||||
/** Greyed and unclickable. Win7 menus disable rather than hide, so the set
|
||||
* of commands stays in the same place from one right-click to the next. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface MenuRequest {
|
||||
x: number;
|
||||
y: number;
|
||||
items: MenuItem[];
|
||||
}
|
||||
|
||||
export interface TabMenuContext {
|
||||
/** The right-clicked tab (NOT necessarily the active one). */
|
||||
id: number;
|
||||
/** Position in the strip, for "close to the right". */
|
||||
index: number;
|
||||
tabCount: number;
|
||||
/** Any pane in this tab holds a live remote session. */
|
||||
isRemote: boolean;
|
||||
}
|
||||
|
||||
export interface RowMenuContext {
|
||||
path: string;
|
||||
name: string;
|
||||
type: EntryType;
|
||||
isLocal: boolean;
|
||||
hasEntry: boolean; // false = right-click on empty panel space
|
||||
}
|
||||
|
||||
// PORTABILITY: shell-integration wording per OS (resolved at build time for locale)
|
||||
const revealKey = (): string =>
|
||||
IS_MAC ? 'ctx.revealFinder' : IS_WINDOWS ? 'ctx.revealExplorer' : 'ctx.revealFileManager';
|
||||
const trashKey = (): string => (IS_WINDOWS ? 'ctx.moveToRecycleBin' : 'ctx.moveToTrash');
|
||||
|
||||
/**
|
||||
* Builds and opens the panel right-click menus. This used to be a native
|
||||
* electrobun ContextMenu on the bun side, but `showContextMenu` is a documented
|
||||
* no-op on Linux (electrobun's own dist/api/bun/proc/linux.md), which left the
|
||||
* whole right-click surface dead there. The menu is plain HTML now — one code
|
||||
* path on every platform — and still emits the exact same action strings, so
|
||||
* the dispatcher in app.ts is unchanged.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ContextMenuService {
|
||||
private readonly i18n = inject(I18nService);
|
||||
private readonly rpc = inject(RpcService);
|
||||
|
||||
readonly request = signal<MenuRequest | null>(null);
|
||||
|
||||
open(event: MouseEvent, items: MenuItem[]): void {
|
||||
this.request.set({ x: event.clientX, y: event.clientY, items });
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.request.set(null);
|
||||
}
|
||||
|
||||
/** A picked item goes through the same dispatcher the app menubar uses. */
|
||||
onPick(action: string): void {
|
||||
this.rpc.emitMenuAction(action);
|
||||
}
|
||||
|
||||
/** Row / empty-space menu for a file panel. */
|
||||
rowItems(ctx: RowMenuContext): MenuItem[] {
|
||||
const t = (key: string) => this.i18n.t(key);
|
||||
const items: MenuItem[] = [];
|
||||
|
||||
if (!ctx.hasEntry || ctx.name === '..') {
|
||||
// empty space (or the parent row): folder-level actions
|
||||
items.push({ label: t('ctx.newFolder'), action: 'ctx-new-folder' });
|
||||
items.push({ label: t('ctx.newFile'), action: 'ctx-new-file' });
|
||||
items.push({ type: 'separator' });
|
||||
items.push({ label: t('ctx.refresh'), action: 'ctx-refresh' });
|
||||
if (ctx.isLocal) {
|
||||
items.push({ type: 'separator' });
|
||||
items.push({ label: t(revealKey()), action: 'ctx-reveal' });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
items.push({ label: t('ctx.copyToOther'), action: 'ctx-copy' });
|
||||
if (ctx.type === 'dir') {
|
||||
items.push({ label: t('ctx.open'), action: 'ctx-open' });
|
||||
} else {
|
||||
if (ctx.isLocal) {
|
||||
items.push({ label: t('ctx.open'), action: 'ctx-open-default', tooltip: t('ctx.openDefault') });
|
||||
}
|
||||
items.push({ label: t('ctx.edit'), action: 'ctx-edit', accelerator: 'F4' });
|
||||
}
|
||||
items.push({ label: t('ctx.rename'), action: 'ctx-rename' });
|
||||
// chmod on Windows local files only flips the read-only bit — hide the lie
|
||||
if (!(ctx.isLocal && IS_WINDOWS)) {
|
||||
items.push({ label: t('ctx.permissions'), action: 'ctx-chmod' });
|
||||
}
|
||||
items.push({ type: 'separator' });
|
||||
items.push({ label: t('ctx.copyPath'), action: 'ctx-copy-path' });
|
||||
if (ctx.isLocal) {
|
||||
items.push({ label: t(revealKey()), action: 'ctx-reveal' });
|
||||
}
|
||||
items.push({ type: 'separator' });
|
||||
if (ctx.isLocal) {
|
||||
items.push({
|
||||
label: t(trashKey()),
|
||||
action: 'ctx-trash',
|
||||
...(IS_MAC ? { tooltip: t('ctx.trashTooltip') } : {}),
|
||||
});
|
||||
}
|
||||
items.push({ label: t('ctx.delete'), action: 'ctx-delete' });
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace-tab menu. Actions carry the target tab's id, because the
|
||||
* right-clicked tab is not necessarily the active one — nothing here may
|
||||
* assume `activeWorkspace`.
|
||||
*
|
||||
* Everything illegal is DISABLED, not omitted: a menu whose items move
|
||||
* between right-clicks is a Win7 sin, and "Close Tab" greyed out explains
|
||||
* why the last tab cannot be closed better than its absence does.
|
||||
*
|
||||
* Duplicate/Rename/Save-session are deliberately absent until tabs have an
|
||||
* identity to duplicate (a stored connection id) — the sessions round.
|
||||
*/
|
||||
tabItems(ctx: TabMenuContext): MenuItem[] {
|
||||
const t = (key: string) => this.i18n.t(key);
|
||||
const last = ctx.tabCount <= 1;
|
||||
return [
|
||||
{ label: t('ctx.tabNew'), action: 'tab:new', accelerator: `${MOD_LABEL}T` },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: t('ctx.tabClose'),
|
||||
action: `tab:close:${ctx.id}`,
|
||||
accelerator: `${MOD_LABEL}W`,
|
||||
disabled: last,
|
||||
},
|
||||
{ label: t('ctx.tabCloseOthers'), action: `tab:close-others:${ctx.id}`, disabled: last },
|
||||
{
|
||||
label: t('ctx.tabCloseRight'),
|
||||
action: `tab:close-right:${ctx.id}`,
|
||||
disabled: ctx.index >= ctx.tabCount - 1,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: t('ctx.tabDisconnect'),
|
||||
action: `tab:disconnect:${ctx.id}`,
|
||||
disabled: !ctx.isRemote,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Column picker for the list headers: checkable toggles per column. */
|
||||
columnItems(visible: ColumnKey[]): MenuItem[] {
|
||||
return COLUMN_KEYS.map((key) => ({
|
||||
label: this.i18n.t(`columns.${key}`),
|
||||
action: `col:${key}`,
|
||||
checked: visible.includes(key),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import type { FileEntry } from '@shared/types';
|
||||
import { ContextMenuService } from './context-menu.service';
|
||||
import { DialogsService } from './dialogs.service';
|
||||
import { joinPath } from './format';
|
||||
import { type PanelState, type Side } from './panels.service';
|
||||
import { IS_WINDOWS } from './platform';
|
||||
import { RpcService } from './rpc.service';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { I18nService } from './i18n.service';
|
||||
import { ToastService } from './toast.service';
|
||||
|
||||
/** The row (or empty space) the user last right-clicked; ctx-* actions apply here. */
|
||||
@@ -21,30 +23,35 @@ export interface MenuTarget {
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FileActionsService {
|
||||
private readonly i18n = inject(I18nService);
|
||||
private readonly rpc = inject(RpcService);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly dialogs = inject(DialogsService);
|
||||
private readonly menu = inject(ContextMenuService);
|
||||
private readonly settings = inject(SettingsService);
|
||||
|
||||
menuTarget: MenuTarget | null = null;
|
||||
|
||||
/** Right-click entry point: remember the target and ask bun for a native menu. */
|
||||
async openContextMenu(target: MenuTarget): Promise<void> {
|
||||
/** Right-click entry point: remember the target, then open the menu on it. */
|
||||
openContextMenu(event: MouseEvent, target: MenuTarget): void {
|
||||
this.menuTarget = target;
|
||||
const { panel, entry } = target;
|
||||
await this.rpc.request.showRowMenu({
|
||||
path: entry?.path ?? panel.cwd(),
|
||||
name: entry?.name ?? '',
|
||||
type: entry?.type ?? 'dir',
|
||||
isLocal: panel.sessionId() === 'local',
|
||||
hasEntry: !!entry,
|
||||
});
|
||||
this.menu.open(
|
||||
event,
|
||||
this.menu.rowItems({
|
||||
path: entry?.path ?? panel.cwd(),
|
||||
name: entry?.name ?? '',
|
||||
type: entry?.type ?? 'dir',
|
||||
isLocal: panel.sessionId() === 'local',
|
||||
hasEntry: !!entry,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async trash(panel: PanelState, entry: FileEntry): Promise<void> {
|
||||
const res = await this.rpc.request.moveToTrash({ path: entry.path });
|
||||
if (!res.ok) this.toast.error(res.error);
|
||||
else this.toast.info(`Moved "${entry.name}" to Trash`);
|
||||
else this.toast.info(this.i18n.t('toasts.movedToTrash', { name: entry.name }));
|
||||
await panel.refresh();
|
||||
}
|
||||
|
||||
@@ -58,9 +65,9 @@ export class FileActionsService {
|
||||
|
||||
mkdir(panel: PanelState): void {
|
||||
this.dialogs.prompt.set({
|
||||
title: 'New folder',
|
||||
placeholder: 'folder name',
|
||||
confirmLabel: 'Create',
|
||||
title: this.i18n.t('fileActions.newFolderTitle'),
|
||||
placeholder: this.i18n.t('fileActions.newFolderPlaceholder'),
|
||||
confirmLabel: this.i18n.t('fileActions.createConfirm'),
|
||||
onConfirm: async (name) => {
|
||||
const res = await this.rpc.request.mkdir({
|
||||
sessionId: panel.sessionId(),
|
||||
@@ -74,9 +81,9 @@ export class FileActionsService {
|
||||
|
||||
createFile(panel: PanelState): void {
|
||||
this.dialogs.prompt.set({
|
||||
title: 'New file',
|
||||
placeholder: 'file name',
|
||||
confirmLabel: 'Create',
|
||||
title: this.i18n.t('fileActions.newFileTitle'),
|
||||
placeholder: this.i18n.t('fileActions.newFilePlaceholder'),
|
||||
confirmLabel: this.i18n.t('fileActions.createConfirm'),
|
||||
onConfirm: async (name) => {
|
||||
const res = await this.rpc.request.createFile({
|
||||
sessionId: panel.sessionId(),
|
||||
@@ -101,7 +108,7 @@ export class FileActionsService {
|
||||
if (!res.ok) {
|
||||
this.toast.error(res.error);
|
||||
} else if (res.watching) {
|
||||
this.toast.info(`Editing ${entry.name} — saves upload automatically`);
|
||||
this.toast.info(this.i18n.t('toasts.editingExternal', { name: entry.name }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -120,9 +127,9 @@ export class FileActionsService {
|
||||
const entry = panel.sorted()[panel.cursor()];
|
||||
if (!entry || entry.name === '..') return;
|
||||
this.dialogs.prompt.set({
|
||||
title: `Rename "${entry.name}"`,
|
||||
title: this.i18n.t('fileActions.renameTitle', { name: entry.name }),
|
||||
initial: entry.name,
|
||||
confirmLabel: 'Rename',
|
||||
confirmLabel: this.i18n.t('fileActions.renameConfirm'),
|
||||
onConfirm: async (name) => {
|
||||
if (name === entry.name) return;
|
||||
const res = await this.rpc.request.rename({
|
||||
@@ -140,11 +147,13 @@ export class FileActionsService {
|
||||
const targets = this.targets(panel);
|
||||
if (!targets.length) return;
|
||||
const what =
|
||||
targets.length === 1 ? `"${targets[0].name}"` : `${targets.length} items`;
|
||||
targets.length === 1
|
||||
? `"${targets[0].name}"`
|
||||
: this.i18n.t('fileActions.itemsCount', { count: targets.length });
|
||||
this.dialogs.confirm.set({
|
||||
title: 'Delete',
|
||||
body: `Delete ${what}? Folders are deleted recursively. This cannot be undone.`,
|
||||
confirmLabel: 'Delete',
|
||||
title: this.i18n.t('fileActions.deleteTitle'),
|
||||
body: this.i18n.t('fileActions.deleteBody', { what }),
|
||||
confirmLabel: this.i18n.t('fileActions.deleteConfirm'),
|
||||
destructive: true,
|
||||
onConfirm: async () => {
|
||||
const res = await this.rpc.request.deleteEntries({
|
||||
@@ -64,22 +64,25 @@ function categoryOf(name: string): Category | null {
|
||||
return EXT_CATEGORY.get(key) ?? null;
|
||||
}
|
||||
|
||||
const KIND_LABELS: Record<Category, string> = {
|
||||
image: 'Image',
|
||||
audio: 'Audio',
|
||||
video: 'Video',
|
||||
archive: 'Archive',
|
||||
code: 'Code',
|
||||
doc: 'Document',
|
||||
const KIND_KEYS: Record<Category, string> = {
|
||||
image: 'image',
|
||||
audio: 'audio',
|
||||
video: 'video',
|
||||
archive: 'archive',
|
||||
code: 'code',
|
||||
doc: 'document',
|
||||
};
|
||||
|
||||
/** Human label for the optional Kind column (category-based, like the icons). */
|
||||
export function fileKind(entry: FileEntry): string {
|
||||
/**
|
||||
* i18n key suffix for the optional Kind column (category-based, like the
|
||||
* icons); '' for the synthetic '..' row. The caller resolves `kinds.<suffix>`.
|
||||
*/
|
||||
export function fileKindKey(entry: FileEntry): string {
|
||||
if (entry.name === '..') return '';
|
||||
if (entry.type === 'dir') return 'Folder';
|
||||
if (entry.type === 'symlink') return 'Symlink';
|
||||
if (entry.type === 'dir') return 'folder';
|
||||
if (entry.type === 'symlink') return 'symlink';
|
||||
const category = categoryOf(entry.name);
|
||||
return category ? KIND_LABELS[category] : 'File';
|
||||
return category ? KIND_KEYS[category] : 'file';
|
||||
}
|
||||
|
||||
export function fileGlyph(entry: FileEntry, multi: boolean): string {
|
||||
@@ -1,34 +1,51 @@
|
||||
const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
|
||||
export function formatSize(bytes: number): string {
|
||||
/** Locale-aware number formatting (decimal separator); cached per locale. */
|
||||
const NUM_FORMATS = new Map<string, Intl.NumberFormat>();
|
||||
function num(value: number, locale: string | undefined): string {
|
||||
const key = locale ?? '';
|
||||
let fmt = NUM_FORMATS.get(key);
|
||||
if (!fmt) {
|
||||
fmt = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 });
|
||||
NUM_FORMATS.set(key, fmt);
|
||||
}
|
||||
return fmt.format(value);
|
||||
}
|
||||
|
||||
export function formatSize(bytes: number, locale?: string): string {
|
||||
let value = bytes;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < SIZE_UNITS.length - 1) {
|
||||
value /= 1024;
|
||||
unit++;
|
||||
}
|
||||
return `${unit === 0 ? value : value.toFixed(1)} ${SIZE_UNITS[unit]}`;
|
||||
return `${unit === 0 ? num(value, locale) : num(Number(value.toFixed(1)), locale)} ${SIZE_UNITS[unit]}`;
|
||||
}
|
||||
|
||||
const DATE_FORMAT = new Intl.DateTimeFormat(undefined, {
|
||||
year: '2-digit',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
export function formatDate(epochMs: number): string {
|
||||
const DATE_FORMATS = new Map<string, Intl.DateTimeFormat>();
|
||||
export function formatDate(epochMs: number, locale?: string): string {
|
||||
if (!epochMs) return '';
|
||||
return DATE_FORMAT.format(new Date(epochMs));
|
||||
const key = locale ?? '';
|
||||
let fmt = DATE_FORMATS.get(key);
|
||||
if (!fmt) {
|
||||
fmt = new Intl.DateTimeFormat(locale, {
|
||||
year: '2-digit',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
DATE_FORMATS.set(key, fmt);
|
||||
}
|
||||
return fmt.format(new Date(epochMs));
|
||||
}
|
||||
|
||||
/** bytes/sec -> "14.2 MB/s" or "113.8 Mbps" depending on the configured unit. */
|
||||
export function formatSpeed(bytesPerSec: number, unit: 'MBps' | 'Mbps'): string {
|
||||
export function formatSpeed(bytesPerSec: number, unit: 'MBps' | 'Mbps', locale?: string): string {
|
||||
if (unit === 'Mbps') {
|
||||
return `${((bytesPerSec * 8) / 1_000_000).toFixed(1)} Mbps`;
|
||||
return `${num(Number(((bytesPerSec * 8) / 1_000_000).toFixed(1)), locale)} Mbps`;
|
||||
}
|
||||
return `${formatSize(bytesPerSec)}/s`;
|
||||
return `${formatSize(bytesPerSec, locale)}/s`;
|
||||
}
|
||||
|
||||
/** Seconds -> "0:42" / "3:07" / "1:02:15". */
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
import { DICTS, type Messages } from '@shared/i18n';
|
||||
import type { AppSettings, SupportedLocale } from '@shared/types';
|
||||
import { formatDate, formatSize, formatSpeed } from './format';
|
||||
|
||||
type Params = Record<string, string | number>;
|
||||
|
||||
function lookup(dict: Messages, key: string): string | Messages | undefined {
|
||||
let node: string | Messages | undefined = dict;
|
||||
for (const part of key.split('.')) {
|
||||
if (typeof node !== 'object') return undefined;
|
||||
node = node[part];
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function interpolate(template: string, params?: Params): string {
|
||||
if (!params) return template;
|
||||
return template.replace(/\{(\w+)\}/g, (_, name: string) =>
|
||||
name in params ? String(params[name]) : `{${name}}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime translator. `t()` reads the `locale` signal, so any template binding
|
||||
* that calls it re-renders live on a language switch (the same reactive
|
||||
* function-call tracking the file-list icon classes rely on). Missing keys fall
|
||||
* back to English, then to the raw key so gaps are visible in dev.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class I18nService {
|
||||
readonly locale = signal<SupportedLocale>('en');
|
||||
private readonly dict = computed(() => DICTS[this.locale()]);
|
||||
|
||||
t(key: string, params?: Params): string {
|
||||
let value = lookup(this.dict(), key) ?? lookup(DICTS.en, key);
|
||||
// plural map: pick the CLDR category for params.count (fallback to English)
|
||||
if (value && typeof value === 'object') {
|
||||
const count = Number(params?.['count'] ?? NaN);
|
||||
if (Number.isFinite(count)) {
|
||||
const rule = new Intl.PluralRules(this.locale()).select(count);
|
||||
const picked = value[rule] ?? value['other'] ?? lookup(DICTS.en, `${key}.other`);
|
||||
value = typeof picked === 'string' ? picked : undefined;
|
||||
} else {
|
||||
value = undefined;
|
||||
}
|
||||
}
|
||||
return typeof value === 'string' ? interpolate(value, params) : key;
|
||||
}
|
||||
|
||||
// Locale-reactive formatting (live-updates the file list on switch).
|
||||
date(epochMs: number): string {
|
||||
return formatDate(epochMs, this.locale());
|
||||
}
|
||||
size(bytes: number): string {
|
||||
return formatSize(bytes, this.locale());
|
||||
}
|
||||
speed(bytesPerSec: number, unit: AppSettings['speedUnit']): string {
|
||||
return formatSpeed(bytesPerSec, unit, this.locale());
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
mynaCode,
|
||||
mynaCog,
|
||||
mynaDots,
|
||||
mynaDownload,
|
||||
mynaEdit,
|
||||
mynaFile,
|
||||
mynaFilePlus,
|
||||
@@ -51,6 +52,7 @@ export const APP_ICONS = {
|
||||
mynaCode,
|
||||
mynaCog,
|
||||
mynaDots,
|
||||
mynaDownload,
|
||||
mynaEdit,
|
||||
mynaFile,
|
||||
mynaFilePlus,
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Rem-to-pixel conversion at the CURRENT root font size.
|
||||
*
|
||||
* The interface scale (settings.uiScale) works by setting the root font-size,
|
||||
* so any TypeScript that positions or measures against rem-sized CSS must
|
||||
* convert through the LIVE root — never through an assumed 16. Both context
|
||||
* menus' flip/clamp math is the canonical consumer: their widths and row
|
||||
* heights are rem in CSS, and hardcoding their px mirrors is exactly the bug
|
||||
* that made the shadlike menu flip at the wrong edge once scaling landed.
|
||||
*
|
||||
* Read per call, not cached: the scale can change mid-session (stepper,
|
||||
* Ctrl+/-) and a menu opened after that must measure against the new root.
|
||||
*/
|
||||
export function remPx(rem: number): number {
|
||||
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Injectable, computed, inject, signal, type WritableSignal } from '@angular/core';
|
||||
import { Injectable, computed, effect, inject, signal, type WritableSignal } from '@angular/core';
|
||||
import type { ColumnKey, ConnectionConfig, FileEntry, SavedConnection } from '@shared/types';
|
||||
import { fileKind } from './file-icons';
|
||||
import { fileKindKey } from './file-icons';
|
||||
import { FocusService } from './focus.service';
|
||||
import { formatMode, isRootPath, joinPath } from './format';
|
||||
import { RpcService } from './rpc.service';
|
||||
import { I18nService } from './i18n.service';
|
||||
import { ToastService } from './toast.service';
|
||||
|
||||
export type Side = 'left' | 'right';
|
||||
@@ -12,7 +13,7 @@ export type SortKey = 'name' | ColumnKey;
|
||||
|
||||
/** Display string the text-based columns sort by. */
|
||||
function sortText(entry: FileEntry, key: 'kind' | 'rights' | 'owner' | 'group'): string {
|
||||
if (key === 'kind') return fileKind(entry);
|
||||
if (key === 'kind') return fileKindKey(entry);
|
||||
if (key === 'rights') return formatMode(entry.mode);
|
||||
return (key === 'owner' ? entry.owner : entry.group) ?? '';
|
||||
}
|
||||
@@ -108,19 +109,34 @@ export class PanelState {
|
||||
}
|
||||
}
|
||||
|
||||
/** Reload cwd, keeping cursor position and selection by name. */
|
||||
async refresh(): Promise<void> {
|
||||
private refreshing = false;
|
||||
|
||||
/**
|
||||
* Reload cwd, keeping cursor position and selection by name. `silent` (used by
|
||||
* auto-refresh) skips the loading/slowLoad flags — so no spinner, no dim
|
||||
* overlay, input never blocked — and touches no DOM, so scroll stays put.
|
||||
*/
|
||||
async refresh(opts?: { silent?: boolean }): Promise<void> {
|
||||
const silent = opts?.silent ?? false;
|
||||
// a silent refresh must never fight the user or overlap itself
|
||||
if (silent && (this.refreshing || this.busy())) return;
|
||||
this.refreshing = true;
|
||||
// snapshot what we're listing; a silent refresh has no busy-block, so the
|
||||
// user can navigate mid-flight — discard the result if they did
|
||||
const forSession = this.sessionId();
|
||||
const forDir = this.cwd();
|
||||
const keepCursorName = this.sorted()[this.cursor()]?.name;
|
||||
const keepSelected = this.selected();
|
||||
this.loading.set(true);
|
||||
const slowTimer = setTimeout(() => this.slowLoad.set(true), 300);
|
||||
if (!silent) this.loading.set(true);
|
||||
const slowTimer = silent ? null : setTimeout(() => this.slowLoad.set(true), 300);
|
||||
try {
|
||||
const res = await this.deps.rpc.request.listDir({
|
||||
sessionId: this.sessionId(),
|
||||
path: this.cwd(),
|
||||
sessionId: forSession,
|
||||
path: forDir,
|
||||
});
|
||||
if (this.sessionId() !== forSession || this.cwd() !== forDir) return;
|
||||
if (!res.ok) {
|
||||
this.deps.toast.error(res.error);
|
||||
if (!silent) this.deps.toast.error(res.error);
|
||||
return;
|
||||
}
|
||||
this.cwd.set(res.path);
|
||||
@@ -130,9 +146,12 @@ export class PanelState {
|
||||
const idx = this.sorted().findIndex((e) => e.name === keepCursorName);
|
||||
this.cursor.set(idx >= 0 ? idx : Math.min(this.cursor(), Math.max(0, this.sorted().length - 1)));
|
||||
} finally {
|
||||
clearTimeout(slowTimer);
|
||||
this.slowLoad.set(false);
|
||||
this.loading.set(false);
|
||||
if (slowTimer) clearTimeout(slowTimer);
|
||||
if (!silent) {
|
||||
this.slowLoad.set(false);
|
||||
this.loading.set(false);
|
||||
}
|
||||
this.refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,6 +226,7 @@ export interface Workspace {
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PanelsService {
|
||||
private readonly i18n = inject(I18nService);
|
||||
readonly workspaces = signal<Workspace[]>([]);
|
||||
readonly activeWorkspaceId = signal(0);
|
||||
readonly activeWorkspace = computed(() => {
|
||||
@@ -230,19 +250,70 @@ export class PanelsService {
|
||||
for (const workspace of this.workspaces()) {
|
||||
for (const panel of [workspace.left, workspace.right]) {
|
||||
if (panel.sessionId() === event.sessionId) {
|
||||
this.toast.error(`${panel.title()}: disconnected (${event.reason})`);
|
||||
this.toast.error(
|
||||
this.i18n.t('toasts.disconnectedReason', {
|
||||
title: panel.title(),
|
||||
reason: event.reason,
|
||||
}),
|
||||
);
|
||||
this.resetToLocal(panel);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// an editor window saved a file — refresh panels showing that directory
|
||||
// a watched local dir changed, or an editor saved a file — silently
|
||||
// refresh every panel showing that directory (no spinner, view preserved)
|
||||
this.rpc.onPathRefresh(({ sessionId, dir }) => {
|
||||
for (const panel of this.allPanels()) {
|
||||
if (panel.sessionId() === sessionId && panel.cwd() === dir) void panel.refresh();
|
||||
if (panel.sessionId() === sessionId && panel.cwd() === dir) {
|
||||
void panel.refresh({ silent: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.setupAutoRefresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-refresh the active tab's panels without disturbing the view. Local
|
||||
* panels are watched instantly via fs.watch (backend pushes pathRefresh);
|
||||
* remote panels have no change notification, so they're polled. Only the
|
||||
* active tab is watched/polled; switching tabs refreshes the new one at once.
|
||||
*/
|
||||
private setupAutoRefresh(): void {
|
||||
// REMOTE_POLL_MS: also the cadence a future "auto-refresh interval" setting
|
||||
// would drive (see the commented breadcrumbs in settings).
|
||||
const REMOTE_POLL_MS = 3000;
|
||||
setInterval(() => {
|
||||
const ws = this.activeWorkspace();
|
||||
if (!ws) return;
|
||||
for (const panel of [ws.left, ws.right]) {
|
||||
if (panel.sessionId() !== 'local') void panel.refresh({ silent: true });
|
||||
}
|
||||
}, REMOTE_POLL_MS);
|
||||
|
||||
// register the active tab's LOCAL dirs for fs.watch; re-runs when the active
|
||||
// tab changes or either panel navigates/(dis)connects
|
||||
effect(() => {
|
||||
const ws = this.activeWorkspace();
|
||||
const dirs = ws
|
||||
? [ws.left, ws.right]
|
||||
.filter((p) => p.sessionId() === 'local' && p.cwd())
|
||||
.map((p) => p.cwd())
|
||||
: [];
|
||||
void this.rpc.request.setWatchedDirs({ dirs: [...new Set(dirs)] });
|
||||
});
|
||||
|
||||
// switching tabs shows fresh data immediately instead of waiting a poll tick
|
||||
let lastActive = this.activeWorkspaceId();
|
||||
effect(() => {
|
||||
const id = this.activeWorkspaceId();
|
||||
if (id === lastActive) return;
|
||||
lastActive = id;
|
||||
const ws = this.activeWorkspace();
|
||||
if (ws) for (const panel of [ws.left, ws.right]) void panel.refresh({ silent: true });
|
||||
});
|
||||
}
|
||||
|
||||
// ---- panels of the active workspace ----
|
||||
@@ -327,6 +398,46 @@ export class PanelsService {
|
||||
this.refocus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close every tab except one — the tab menu's "Close Other Tabs".
|
||||
*
|
||||
* Snapshot the ids FIRST: closeWorkspace mutates the list on every call, so
|
||||
* iterating the live signal would skip half of them. It also refuses the
|
||||
* last tab, which is exactly the wanted floor here.
|
||||
*/
|
||||
closeOtherWorkspaces(id: number): void {
|
||||
for (const other of this.workspaces().map((w) => w.id)) {
|
||||
if (other !== id) this.closeWorkspace(other);
|
||||
}
|
||||
this.setActiveWorkspace(id);
|
||||
}
|
||||
|
||||
/** Close every tab to the right of one, in visual order. */
|
||||
closeWorkspacesToRight(id: number): void {
|
||||
const list = this.workspaces();
|
||||
const index = list.findIndex((w) => w.id === id);
|
||||
if (index < 0) return;
|
||||
for (const target of list.slice(index + 1).map((w) => w.id)) {
|
||||
this.closeWorkspace(target);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop both of a tab's remote sessions, keeping the tab itself. Unlike
|
||||
* `disconnectPanel` this targets a tab by id, because the right-clicked tab
|
||||
* is not necessarily the active one.
|
||||
*/
|
||||
async disconnectWorkspace(id: number): Promise<void> {
|
||||
const workspace = this.workspaces().find((w) => w.id === id);
|
||||
if (!workspace) return;
|
||||
for (const panel of [workspace.left, workspace.right]) {
|
||||
const sessionId = panel.sessionId();
|
||||
if (sessionId === 'local') continue;
|
||||
await this.rpc.request.disconnect({ sessionId });
|
||||
this.resetToLocal(panel);
|
||||
}
|
||||
}
|
||||
|
||||
setActiveWorkspace(id: number): void {
|
||||
if (this.activeWorkspaceId() !== id) {
|
||||
this.activeWorkspaceId.set(id);
|
||||
@@ -389,7 +500,7 @@ export class PanelsService {
|
||||
const remoteTitles = [workspace.left, workspace.right]
|
||||
.filter((p) => p.sessionId() !== 'local')
|
||||
.map((p) => p.title());
|
||||
return remoteTitles.length ? remoteTitles.join(' ⇄ ') : 'Local';
|
||||
return remoteTitles.length ? remoteTitles.join(' ⇄ ') : this.i18n.t('common.local');
|
||||
}
|
||||
|
||||
workspaceIsRemote(workspace: Workspace): boolean {
|
||||
@@ -0,0 +1,67 @@
|
||||
// Injected by the bun process via BrowserWindow preload (src/bun/windows.ts
|
||||
// buildPreload) before any page script. Brand-neutral names so rebrands never
|
||||
// touch this file.
|
||||
import type { EditorOpenContext } from '@shared/rpc';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__APP_PLATFORM?: string;
|
||||
/** Which root component this window boots. */
|
||||
__APP_MODE?: 'main' | 'editor' | 'manager';
|
||||
/** 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). */
|
||||
__APP_CTX?: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?? '') : '';
|
||||
|
||||
export const IS_MAC = PLATFORM === 'darwin';
|
||||
|
||||
export const IS_WINDOWS = PLATFORM === 'win32';
|
||||
|
||||
export const IS_LINUX = PLATFORM === 'linux';
|
||||
|
||||
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. Keyed on the real
|
||||
* 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';
|
||||
|
||||
export const EDITOR_CONTEXT =
|
||||
typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined;
|
||||
|
||||
/**
|
||||
* Which skin tree this window booted. Read from the preload rather than from
|
||||
* SettingsService because both are needed before first paint — main.ts picks a
|
||||
* 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
|
||||
* `hiddenInset` layout, which reserves `pl-24` for them. False under the win7
|
||||
* skin even on macOS: `titleBarStyle: 'hidden'` removes the traffic lights
|
||||
* altogether, so reserving space would leave a gap. Only the shadlike tree
|
||||
* reads this; 7like draws its own title bar and never defers to native chrome.
|
||||
*/
|
||||
export const IS_MAC_CHROME = IS_MAC && !IS_WIN7_CHROME;
|
||||
|
||||
/**
|
||||
* Kill the webview's default right-click menu (Reload, Inspect Element…)
|
||||
* everywhere the app doesn't show its own native menu. Text fields keep the
|
||||
* system text menu (cut/copy/paste, spelling).
|
||||
*/
|
||||
export function blockNativeContextMenu(event: Event): void {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest('input, textarea, [contenteditable="true"]')) return;
|
||||
event.preventDefault();
|
||||
}
|
||||
@@ -3,6 +3,13 @@ import { Electroview } from 'electrobun/view';
|
||||
import type { AppRPC } from '@shared/rpc';
|
||||
import type { AppSettings, TransferSnapshot } from '@shared/types';
|
||||
|
||||
export type UpdateStatus = {
|
||||
phase: 'available' | 'downloading' | 'error';
|
||||
version?: string;
|
||||
progress?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
// Matches MAX_REQUEST_TIME_MS on the bun side — SFTP ops can be slow.
|
||||
const MAX_REQUEST_TIME_MS = 10 * 60_000;
|
||||
|
||||
@@ -24,6 +31,8 @@ export class RpcService {
|
||||
private editorCloseListeners = new Set<() => void>();
|
||||
private managerStateListeners = new Set<(open: boolean) => void>();
|
||||
private connectionsListeners = new Set<() => void>();
|
||||
private updateStatusListeners = new Set<(status: UpdateStatus) => void>();
|
||||
private windowStateListeners = new Set<(maximized: boolean) => void>();
|
||||
|
||||
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
|
||||
private ready: Promise<void> = Promise.resolve();
|
||||
@@ -60,6 +69,12 @@ export class RpcService {
|
||||
connectionsChanged: () => {
|
||||
for (const listener of this.connectionsListeners) listener();
|
||||
},
|
||||
updateStatus: (status) => {
|
||||
for (const listener of this.updateStatusListeners) listener(status);
|
||||
},
|
||||
windowState: ({ maximized }) => {
|
||||
for (const listener of this.windowStateListeners) listener(maximized);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -88,6 +103,15 @@ export class RpcService {
|
||||
this.menuListeners.add(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an action that originated in the UI (the HTML context menu) as if
|
||||
* it had arrived from bun, so app.ts keeps ONE handler for menu actions
|
||||
* regardless of whether the app menubar or a right-click produced them.
|
||||
*/
|
||||
emitMenuAction(action: string): void {
|
||||
for (const listener of this.menuListeners) listener(action);
|
||||
}
|
||||
|
||||
onSettingsChanged(listener: (settings: AppSettings) => void): void {
|
||||
this.settingsListeners.add(listener);
|
||||
}
|
||||
@@ -108,6 +132,16 @@ export class RpcService {
|
||||
this.connectionsListeners.add(listener);
|
||||
}
|
||||
|
||||
onUpdateStatus(listener: (status: UpdateStatus) => void): void {
|
||||
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. */
|
||||
get request() {
|
||||
const rpc = this.rpc;
|
||||
@@ -1,23 +1,33 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { DEFAULT_SETTINGS, type AppSettings, type ColumnKey } from '@shared/types';
|
||||
import { resolveLocale } from '@shared/i18n';
|
||||
import { clampUiScale, DEFAULT_SETTINGS, type AppSettings, type ColumnKey } from '@shared/types';
|
||||
import { I18nService } from './i18n.service';
|
||||
import { RpcService } from './rpc.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SettingsService {
|
||||
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 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 speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit);
|
||||
readonly textSize = signal<AppSettings['textSize']>(DEFAULT_SETTINGS.textSize);
|
||||
readonly uiScale = signal<number>(DEFAULT_SETTINGS.uiScale);
|
||||
readonly transferMode = signal<AppSettings['transferMode']>(DEFAULT_SETTINGS.transferMode);
|
||||
readonly transferConcurrency = signal(DEFAULT_SETTINGS.transferConcurrency);
|
||||
readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted);
|
||||
readonly editor = signal<AppSettings['editor']>(DEFAULT_SETTINGS.editor);
|
||||
readonly editorApp = signal(DEFAULT_SETTINGS.editorApp);
|
||||
// Auto-refresh is always on for now (see panels.service). To make it
|
||||
// user-controllable: uncomment the field in types.ts, then here add
|
||||
// readonly autoRefresh = signal(DEFAULT_SETTINGS.autoRefresh);
|
||||
// load it in the constructor, add setAutoRefresh(), include it in settings(),
|
||||
// gate the poll/watch in panels.service on it, and add the tab-bar row below.
|
||||
|
||||
private readonly rpc = inject(RpcService);
|
||||
private readonly i18n = inject(I18nService);
|
||||
|
||||
/** Whether the app is actually rendering dark right now (drives the Tone row). */
|
||||
readonly effectiveDark = computed(
|
||||
@@ -40,22 +50,58 @@ export class SettingsService {
|
||||
}
|
||||
|
||||
private applyAll(settings: AppSettings): void {
|
||||
this.locale.set(settings.locale);
|
||||
this.skin.set(settings.skin);
|
||||
this.theme.set(settings.theme);
|
||||
this.darkTone.set(settings.darkTone);
|
||||
this.accent.set(settings.accent);
|
||||
this.iconColors.set(settings.iconColors);
|
||||
this.columns.set(settings.columns);
|
||||
this.speedUnit.set(settings.speedUnit);
|
||||
this.textSize.set(settings.textSize);
|
||||
this.uiScale.set(settings.uiScale);
|
||||
this.transferMode.set(settings.transferMode);
|
||||
this.transferConcurrency.set(settings.transferConcurrency);
|
||||
this.autoClearCompleted.set(settings.autoClearCompleted);
|
||||
this.editor.set(settings.editor);
|
||||
this.editorApp.set(settings.editorApp);
|
||||
// NO applySkin here, deliberately — see setSkin. applyAll also runs on the
|
||||
// settingsChanged push, so flipping the class here would break every OPEN
|
||||
// editor and manager window too, not just the one that changed the setting.
|
||||
this.applyTheme();
|
||||
this.applyDarkTone();
|
||||
this.applyAccent();
|
||||
this.applyTextSize();
|
||||
this.applyUiScale();
|
||||
this.applyLocale();
|
||||
}
|
||||
|
||||
setLocale(locale: AppSettings['locale']): void {
|
||||
this.locale.set(locale);
|
||||
this.applyLocale();
|
||||
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 {
|
||||
@@ -94,9 +140,9 @@ export class SettingsService {
|
||||
void this.persist();
|
||||
}
|
||||
|
||||
setTextSize(size: AppSettings['textSize']): void {
|
||||
this.textSize.set(size);
|
||||
this.applyTextSize();
|
||||
setUiScale(scale: number): void {
|
||||
this.uiScale.set(clampUiScale(scale));
|
||||
this.applyUiScale();
|
||||
void this.persist();
|
||||
}
|
||||
|
||||
@@ -125,6 +171,12 @@ export class SettingsService {
|
||||
void this.persist();
|
||||
}
|
||||
|
||||
// There is deliberately no applySkin(). The `.win7` class is written once by
|
||||
// buildPreload at DOCUMENT_START for the skin this window actually booted,
|
||||
// and must never change afterwards — see setSkin for why. Every other apply*
|
||||
// below is safe to run live because it only re-points tokens, and both skins
|
||||
// consume the same token system.
|
||||
|
||||
private applyTheme(): void {
|
||||
const theme = this.theme();
|
||||
if (theme === 'system') {
|
||||
@@ -152,24 +204,35 @@ export class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
private applyTextSize(): void {
|
||||
const size = this.textSize();
|
||||
if (size === 'default') {
|
||||
delete document.documentElement.dataset['text'];
|
||||
/** 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
|
||||
* browser-default 16px root; delete-when-default keeps the pristine state
|
||||
* attribute-free like the other axes. buildPreload mirrors this for a
|
||||
* flash-free first paint. */
|
||||
private applyUiScale(): void {
|
||||
const scale = this.uiScale();
|
||||
if (scale === 1) {
|
||||
document.documentElement.style.removeProperty('font-size');
|
||||
} else {
|
||||
document.documentElement.dataset['text'] = size;
|
||||
document.documentElement.style.fontSize = `${16 * scale}px`;
|
||||
}
|
||||
}
|
||||
|
||||
private applyLocale(): void {
|
||||
this.i18n.locale.set(resolveLocale(this.locale(), navigator.language));
|
||||
}
|
||||
|
||||
protected settings(): AppSettings {
|
||||
return {
|
||||
locale: this.locale(),
|
||||
skin: this.skin(),
|
||||
theme: this.theme(),
|
||||
darkTone: this.darkTone(),
|
||||
accent: this.accent(),
|
||||
iconColors: this.iconColors(),
|
||||
columns: this.columns(),
|
||||
speedUnit: this.speedUnit(),
|
||||
textSize: this.textSize(),
|
||||
uiScale: this.uiScale(),
|
||||
transferMode: this.transferMode(),
|
||||
transferConcurrency: this.transferConcurrency(),
|
||||
autoClearCompleted: this.autoClearCompleted(),
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Every window-level shortcut the app answers to, by name. Components switch
|
||||
* on these ids instead of key-matching inline, so the whole keymap is ONE
|
||||
* table — the precondition for the vim-style bindings planned next, which
|
||||
* will need a mode/leader state machine wrapped around exactly this point.
|
||||
*/
|
||||
export type ShortcutId =
|
||||
// main window (workspace/tab management)
|
||||
| 'new-tab'
|
||||
| 'close-tab'
|
||||
| 'cycle-tab'
|
||||
| 'cycle-tab-back'
|
||||
| 'go-to-tab' // 1-9; the digit rides in ShortcutMatch.digit
|
||||
// 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
|
||||
* previously copy-pasted inline in six places, drifting apart):
|
||||
* - `mod` is metaKey OR ctrlKey — ⌘ on macOS, Ctrl elsewhere. The keyboard
|
||||
* does not change with the window chrome (platform.ts says the same about
|
||||
* MOD_LABEL).
|
||||
* - Layout-dependent keys match on `event.code`, not `event.key`: digits
|
||||
* (AZERTY's digit row shifts), +/- (key varies wildly across layouts), and
|
||||
* letter chords with Alt (macOS Option produces Ω for ⌥Z). Plain letter
|
||||
* chords match on `key` so they follow the user's layout.
|
||||
* - isTypingTarget guards shortcuts that would be hostile mid-typing. It is
|
||||
* deliberately NOT applied inside resolve(): the existing bindings rely on
|
||||
* the path bar's stopPropagation and the editor WANTS Mod+S while typing.
|
||||
* Callers opt in per shortcut (the scale keys do).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ShortcutsService {
|
||||
/** The platform command modifier: ⌘ on macOS, Ctrl elsewhere. Mouse events
|
||||
* qualify too — Mod+click toggle-select in the panels uses the same rule. */
|
||||
isMod(event: KeyboardEvent | MouseEvent): boolean {
|
||||
return event.metaKey || event.ctrlKey;
|
||||
}
|
||||
|
||||
/** True when the event originates from something the user types into. */
|
||||
isTypingTarget(event: KeyboardEvent): boolean {
|
||||
const target = event.target as HTMLElement | null;
|
||||
return !!target?.closest('input, textarea, select, [contenteditable="true"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an event against the scope's keymap. Returns null for anything the
|
||||
* table does not claim — callers must then leave the event alone.
|
||||
*/
|
||||
resolve(event: KeyboardEvent, scope: ShortcutScope): ShortcutMatch | null {
|
||||
const mod = this.isMod(event);
|
||||
const key = event.key.toLowerCase();
|
||||
|
||||
// ---- any window: interface scale ----
|
||||
// event.code, not key: '+' needs Shift on many layouts and '-' moves
|
||||
// around; the physical Equal/Minus keys (and the numpad pair) are stable.
|
||||
if (mod && !event.altKey) {
|
||||
if (event.code === 'Equal' || event.code === 'NumpadAdd') return { id: 'scale-up' };
|
||||
if (event.code === 'Minus' || event.code === 'NumpadSubtract') return { id: 'scale-down' };
|
||||
if (event.code === 'Digit0' || event.code === 'Numpad0') return { id: 'scale-reset' };
|
||||
}
|
||||
|
||||
if (scope === 'manager') return null; // any-window shortcuts only
|
||||
|
||||
if (scope === 'main') {
|
||||
if (mod && key === 't') return { id: 'new-tab' };
|
||||
if (mod && key === 'w') return { id: 'close-tab' };
|
||||
// ctrlKey only, never meta: ⌘Tab is the macOS app switcher
|
||||
if (event.ctrlKey && event.key === 'Tab') {
|
||||
return { id: event.shiftKey ? 'cycle-tab-back' : 'cycle-tab' };
|
||||
}
|
||||
if (mod && !event.shiftKey && !event.altKey) {
|
||||
const digit = /^(?:Digit|Numpad)([1-9])$/.exec(event.code)?.[1];
|
||||
if (digit) return { id: 'go-to-tab', digit: Number(digit) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// scope === 'editor'
|
||||
if (mod && key === 's') return { id: 'save' };
|
||||
if (mod && key === 'f') return { id: 'find' };
|
||||
// on macOS the app menu intercepts ⌘W before this ever fires; this arm is
|
||||
// the Windows/Linux path
|
||||
if (mod && key === 'w') return { id: 'close-window' };
|
||||
if (event.altKey && event.code === 'KeyZ') return { id: 'toggle-wrap' };
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { DialogsService } from './dialogs.service';
|
||||
import { PanelsService, type Side } from './panels.service';
|
||||
import { RpcService } from './rpc.service';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { I18nService } from './i18n.service';
|
||||
import { ToastService } from './toast.service';
|
||||
|
||||
const AUTO_DISMISS_COMPLETED_MS = 5000;
|
||||
@@ -13,6 +14,7 @@ const SPEED_SMOOTHING = 0.7;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TransfersService {
|
||||
private readonly i18n = inject(I18nService);
|
||||
private readonly rpc = inject(RpcService);
|
||||
private readonly panels = inject(PanelsService);
|
||||
private readonly toast = inject(ToastService);
|
||||
@@ -53,19 +55,19 @@ export class TransfersService {
|
||||
(e) => e.type !== 'symlink' && e.name !== '..',
|
||||
);
|
||||
if (!targets.length) {
|
||||
this.toast.info('Nothing to copy (symlinks are skipped)');
|
||||
this.toast.info(this.i18n.t('toasts.nothingToCopy'));
|
||||
return;
|
||||
}
|
||||
const sameFs = src.fsKey() === dst.fsKey();
|
||||
if (sameFs && src.cwd() === dst.cwd()) {
|
||||
this.toast.info('Both panels show the same folder');
|
||||
this.toast.info(this.i18n.t('toasts.sameFolder'));
|
||||
return;
|
||||
}
|
||||
|
||||
const finalTargets = await this.resolveCollisions(targets, dst.entries(), sameFs);
|
||||
if (finalTargets === null) return; // user cancelled
|
||||
if (!finalTargets.length) {
|
||||
this.toast.info('Nothing to copy — all conflicting items were skipped');
|
||||
this.toast.info(this.i18n.t('toasts.nothingToCopyAllSkipped'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -105,11 +107,11 @@ export class TransfersService {
|
||||
}
|
||||
if (sameFs && dstEntry.path === target.path) {
|
||||
// replacing a file with ITSELF would only destroy it — never offer that
|
||||
this.toast.info(`Skipped "${target.name}" — it is the same file`);
|
||||
this.toast.info(this.i18n.t('toasts.sameFile', { name: target.name }));
|
||||
continue;
|
||||
}
|
||||
if ((dstEntry.type === 'dir') !== (target.type === 'dir')) {
|
||||
this.toast.info(`Skipped "${target.name}" — a different kind of item is in the way`);
|
||||
this.toast.info(this.i18n.t('toasts.typeMismatch', { name: target.name }));
|
||||
continue;
|
||||
}
|
||||
if (mode === 'all') {
|
||||
@@ -178,7 +180,12 @@ export class TransfersService {
|
||||
}
|
||||
} else if (snapshot.state === 'failed') {
|
||||
this.expanded.set(true);
|
||||
this.toast.error(`Transfer "${snapshot.name}" failed: ${snapshot.error ?? 'unknown error'}`);
|
||||
this.toast.error(
|
||||
this.i18n.t('toasts.transferFailed', {
|
||||
name: snapshot.name,
|
||||
error: snapshot.error ?? this.i18n.t('toasts.unknownError'),
|
||||
}),
|
||||
);
|
||||
this.refreshPanelsOn(snapshot.dstSessionId);
|
||||
} else if (snapshot.state === 'cancelled') {
|
||||
this.refreshPanelsOn(snapshot.dstSessionId);
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { RpcService } from './rpc.service';
|
||||
|
||||
export type UpdaterState = 'idle' | 'available' | 'downloading' | 'ready' | 'error';
|
||||
|
||||
/**
|
||||
* Drives the tab-bar update pill. The backend pushes updateStatus events
|
||||
* (available / downloading progress / error); "ready" is derived locally when
|
||||
* downloadUpdate resolves ok. Two-step UX: available -> download -> ready ->
|
||||
* apply (relaunch).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UpdaterService {
|
||||
private rpc = inject(RpcService);
|
||||
|
||||
readonly state = signal<UpdaterState>('idle');
|
||||
readonly version = signal('');
|
||||
readonly progress = signal(0);
|
||||
|
||||
constructor() {
|
||||
if (!this.rpc.available) return;
|
||||
this.rpc.onUpdateStatus((s) => {
|
||||
if (s.phase === 'available') {
|
||||
this.version.set(s.version ?? '');
|
||||
this.state.set('available');
|
||||
} else if (s.phase === 'downloading') {
|
||||
this.state.set('downloading');
|
||||
this.progress.set(s.progress ?? 0);
|
||||
} else if (s.phase === 'error') {
|
||||
this.state.set('error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async download(): Promise<void> {
|
||||
if (this.state() !== 'available') return;
|
||||
this.state.set('downloading');
|
||||
this.progress.set(0);
|
||||
const r = await this.rpc.request.downloadUpdate({});
|
||||
this.state.set(r?.ok ? 'ready' : 'error');
|
||||
}
|
||||
|
||||
apply(): void {
|
||||
if (this.state() !== 'ready') return;
|
||||
void this.rpc.request.applyUpdate({}); // quits + relaunches
|
||||
}
|
||||
|
||||
/** Manual re-check (menu item). */
|
||||
async check(): Promise<void> {
|
||||
if (!this.rpc.available) return;
|
||||
const r = await this.rpc.request.checkForUpdate({});
|
||||
if (r?.ok && r.updateAvailable) {
|
||||
this.version.set(r.version);
|
||||
this.state.set('available');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// Injected by the bun process via BrowserWindow preload (src/bun/windows.ts
|
||||
// buildPreload) before any page script. Brand-neutral names so rebrands never
|
||||
// touch this file.
|
||||
import type { EditorOpenContext } from '@shared/rpc';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__APP_PLATFORM?: string;
|
||||
/** Which root component this window boots. */
|
||||
__APP_MODE?: 'main' | 'editor' | 'manager';
|
||||
/** Window-kind specific context (EditorOpenContext for editor windows). */
|
||||
__APP_CTX?: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?? '') : '';
|
||||
|
||||
/** True when the window uses macOS hiddenInset chrome (header fused with titlebar). */
|
||||
export const IS_MAC_CHROME = PLATFORM === 'darwin';
|
||||
|
||||
export const IS_WINDOWS = PLATFORM === 'win32';
|
||||
|
||||
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. */
|
||||
export const MOD_LABEL = IS_MAC_CHROME ? '⌘' : 'Ctrl+';
|
||||
export const ALT_LABEL = IS_MAC_CHROME ? '⌥' : 'Alt+';
|
||||
|
||||
export const APP_MODE: 'main' | 'editor' | 'manager' =
|
||||
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
|
||||
|
||||
export const EDITOR_CONTEXT =
|
||||
typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined;
|
||||
|
||||
/**
|
||||
* Kill the webview's default right-click menu (Reload, Inspect Element…)
|
||||
* everywhere the app doesn't show its own native menu. Text fields keep the
|
||||
* system text menu (cut/copy/paste, spelling).
|
||||
*/
|
||||
export function blockNativeContextMenu(event: Event): void {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest('input, textarea, [contenteditable="true"]')) return;
|
||||
event.preventDefault();
|
||||
}
|
||||
@@ -0,0 +1,840 @@
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<!-- .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>
|
||||
@@ -0,0 +1,307 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
<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>
|
||||
@@ -0,0 +1,291 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
<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>
|
||||
@@ -0,0 +1,389 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||