Compare commits
33
Commits
07825c1bd1
...
4605a481f1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -8,3 +8,6 @@ ui/dist/
|
|||||||
ui/.angular/
|
ui/.angular/
|
||||||
src/shared/version.gen.ts
|
src/shared/version.gen.ts
|
||||||
release/
|
release/
|
||||||
|
|
||||||
|
# headless screenshot workspace (scripts/shot.ts)
|
||||||
|
.shot/
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ remote server over SSH/SFTP, so you can move files **local↔remote**,
|
|||||||
Dense reference material lives in [docs/](docs/): [architecture](docs/architecture.md)
|
Dense reference material lives in [docs/](docs/): [architecture](docs/architecture.md)
|
||||||
(processes, windows, RPC, every module/service), [build & release](docs/build-and-release.md)
|
(processes, windows, RPC, every module/service), [build & release](docs/build-and-release.md)
|
||||||
(Build-n versioning, per-target release pipeline, CI), [platform notes](docs/platform-notes.md)
|
(Build-n versioning, per-target release pipeline, CI), [platform notes](docs/platform-notes.md)
|
||||||
(macOS/Windows matrix + smoke tests), and [gotchas](docs/gotchas.md) (known landmines).
|
(macOS/Windows/Linux matrix, runtime deps + smoke tests), and
|
||||||
|
[gotchas](docs/gotchas.md) (known landmines).
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -75,6 +76,11 @@ ui/src/app/ Angular components + signal services
|
|||||||
|
|
||||||
- [Bun](https://bun.sh) ≥ 1.3 (`bun --version`).
|
- [Bun](https://bun.sh) ≥ 1.3 (`bun --version`).
|
||||||
- macOS: **Xcode Command Line Tools** (for the app build).
|
- macOS: **Xcode Command Line Tools** (for the app build).
|
||||||
|
- Linux: **AppIndicator** — the app links it for the tray and will not start
|
||||||
|
without it (`ERR_DLOPEN_FAILED`). `libayatana-appindicator` (Arch),
|
||||||
|
`libayatana-appindicator3-1` (Debian/Ubuntu),
|
||||||
|
`libayatana-appindicator-gtk3` (Fedora), plus system `webkit2gtk`.
|
||||||
|
Details and diagnostics in [platform notes](docs/platform-notes.md).
|
||||||
- No Node install required — a preload shim (`scripts/node-version-shim.ts`)
|
- No Node install required — a preload shim (`scripts/node-version-shim.ts`)
|
||||||
satisfies the Angular CLI's Node version check while running under Bun.
|
satisfies the Angular CLI's Node version check while running under Bun.
|
||||||
|
|
||||||
@@ -107,11 +113,14 @@ cross-compile between OSes:
|
|||||||
bun run release -- --target mac-arm64 # on the Mac (native)
|
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 mac-x64 # on the same arm64 Mac, via Rosetta 2
|
||||||
bun run release -- --target win-x64 # on a Windows 11 x64 machine
|
bun run release -- --target win-x64 # on a Windows 11 x64 machine
|
||||||
|
bun run release -- --target linux-x64 # on a Linux x64 machine
|
||||||
```
|
```
|
||||||
|
|
||||||
Artifacts land in `release/<target>/miniSCP-Build<n>-<target>.*` — a `.dmg`
|
Artifacts land in `release/<target>/miniSCP-Build<n>-<target>.*` — a `.dmg`
|
||||||
per mac arch; on Windows a `-Setup.zip` installer **and** a `-portable.zip`
|
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
|
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
|
believes it is on an Intel Mac. CI: `.gitea/workflows/release.yml` runs the
|
||||||
same script on a macos + windows runner matrix once act_runners are
|
same script on a macos + windows runner matrix once act_runners are
|
||||||
@@ -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;
|
application menu ([src/bun/menu.ts](src/bun/menu.ts)) is macOS-only for now;
|
||||||
the in-app UI covers every menu action.
|
the in-app UI covers every menu action.
|
||||||
|
|
||||||
|
Right-click menus are **HTML, on every platform**
|
||||||
|
([ui/src/app/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 (beta)
|
||||||
|
|
||||||
Windows 11+ x64 is supported as a **beta** target (win-arm64 deferred). All
|
Windows 11+ x64 is supported as a **beta** target (win-arm64 deferred). All
|
||||||
@@ -231,6 +246,29 @@ drive root ('..' disappears); local↔remote transfers; external editor launch
|
|||||||
renders colored; Permissions absent on local panels; Ctrl+labels in tooltips;
|
renders colored; Permissions absent on local panels; Ctrl+labels in tooltips;
|
||||||
portable zip runs from any folder.
|
portable zip runs from any folder.
|
||||||
|
|
||||||
|
## Linux (beta)
|
||||||
|
|
||||||
|
Linux x64 is supported as a **beta** target and 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
|
## Known issues
|
||||||
|
|
||||||
- **Rare native crash under load.** A long-running session (multiple remote
|
- **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
|
Drag-and-drop, file preview/edit, transfer resume, symlink following
|
||||||
(symlinks are listed but skipped by transfers), OS keychain, tab persistence
|
(symlinks are listed but skipped by transfers), OS keychain, tab persistence
|
||||||
across restarts, Windows/Linux packaging (the code is portable; only macOS
|
across restarts, Linux desktop integration (AppImage/.deb/.desktop entry —
|
||||||
packaging is wired up).
|
the release is a portable tarball).
|
||||||
|
|||||||
+213
-38
@@ -10,7 +10,8 @@ Dense reference for development and debugging. Companion files:
|
|||||||
its native launcher. Owns every privileged capability: filesystem, SSH,
|
its native launcher. Owns every privileged capability: filesystem, SSH,
|
||||||
window management, native menus/dialogs/tray, the connection/settings stores.
|
window management, native menus/dialogs/tray, the connection/settings stores.
|
||||||
- **Webviews** (`ui/` Angular): one per window, system WebKit on macOS /
|
- **Webviews** (`ui/` Angular): one per window, system WebKit on macOS /
|
||||||
WebView2 on Windows. Zero privileged access — everything goes through RPC.
|
WebView2 on Windows / WebKitGTK on Linux. Zero privileged access — everything
|
||||||
|
goes through RPC.
|
||||||
- **RPC transport**: one WebSocket server in the bun process, multiplexed by
|
- **RPC transport**: one WebSocket server in the bun process, multiplexed by
|
||||||
`webviewId` (each webview auto-connects using natively injected globals).
|
`webviewId` (each webview auto-connects using natively injected globals).
|
||||||
Requests are typed by the `AppRPC` schema in `src/shared/rpc.ts`.
|
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
|
## Window kinds
|
||||||
|
|
||||||
`src/bun/windows.ts` — `buildPreload(mode, ctx?)` injects `__APP_PLATFORM`,
|
`src/bun/windows.ts` — `buildPreload(mode, settings, ctx?)` injects
|
||||||
`__APP_MODE` (`main|editor|manager`), `__APP_CTX` before any page script.
|
`__APP_PLATFORM`, `__APP_MODE` (`main|editor|manager`), `__APP_SKIN`
|
||||||
`ui/src/main.ts` picks the root component from `APP_MODE`; all three roots use
|
(`modern|win7`), `__APP_CTX` before any page script. `ui/src/main.ts` selects on
|
||||||
selector `app-root` (index.html hosts only that tag). `openSecondaryWindow` is
|
BOTH axes: the skin picks a component tree, `APP_MODE` picks a root within it
|
||||||
the ONLY place that branches platform for secondary chrome: macOS
|
(see "UI layout" below). All roots use selector `app-root` (index.html hosts
|
||||||
`hiddenInset` + `trafficLightOffset {x:12,y:13}` (the UI renders an
|
only that tag). Both globals are preload-baked because both are needed before
|
||||||
`h-10 pl-24` drag-region header via `IS_MAC_CHROME`); other platforms keep the
|
first paint — settings arrive over an async RPC, far too late to choose a tree
|
||||||
native titlebar and a compact `h-9` non-drag header.
|
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,
|
`WindowRegistry<Rec>` (same file): keyed window map with dedupe/activate,
|
||||||
cascade placement off an anchor frame, focus tracking. electrobun's emitter
|
cascade placement off an anchor frame, focus tracking. electrobun's emitter
|
||||||
@@ -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
|
records can be garbage collected. A future "window follows window" grouping
|
||||||
belongs here (`anchor.on('move')` + `setPosition`).
|
belongs here (`anchor.on('move')` + `setPosition`).
|
||||||
|
|
||||||
### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/editor-window/`)
|
### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/skins/*/editor-window/`)
|
||||||
|
|
||||||
One window per file, key `sessionId+path` (sessionId, not fsKey: saves route
|
One window per file, key `sessionId+path` (sessionId, not fsKey: saves route
|
||||||
through a concrete session, and `local` is one shared session anyway).
|
through a concrete session, and `local` is one shared session anyway).
|
||||||
@@ -148,10 +153,6 @@ would float over editor windows).
|
|||||||
- `store/connections.ts` — plaintext-passwords JSON (deliberate v1 choice),
|
- `store/connections.ts` — plaintext-passwords JSON (deliberate v1 choice),
|
||||||
atomic tmp+rename, 0600 + win32 `icacls` ACL. `store/settings.ts` — defaults
|
atomic tmp+rename, 0600 + win32 `icacls` ACL. `store/settings.ts` — defaults
|
||||||
spread-merge on load, so NEW SETTINGS KEYS need no backend change.
|
spread-merge on load, so NEW SETTINGS KEYS need no backend change.
|
||||||
- `context-menu.ts` — native row/empty-space menus + the column picker.
|
|
||||||
Local shell actions (reveal/open/copy-path) run bun-side via Utils; others
|
|
||||||
forward through `menuAction` and the UI applies them to the row it
|
|
||||||
remembered (`FileActionsService.menuTarget`). Labels are per-platform.
|
|
||||||
- `menu.ts` — macOS-only app menu, rebuilt on every connections change (the
|
- `menu.ts` — macOS-only app menu, rebuilt on every connections change (the
|
||||||
Connect submenu mirrors the store). ⌘W is intercepted by the menu, so bun
|
Connect submenu mirrors the store). ⌘W is intercepted by the menu, so bun
|
||||||
routes `close-tab` to the focused editor window first. Labels go through
|
routes `close-tab` to the focused editor window first. Labels go through
|
||||||
@@ -161,7 +162,8 @@ would float over editor windows).
|
|||||||
locale (from `settings.locale`, `system` via `Intl…resolvedOptions().locale`);
|
locale (from `settings.locale`, `system` via `Intl…resolvedOptions().locale`);
|
||||||
`t(key, params)` shares the same `src/shared/i18n` dictionaries as the UI.
|
`t(key, params)` shares the same `src/shared/i18n` dictionaries as the UI.
|
||||||
Wired in index.ts on boot and on `settingsChanged` (relocalizes + rebuilds
|
Wired in index.ts on boot and on `settingsChanged` (relocalizes + rebuilds
|
||||||
the app menu). `context-menu.ts` labels resolve through `t()` at show-time.
|
the app menu). The context menu no longer needs this — it is Angular and reads
|
||||||
|
`I18nService` directly.
|
||||||
- `tray.ts` — static icon, click = activate main. macOS template image;
|
- `tray.ts` — static icon, click = activate main. macOS template image;
|
||||||
Windows colored icon. No `setImage` calls ever (they lose the template
|
Windows colored icon. No `setImage` calls ever (they lose the template
|
||||||
treatment) and no menu (clicks arrive directly).
|
treatment) and no menu (clicks arrive directly).
|
||||||
@@ -169,7 +171,41 @@ would float over editor windows).
|
|||||||
rpc; manager/editors constructed after win; quit guard; focus tracking for
|
rpc; manager/editors constructed after win; quit guard; focus tracking for
|
||||||
notification suppression across all windows).
|
notification suppression across all windows).
|
||||||
|
|
||||||
## UI services (`ui/src/app/`)
|
## UI layout: 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
|
- `rpc.service.ts` — wraps `Electroview.defineRPC`. **Push messages fan out
|
||||||
through plain callback registries, never signals**: a signal consumed from
|
through plain callback registries, never signals**: a signal consumed from
|
||||||
@@ -220,12 +256,35 @@ would float over editor windows).
|
|||||||
boot and on the `settingsChanged` broadcast (so editor/manager windows follow).
|
boot and on the `settingsChanged` broadcast (so editor/manager windows follow).
|
||||||
- `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always
|
- `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always
|
||||||
against an explicit panel; `menuTarget` remembers the right-clicked row.
|
against an explicit panel; `menuTarget` remembers the right-clicked row.
|
||||||
|
- `context-menu.service.ts` + `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
|
- `file-icons.ts` — extension → category map driving glyphs, colors
|
||||||
(fixed `--icon-*` tokens, never accent-tinted) and the Kind column.
|
(fixed `--icon-*` tokens, never accent-tinted) and the Kind column.
|
||||||
- `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`,
|
- `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`,
|
||||||
`EDITOR_CONTEXT`, `IS_MAC_CHROME`, `IS_WINDOWS`, `MOD_LABEL`/`ALT_LABEL`)
|
`EDITOR_CONTEXT`, `IS_MAC`/`IS_MAC_CHROME`, `IS_WINDOWS`, `IS_LINUX`,
|
||||||
+ `blockNativeContextMenu` (kills WebKit's Reload/Inspect menu outside
|
`MOD_LABEL`/`ALT_LABEL`) + `blockNativeContextMenu` (kills WebKit's
|
||||||
text fields).
|
Reload/Inspect menu outside text fields — the app draws its own menu instead).
|
||||||
- `updater.service.ts` — auto-updater state for the tab-bar pill. Signals
|
- `updater.service.ts` — auto-updater state for the tab-bar pill. Signals
|
||||||
`state` (`idle→available→downloading→ready`|`error`), `version`, `progress`.
|
`state` (`idle→available→downloading→ready`|`error`), `version`, `progress`.
|
||||||
Subscribes the `updateStatus` push (backend `Updater.checkForUpdate` on
|
Subscribes the `updateStatus` push (backend `Updater.checkForUpdate` on
|
||||||
@@ -257,9 +316,15 @@ toolbars deliberately stay on `--background`. Accent tones: one
|
|||||||
`@theme inline` maps tokens to Tailwind utilities (`bg-primary`,
|
`@theme inline` maps tokens to Tailwind utilities (`bg-primary`,
|
||||||
`text-icon-folder`, `bg-cursor-tint`). Selected rows are `bg-primary/15`
|
`text-icon-folder`, `bg-cursor-tint`). Selected rows are `bg-primary/15`
|
||||||
(follow the accent); the cursor row uses `--cursor-tint` so graphite stays
|
(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`,
|
pixel-identical to the old neutral hover. **Interface scale** (`settings.uiScale`,
|
||||||
`--list-font` etc. per `data-text` attribute, consumed via `h-(--row-h)` /
|
0.5–4 in 0.1 steps): one root font-size lever — `applyUiScale` sets
|
||||||
`text-(length:--list-font)` arbitrary utilities.
|
`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`)
|
### Skins (`settings.skin`: `modern | win7`)
|
||||||
|
|
||||||
@@ -267,10 +332,39 @@ 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
|
`<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.
|
precompiled against a literal `.win7`, so the name is not ours to pick.
|
||||||
Default `modern` = class absent, matching the delete-when-default rule.
|
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`.
|
||||||
|
|
||||||
**Cascade** (`ui/src/styles.css`, first lines): an explicit
|
**Unlike the other four axes, this one is launch-time only.** It selects a
|
||||||
`@layer theme, base, components, utilities, skin, skinfix;` statement precedes
|
whole component tree (see "UI layout") as well as CSS, and neither half can
|
||||||
both imports. 7.css is imported with `layer(skin)`, i.e. **above `utilities`**,
|
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 —
|
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
|
that inversion is what reskins ~1900 inline utility classes with no template
|
||||||
edits. Consequences: (1) any utility later added to a `button`/`input`/
|
edits. Consequences: (1) any utility later added to a `button`/`input`/
|
||||||
@@ -280,7 +374,7 @@ that want the Tailwind value back must sit **inside `@layer skin`**, not
|
|||||||
`utilities`, but from `skinfix` it lands back on 7.css and does nothing.
|
`utilities`, but from `skinfix` it lands back on 7.css and does nothing.
|
||||||
`skinfix` is for rules that state an explicit value (`.w7-btn`).
|
`skinfix` is for rules that state an explicit value (`.w7-btn`).
|
||||||
|
|
||||||
**Token bridge** (bottom of styles.css): `:root.win7` re-points the existing
|
**Token bridge** (`skins/7like/7like.css`): `:root.win7` re-points the existing
|
||||||
semantic tokens at Win7 values, so utilities Tailwind still controls come out
|
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
|
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:
|
`@theme inline` line. The Win7 surface ramp **inverts** the modern one:
|
||||||
@@ -291,14 +385,79 @@ right too. It adds NO new tokens on purpose — a new colour would need another
|
|||||||
`:root.win7[data-accent]` feeds it to `--w7-w-bg`, which is authentic (Win7
|
`: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
|
shipped "Window Color"). Dark Win7 is hand-authored (Microsoft never shipped
|
||||||
one) as ~20 `--w7-*` overrides, duplicated forced + system like every other
|
one) as ~20 `--w7-*` overrides, duplicated forced + system like every other
|
||||||
dark block. `--w7-font` is restated as a px `font` shorthand so `data-text`
|
dark block. `--w7-font` is restated as a rem `font` shorthand so the interface
|
||||||
still scales and 7.css's `pt` shorthand stops resetting weight/line-height.
|
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** are additive and inert under `modern`: `role="progressbar"`
|
**Markup hooks**: `role="progressbar"` (+aria) on the transfer bars,
|
||||||
(+aria) on the transfer bars, `has-scrollbar` on scroll containers (7.css only
|
`has-scrollbar` on scroll containers (7.css only styles `::-webkit-scrollbar`
|
||||||
styles `::-webkit-scrollbar` under that class), `w7-toolbar` on the three
|
under that class), `w7-toolbar` on every command row — the three window header
|
||||||
header rows (their buttons go flat — Explorer's command bar was flat, unlike
|
rows plus each file panel's session strip and path bar (their buttons go flat
|
||||||
dialog push buttons), `w7-btn` on the 10 dialog action buttons.
|
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 —
|
**Font**: Segoe UI is absent off Windows and not redistributable. Noto Sans —
|
||||||
the substitute 7.css itself names — is vendored (OFL-1.1, from
|
the substitute 7.css itself names — is vendored (OFL-1.1, from
|
||||||
@@ -309,16 +468,32 @@ so esbuild emits it hashed into `media/` and rewrites the `url()`.
|
|||||||
place either skin's frame is decided, spread by both the main window and
|
place either skin's frame is decided, spread by both the main window and
|
||||||
`openSecondaryWindow`. `win7` uses `titleBarStyle: "hidden"` on **every**
|
`openSecondaryWindow`. `win7` uses `titleBarStyle: "hidden"` on **every**
|
||||||
platform — that clears `Titled` from the Cocoa style mask, which is also the
|
platform — that clears `Titled` from the Cocoa style mask, which is also the
|
||||||
only way to remove the macOS traffic lights. The webview then draws the whole
|
only way to remove the macOS traffic lights. **Linux honours it too**: the GTK
|
||||||
frame via `ui/src/app/title-bar/title-bar.ts` (7.css's `.title-bar` >
|
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`),
|
`.title-bar-text` + `.title-bar-controls`, buttons keyed by `aria-label`),
|
||||||
mounted by all three window roots. Because `titleBarStyle` is fixed at
|
mounted by all three of that skin's window roots. Because `titleBarStyle` is
|
||||||
construction, **a skin change only reaches the frame on the next window open**;
|
fixed at construction, **a skin change only reaches the frame on the next app
|
||||||
the CSS half flips live, and the options row says so. `IS_WIN7_CHROME` in
|
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
|
`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
|
for layout) and forces `IS_MAC_CHROME` false, since there are no traffic
|
||||||
lights to reserve `pl-24` for.
|
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: eight handles overlaying the frame
|
||||||
|
drag it through `windowGetFrame`/`windowSetFrame` → `BrowserWindow.setFrame`.
|
||||||
|
Mounted by all three win7 roots, hidden while maximized by a CSS `:has()` rule
|
||||||
|
(no second `windowState` subscription), and every handle carries
|
||||||
|
`electrobun-webkit-app-region-no-drag` or the drag region would move the window
|
||||||
|
instead. 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` /
|
**Window controls**: new `windowMinimize` / `windowToggleMaximize` /
|
||||||
`windowClose` / `windowIsMaximized` requests, built by
|
`windowClose` / `windowIsMaximized` requests, built by
|
||||||
`windowControlHandlers(getWindow, onClose?)` in `rpc-handlers.ts` and served by
|
`windowControlHandlers(getWindow, onClose?)` in `rpc-handlers.ts` and served by
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ semver-shaped (`0.1.<n>`) for electrobun's config/updater; `BRANDING.buildLabel`
|
|||||||
|
|
||||||
## Release builds
|
## 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
|
Steps: gen-version → gen-icons → Angular build → `electrobun build
|
||||||
--env=stable` → copy/rename artifacts into
|
--env=stable` → copy/rename artifacts into
|
||||||
`release/<target>/miniSCP-Build<n>-<target>.*`.
|
`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
|
- **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
|
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
|
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
|
- **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
|
x64 CLI binary once (`electrobun-cli-darwin-x64.tar.gz` from the GitHub
|
||||||
release matching the installed version, cached at
|
release matching the installed version, cached at
|
||||||
@@ -56,7 +58,16 @@ Facts the script encodes:
|
|||||||
(self-extracting installer → `%LOCALAPPDATA%`) plus the script's own
|
(self-extracting installer → `%LOCALAPPDATA%`) plus the script's own
|
||||||
**portable zip** made from the assembled app folder under
|
**portable zip** made from the assembled app folder under
|
||||||
`build/stable-win-x64/` (layout to confirm on the first real Windows build —
|
`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
|
- The script filters artifacts by app name, so stale artifacts from older
|
||||||
brandings don't get collected.
|
brandings don't get collected.
|
||||||
|
|
||||||
@@ -107,8 +118,8 @@ Wired on top of the download host above. See the "Auto-updater" section in
|
|||||||
- **RPC** ([src/shared/rpc.ts](../src/shared/rpc.ts)): requests `checkForUpdate` / `downloadUpdate` /
|
- **RPC** ([src/shared/rpc.ts](../src/shared/rpc.ts)): requests `checkForUpdate` / `downloadUpdate` /
|
||||||
`applyUpdate` (handlers in [rpc-handlers.ts](../src/bun/rpc-handlers.ts) call the electrobun `Updater`);
|
`applyUpdate` (handlers in [rpc-handlers.ts](../src/bun/rpc-handlers.ts) call the electrobun `Updater`);
|
||||||
push message `updateStatus`.
|
push message `updateStatus`.
|
||||||
- **UI**: `UpdaterService` ([ui/src/app/updater.service.ts](../ui/src/app/updater.service.ts)) holds signals
|
- **UI**: `UpdaterService` ([ui/src/app/core/updater.service.ts](../ui/src/app/core/updater.service.ts)) holds signals
|
||||||
(`state`/`version`/`progress`); the tab-bar "update pill" ([tab-bar.ts](../ui/src/app/tab-bar/tab-bar.ts))
|
(`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 →
|
is one accent (`bg-primary`) control that announces → fills as a progress bar while downloading →
|
||||||
"Restart to apply". `state` is `available → downloading → ready` (ready derived from the
|
"Restart to apply". `state` is `available → downloading → ready` (ready derived from the
|
||||||
`downloadUpdate` response). App-menu "Check for Updates…" ([menu.ts](../src/bun/menu.ts)) routes a
|
`downloadUpdate` response). App-menu "Check for Updates…" ([menu.ts](../src/bun/menu.ts)) routes a
|
||||||
|
|||||||
+278
-12
@@ -31,10 +31,10 @@ touching the adjacent code.
|
|||||||
- **The win7 skin layer outranks Tailwind utilities.** 7.css is imported
|
- **The win7 skin layer outranks Tailwind utilities.** 7.css is imported
|
||||||
`layer(skin)`, ordered above `utilities`, so any utility you add to a
|
`layer(skin)`, ordered above `utilities`, so any utility you add to a
|
||||||
`button`/`input`/`select`/`textarea`/`table` is silently dead under that
|
`button`/`input`/`select`/`textarea`/`table` is silently dead under that
|
||||||
skin. If it matters, neutralize in the `@layer skin` block at the bottom of
|
skin. If it matters, neutralize in the `@layer skin` block in
|
||||||
styles.css — and note `revert-layer` only reaches `utilities` from **inside
|
`app/skins/7like/7like.css` — and note `revert-layer` only reaches
|
||||||
`skin`**; used from `skinfix` it falls back onto 7.css and is a no-op. This
|
`utilities` from **inside `skin`**; used from `skinfix` it falls back onto
|
||||||
bit once already.
|
7.css and is a no-op. This bit once already.
|
||||||
- `@import … layer(name)` works through `@tailwindcss/postcss` (it wraps the
|
- `@import … layer(name)` works through `@tailwindcss/postcss` (it wraps the
|
||||||
imported AST), but the **deep path is mandatory**: 7.css ships no `exports`
|
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
|
map and Tailwind's resolver reads only `style`/`mainFields`, so a bare
|
||||||
@@ -43,6 +43,159 @@ touching the adjacent code.
|
|||||||
`styles.css` live under `ui/src/` (esbuild emits them hashed into `media/`
|
`styles.css` live under `ui/src/` (esbuild emits them hashed into `media/`
|
||||||
and rewrites the url), NOT `ui/public/` — a public path fails the build with
|
and rewrites the url), NOT `ui/public/` — a public path fails the build with
|
||||||
"Could not resolve".
|
"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
|
## Electrobun
|
||||||
|
|
||||||
@@ -63,7 +216,99 @@ touching the adjacent code.
|
|||||||
- Tray: never call `setImage` after construction on macOS — the template
|
- Tray: never call `setImage` after construction on macOS — the template
|
||||||
treatment is lost and the icon renders black on dark menu bars.
|
treatment is lost and the icon renders black on dark menu bars.
|
||||||
- `ApplicationMenu` is macOS-only in our usage; accelerators on Windows are
|
- `ApplicationMenu` is macOS-only in our usage; accelerators on Windows are
|
||||||
single-character only. ContextMenu exists on macOS+Windows, not Linux.
|
single-character only.
|
||||||
|
- **`ContextMenu.showContextMenu` is a silent no-op on Linux** (electrobun's own
|
||||||
|
`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 handles 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). Three 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
|
- **Window-open flash prevention is three-layer** — remove any one and the
|
||||||
white/inverted blink returns: (1) windows are created `hidden: true` and
|
white/inverted blink returns: (1) windows are created `hidden: true` and
|
||||||
revealed by `showWhenReady` on the webview's first `dom-ready` (800 ms
|
revealed by `showWhenReady` on the webview's first `dom-ready` (800 ms
|
||||||
@@ -74,10 +319,30 @@ touching the adjacent code.
|
|||||||
The win7 skin adds a **fourth value to keep in sync**: `buildPreload` also
|
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
|
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.
|
for all four theme states. Miss it and every win7 window flashes white.
|
||||||
- **`titleBarStyle` is construction-time only.** Switching skin cannot
|
- **The skin is a LAUNCH-TIME choice. Never apply one to a running window.**
|
||||||
re-frame a live window; `chromeOptions` is read once per `new
|
This shipped broken once: `SettingsService.applySkin` toggled the `.win7`
|
||||||
BrowserWindow`. The options row tells the user to reopen. Don't "fix" this
|
class the moment the setting changed, which was harmless when there was one
|
||||||
by recreating the main window — that would drop every session and tab.
|
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
|
- Dev vs prod URL: `getMainViewUrl()` resolves the ng-serve dev server only
|
||||||
when `DEV_SERVER_URL` is set AND the updater channel is `dev` AND the
|
when `DEV_SERVER_URL` is set AND the updater channel is `dev` AND the
|
||||||
server answers a HEAD request; secondary windows must reuse the resolved
|
server answers a HEAD request; secondary windows must reuse the resolved
|
||||||
@@ -122,9 +387,10 @@ touching the adjacent code.
|
|||||||
single-quoted entries** (`'…c'est…'`). Use double quotes for any value
|
single-quoted entries** (`'…c'est…'`). Use double quotes for any value
|
||||||
containing an apostrophe. Plurals are `{ one, other }` maps (not ICU strings);
|
containing an apostrophe. Plurals are `{ one, other }` maps (not ICU strings);
|
||||||
zh/ja supply only `other`.
|
zh/ja supply only `other`.
|
||||||
- **Native menus can't use the Angular service** — `menu.ts`/`context-menu.ts`
|
- **Native menus can't use the Angular service** — `menu.ts` goes through backend
|
||||||
go through backend `i18n.ts`, and the app menu must be rebuilt when the locale
|
`i18n.ts`, and the app menu must be rebuilt when the locale changes (wired in
|
||||||
changes (wired in index.ts `settingsChanged`), or it keeps the old language.
|
index.ts `settingsChanged`), or it keeps the old language. The context menu no
|
||||||
|
longer has this problem: it is Angular now and reads `I18nService` directly.
|
||||||
- `eu`/`zh`/`ja` are AI drafts pending native review (see the i18n README).
|
- `eu`/`zh`/`ja` are AI drafts pending native review (see the i18n README).
|
||||||
- **Silent auto-refresh must snapshot sessionId+cwd and discard a mismatched
|
- **Silent auto-refresh must snapshot sessionId+cwd and discard a mismatched
|
||||||
result.** A normal `refresh()` is masked by `busy` (input blocked), but the
|
result.** A normal `refresh()` is masked by `busy` (input blocked), but the
|
||||||
|
|||||||
+117
-11
@@ -1,35 +1,64 @@
|
|||||||
# Platform notes — macOS / Windows behavior matrix
|
# Platform notes — macOS / Windows / Linux behavior matrix
|
||||||
|
|
||||||
macOS is the primary target; Windows 11+ x64 is a **beta** target (win-arm64
|
macOS is the primary target; Windows 11+ x64 and Linux x64 are **beta** targets
|
||||||
deferred; Linux untargeted — electrobun lacks menus/context menus there).
|
(win-arm64 and linux-arm64 deferred). Every platform seam carries a
|
||||||
Every platform seam carries a `PORTABILITY:` comment at the branch site.
|
`PORTABILITY:` comment at the branch site.
|
||||||
|
|
||||||
## Seam inventory (where platform branches live)
|
## Seam inventory (where platform branches live)
|
||||||
|
|
||||||
| Concern | File | mac | win | other |
|
| Concern | File | mac | win | linux |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Window chrome | `src/bun/windows.ts`, `index.ts` | hiddenInset + traffic-light offset, UI drag header | native titlebar, compact header | same as win |
|
| Window chrome (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` |
|
| External editor launch | `src/bun/edits.ts launchEditor` | `open [-a app]` | `cmd /c start "" [app] file` | app directly / `xdg-open` |
|
||||||
| Editor app picker | `rpc-handlers.ts pickEditorApp` | /Applications, `.app` | %ProgramFiles%, `.exe` | home, any |
|
| Editor app picker | `rpc-handlers.ts pickEditorApp` | /Applications, `.app` | %ProgramFiles%, `.exe` | home, any |
|
||||||
| Owner/group names | `fs/local.ts resolveId` | `id -nu` + `dscacheutil` | undefined → "—" | `id -nu` + `getent` |
|
| Owner/group names | `fs/local.ts resolveId` | `id -nu` + `dscacheutil` | undefined → "—" | `id -nu` + `getent` |
|
||||||
| Drive roots | `fs/local.ts listLocalDrives` | empty | `fsutil fsinfo drives` | empty |
|
| Drive roots | `fs/local.ts listLocalDrives` | empty | `fsutil fsinfo drives` | empty |
|
||||||
| chmod/Permissions | context-menu.ts, file-panel, file-actions | full | hidden for LOCAL panels (only flips read-only); SFTP unaffected | full |
|
| chmod/Permissions | context-menu.service.ts, file-panel, file-actions | full | hidden for LOCAL panels (only flips read-only); SFTP unaffected | full |
|
||||||
| Shell wording | `context-menu.ts` | Reveal in Finder / Move to Trash | Show in Explorer / Move to Recycle Bin | Show in File Manager |
|
| Shell wording | `ui context-menu.service.ts` | Reveal in Finder / Move to Trash | Show in Explorer / Move to Recycle Bin | Show in File Manager |
|
||||||
| Shortcut labels | `ui platform.ts MOD_LABEL/ALT_LABEL` | ⌘ / ⌥ | Ctrl+ / Alt+ | Ctrl+ / Alt+ |
|
| Shortcut labels | `ui platform.ts MOD_LABEL/ALT_LABEL` | ⌘ / ⌥ | Ctrl+ / Alt+ | Ctrl+ / Alt+ |
|
||||||
| Tray | `src/bun/tray.ts` | 22 pt template PNG | 16 px colored PNG | colored |
|
| Tray | `src/bun/tray.ts` | 22 pt template PNG | 16 px colored PNG | colored (needs AppIndicator, see below) |
|
||||||
| App menu | `src/bun/menu.ts` | full menu incl. About | none (early return) — About lives in the options popover | none |
|
| App menu | `src/bun/menu.ts` | full menu incl. About | none (early return) — 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 |
|
| connections.json protection | `store/connections.ts` | chmod 0600 | `icacls` current-user ACL (0600 is meaningless) | 0600 |
|
||||||
| App data dir | `store/paths.ts` | ~/Library/Application Support | %APPDATA% | $XDG_CONFIG_HOME/.config |
|
| App data dir | `store/paths.ts` | ~/Library/Application Support | %APPDATA% | $XDG_CONFIG_HOME/.config |
|
||||||
| ssh-agent | `fs/sftp.ts` | $SSH_AUTH_SOCK | `\\.\pipe\openssh-ssh-agent` fallback | $SSH_AUTH_SOCK |
|
| ssh-agent | `fs/sftp.ts` | $SSH_AUTH_SOCK | `\\.\pipe\openssh-ssh-agent` fallback | $SSH_AUTH_SOCK |
|
||||||
| Paths in UI | `ui format.ts joinPath/isRootPath` | "/" | separator detected from the dir string; drive-root regex | "/" |
|
| Paths in UI | `ui format.ts joinPath/isRootPath` | "/" | separator detected from the dir string; drive-root regex | "/" |
|
||||||
|
|
||||||
|
The **context menu is HTML on every platform** (`ui/src/app/skins/*/context-menu.ts` +
|
||||||
|
`context-menu.service.ts`), not a native electrobun menu — see the Linux section
|
||||||
|
below for why. Only the three shell actions it can't do itself (reveal, open with
|
||||||
|
the default app, copy path) cross to bun, via the `shellItemAction` RPC.
|
||||||
|
|
||||||
Remote (SFTP) paths are ALWAYS POSIX regardless of host or server OS
|
Remote (SFTP) paths are ALWAYS POSIX regardless of host or server OS
|
||||||
(`path.posix` throughout `fs/sftp.ts`).
|
(`path.posix` throughout `fs/sftp.ts`).
|
||||||
|
|
||||||
|
### 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**: eight handles in the webview drag the native frame over RPC
|
||||||
|
(`ui/src/app/skins/7like/window-resize/`).
|
||||||
|
|
||||||
|
That is a **stopgap** — each drag tick is an RPC hop plus an FFI call, where a
|
||||||
|
real WM grab is compositor-side. `scripts/check-electrobun-resize.ts` watches the
|
||||||
|
installed electrobun for the missing primitives and prints the removal checklist
|
||||||
|
when they appear; it runs from `postinstall`, so an electrobun bump surfaces it
|
||||||
|
without anyone remembering to look. The modern skin keeps the native frame and
|
||||||
|
needs none of this.
|
||||||
|
|
||||||
## Memory footprint (expected, not a bug)
|
## Memory footprint (expected, not a bug)
|
||||||
|
|
||||||
Investigated 2026-07 — packaged build. Roughly **~200 MB macOS / ~300 MB
|
Investigated 2026-07 — packaged build. Roughly **~200 MB macOS / ~300 MB
|
||||||
Windows**, which is normal for a Bun + system-WebView app (30 MB is native /
|
Windows** (Linux sits near macOS: WebKitGTK, like WKWebView, is lighter than
|
||||||
|
Chromium), which is normal for a Bun + system-WebView app (30 MB is native /
|
||||||
no-web-engine territory; Tauri-class apps run ~80–200 MB, Electron 150–400 MB).
|
no-web-engine territory; Tauri-class apps run ~80–200 MB, Electron 150–400 MB).
|
||||||
Not reducible to tens of MB without dropping the web UI.
|
Not reducible to tens of MB without dropping the web UI.
|
||||||
|
|
||||||
@@ -49,6 +78,83 @@ Not reducible to tens of MB without dropping the web UI.
|
|||||||
each), and each open tab keeps both panels' full `entries` arrays; terminal
|
each), and each open tab keeps both panels' full `entries` arrays; terminal
|
||||||
transfers are retained until cleared. None affect normal use.
|
transfers are retained until cleared. None affect normal use.
|
||||||
|
|
||||||
|
## Linux runtime dependencies (REQUIRED — the app will not start without them)
|
||||||
|
|
||||||
|
electrobun's `libNativeWrapper.so` links AppIndicator *hard* for the tray, so a
|
||||||
|
missing library is a hard boot failure (`ERR_DLOPEN_FAILED` out of `bun:ffi`
|
||||||
|
`dlopen`), not a degraded tray. The transitive deps (`libdbusmenu-glib`,
|
||||||
|
`libdbusmenu-gtk3`, `libayatana-indicator3`, `libayatana-ido3`) come in as
|
||||||
|
package dependencies:
|
||||||
|
|
||||||
|
| Distro | Install |
|
||||||
|
|---|---|
|
||||||
|
| Arch / CachyOS | `sudo pacman -S libayatana-appindicator` |
|
||||||
|
| Debian / Ubuntu | `sudo apt install libayatana-appindicator3-1` |
|
||||||
|
| Fedora | `sudo dnf install libayatana-appindicator-gtk3` |
|
||||||
|
|
||||||
|
GTK/WebKit itself comes from the distro too — electrobun uses the system webview
|
||||||
|
(`bundleCEF: false`), so `webkit2gtk` must be present.
|
||||||
|
|
||||||
|
Diagnosing a boot failure:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ldd build/dev-linux-x64/*/bin/libNativeWrapper.so | grep 'not found'
|
||||||
|
```
|
||||||
|
|
||||||
|
`libasar.so => not found` in that output is **expected and harmless** — it ships
|
||||||
|
in the same `bin/` folder and resolves at load time; only an unresolved
|
||||||
|
`libayatana-appindicator3.so.1` is fatal.
|
||||||
|
|
||||||
|
**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)
|
## Windows known issues (upstream electrobun, documented not fixed)
|
||||||
|
|
||||||
- **HiDPI blur** — WebView2 renders at 1x on scaled displays
|
- **HiDPI blur** — WebView2 renders at 1x on scaled displays
|
||||||
|
|||||||
+2
-2
@@ -11,8 +11,8 @@
|
|||||||
"dev:ui": "bun run --cwd ui start",
|
"dev:ui": "bun run --cwd ui start",
|
||||||
"build:ui": "bun run --cwd ui build",
|
"build:ui": "bun run --cwd ui build",
|
||||||
"build": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun build",
|
"build": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun build",
|
||||||
"start": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun dev",
|
"start": "bun scripts/gen-version.ts && bun run build:ui && bun scripts/start.ts",
|
||||||
"postinstall": "bun scripts/gen-version.ts",
|
"postinstall": "bun scripts/gen-version.ts && bun scripts/check-electrobun-resize.ts",
|
||||||
"release": "bun scripts/release.ts"
|
"release": "bun scripts/release.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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);
|
||||||
|
}
|
||||||
+3
-1
@@ -1,5 +1,7 @@
|
|||||||
// Dev orchestrator: starts ng serve (UI hot reload), waits for it, then runs
|
// Dev orchestrator: starts ng serve (UI hot reload), waits for it, then runs
|
||||||
// `electrobun dev --watch` (relaunches the app when bun-side code changes).
|
// `electrobun dev --watch` (relaunches the app when bun-side code changes).
|
||||||
|
import { launchEnv } from "./linux-gpu";
|
||||||
|
|
||||||
const DEV_SERVER_URL = "http://localhost:4200";
|
const DEV_SERVER_URL = "http://localhost:4200";
|
||||||
|
|
||||||
const ui = Bun.spawn(["bun", "run", "--cwd", "ui", "start"], {
|
const ui = Bun.spawn(["bun", "run", "--cwd", "ui", "start"], {
|
||||||
@@ -27,7 +29,7 @@ console.log("Angular dev server is up, launching Electrobun ...");
|
|||||||
const app = Bun.spawn(["bunx", "electrobun", "dev", "--watch"], {
|
const app = Bun.spawn(["bunx", "electrobun", "dev", "--watch"], {
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
stderr: "inherit",
|
stderr: "inherit",
|
||||||
env: { ...process.env, DEV_SERVER_URL },
|
env: launchEnv({ DEV_SERVER_URL }),
|
||||||
});
|
});
|
||||||
|
|
||||||
function shutdown() {
|
function shutdown() {
|
||||||
|
|||||||
@@ -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");
|
||||||
+47
-11
@@ -1,21 +1,22 @@
|
|||||||
// Build one release artifact for one target, on the matching host:
|
// Build one release artifact for one target, on the matching host:
|
||||||
// bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64]
|
// bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64|linux-x64]
|
||||||
// Default target = the host. electrobun cannot cross-compile OSes (its
|
// Default target = the host. electrobun cannot cross-compile OSes (its
|
||||||
// packaging needs host tools: codesign/hdiutil on mac, rcedit/PowerShell on
|
// 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
|
// 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
|
// 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.
|
// Rosetta 2 — it sees an x64 host, pulls the x64 core, and packages mac-x64.
|
||||||
// Artifacts land in release/<target>/miniSCP-Build<N>-<target>.<ext>.
|
// Artifacts land in release/<target>/miniSCP-Build<N>-<target>.<ext>.
|
||||||
import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
import { cpSync, existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
const ROOT = path.join(import.meta.dir, "..");
|
const ROOT = path.join(import.meta.dir, "..");
|
||||||
const TARGETS = ["mac-arm64", "mac-x64", "win-x64"] as const;
|
const TARGETS = ["mac-arm64", "mac-x64", "win-x64", "linux-x64"] as const;
|
||||||
type Target = (typeof TARGETS)[number];
|
type Target = (typeof TARGETS)[number];
|
||||||
|
|
||||||
function hostTarget(): Target {
|
function hostTarget(): Target {
|
||||||
if (process.platform === "win32") return "win-x64";
|
if (process.platform === "win32") return "win-x64";
|
||||||
|
if (process.platform === "linux") return "linux-x64";
|
||||||
return process.arch === "arm64" ? "mac-arm64" : "mac-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") {
|
if (requested === "win-x64" && process.platform !== "win32") {
|
||||||
fail("win-x64 must be built on a Windows machine/runner — electrobun cannot cross-compile.");
|
fail("win-x64 must be built on a Windows machine/runner — electrobun cannot cross-compile.");
|
||||||
}
|
}
|
||||||
|
if (requested === "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") {
|
if (requested.startsWith("mac") && process.platform !== "darwin") {
|
||||||
fail(`${requested} must be built on macOS.`);
|
fail(`${requested} must be built on macOS.`);
|
||||||
}
|
}
|
||||||
@@ -89,7 +93,12 @@ if (viaRosetta) {
|
|||||||
|
|
||||||
// ---- collect artifacts ----
|
// ---- 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 prefix = `stable-${osName}-${archName}-`;
|
||||||
const artifactsDir = path.join(ROOT, "artifacts");
|
const artifactsDir = path.join(ROOT, "artifacts");
|
||||||
const outDir = path.join(ROOT, "release", requested);
|
const outDir = path.join(ROOT, "release", requested);
|
||||||
@@ -109,15 +118,42 @@ for (const file of produced) {
|
|||||||
out.push(name);
|
out.push(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Windows: also produce a portable zip from the assembled app folder (the
|
// Windows / Linux: package the assembled app folder directly. On Windows that
|
||||||
// Setup.exe is an installer; portable = unzip anywhere and run). Best-effort:
|
// complements the Setup.exe installer (portable = unzip anywhere and run); on
|
||||||
// the folder layout is confirmed by the first real Windows build.
|
// Linux it is the ONLY artifact, since electrobun emits just the .tar.zst
|
||||||
if (requested === "win-x64") {
|
// 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);
|
const appDir = path.join(ROOT, "build", `stable-${osName}-${archName}`, BRANDING.appName);
|
||||||
if (existsSync(appDir)) {
|
if (existsSync(appDir)) {
|
||||||
const portable = path.join(outDir, `${BRANDING.appName}-${buildName}-win-x64-portable.zip`);
|
if (requested === "win-x64") {
|
||||||
run(["powershell", "-Command", `Compress-Archive -Force -Path '${appDir}\\*' -DestinationPath '${portable}'`]);
|
const portable = path.join(outDir, `${BRANDING.appName}-${buildName}-win-x64-portable.zip`);
|
||||||
out.push(path.basename(portable));
|
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 {
|
} else {
|
||||||
console.warn(`[release] WARN: portable skipped — app folder not found at ${appDir}`);
|
console.warn(`[release] WARN: portable skipped — app folder not found at ${appDir}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// 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.
|
||||||
|
import {
|
||||||
|
MIN_HEIGHT,
|
||||||
|
MIN_WIDTH,
|
||||||
|
RESIZE_EDGES,
|
||||||
|
type Frame,
|
||||||
|
type ResizeEdge,
|
||||||
|
resizeFrame,
|
||||||
|
} from "../ui/src/app/skins/7like/window-resize/resize-math";
|
||||||
|
|
||||||
|
const START: Frame = { x: 100, y: 200, width: 1000, height: 800 };
|
||||||
|
|
||||||
|
interface Case {
|
||||||
|
name: string;
|
||||||
|
edge: ResizeEdge;
|
||||||
|
dx: number;
|
||||||
|
dy: number;
|
||||||
|
want: Frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every expectation is written out in full rather than computed, so a bug in
|
||||||
|
// resizeFrame cannot be mirrored into the expectation by sharing its algebra.
|
||||||
|
const CASES: Case[] = [
|
||||||
|
// --- edges: each moves only what it owns ---
|
||||||
|
{ name: "east grows width only", edge: "e", dx: 50, dy: 999, want: { x: 100, y: 200, width: 1050, height: 800 } },
|
||||||
|
{ name: "south grows height only", edge: "s", dx: 999, dy: 40, want: { x: 100, y: 200, width: 1000, height: 840 } },
|
||||||
|
{ name: "west moves x AND width", edge: "w", dx: -30, dy: 0, want: { x: 70, y: 200, width: 1030, height: 800 } },
|
||||||
|
{ name: "north moves y AND height", edge: "n", dx: 0, dy: -25, want: { x: 100, y: 175, width: 1000, height: 825 } },
|
||||||
|
// --- corners: two axes at once ---
|
||||||
|
{ name: "south-east", edge: "se", dx: 10, dy: 20, want: { x: 100, y: 200, width: 1010, height: 820 } },
|
||||||
|
{ name: "north-west", edge: "nw", dx: -10, dy: -20, want: { x: 90, y: 180, width: 1010, height: 820 } },
|
||||||
|
{ name: "north-east", edge: "ne", dx: 10, dy: -20, want: { x: 100, y: 180, width: 1010, height: 820 } },
|
||||||
|
{ name: "south-west", edge: "sw", dx: -10, dy: 20, want: { x: 90, y: 200, width: 1010, height: 820 } },
|
||||||
|
// --- the anchored edge must not drift once clamped ---
|
||||||
|
// west dragged far east: width parks at MIN, and x parks where the EAST edge
|
||||||
|
// (100 + 1000 = 1100) minus MIN puts it. If x kept tracking dx, the window
|
||||||
|
// would slide away while appearing frozen.
|
||||||
|
{
|
||||||
|
name: "west clamps, east edge holds",
|
||||||
|
edge: "w",
|
||||||
|
dx: 5000,
|
||||||
|
dy: 0,
|
||||||
|
want: { x: 1100 - MIN_WIDTH, y: 200, width: MIN_WIDTH, height: 800 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "north clamps, south edge holds",
|
||||||
|
edge: "n",
|
||||||
|
dx: 0,
|
||||||
|
dy: 5000,
|
||||||
|
want: { x: 100, y: 1000 - MIN_HEIGHT, width: 1000, height: MIN_HEIGHT },
|
||||||
|
},
|
||||||
|
// east/south clamp the size but never touch the origin
|
||||||
|
{ name: "east clamps, origin holds", edge: "e", dx: -5000, dy: 0, want: { x: 100, y: 200, width: MIN_WIDTH, height: 800 } },
|
||||||
|
{ name: "south clamps, origin holds", edge: "s", dx: 0, dy: -5000, want: { x: 100, y: 200, width: 1000, height: MIN_HEIGHT } },
|
||||||
|
// a corner clamping on BOTH axes at once
|
||||||
|
{
|
||||||
|
name: "north-west clamps both",
|
||||||
|
edge: "nw",
|
||||||
|
dx: 5000,
|
||||||
|
dy: 5000,
|
||||||
|
want: { x: 1100 - MIN_WIDTH, y: 1000 - MIN_HEIGHT, width: MIN_WIDTH, height: MIN_HEIGHT },
|
||||||
|
},
|
||||||
|
// a zero drag must be a no-op, or a click on a handle would nudge the window
|
||||||
|
{ name: "zero drag is identity", edge: "se", dx: 0, dy: 0, want: START },
|
||||||
|
];
|
||||||
|
|
||||||
|
const fmt = (f: Frame) => `${f.x},${f.y} ${f.width}x${f.height}`;
|
||||||
|
|
||||||
|
console.log("[resize-math] frame arithmetic:\n");
|
||||||
|
console.log(" case edge got verdict");
|
||||||
|
let failed = 0;
|
||||||
|
for (const c of CASES) {
|
||||||
|
const got = resizeFrame(c.edge, START, c.dx, c.dy);
|
||||||
|
const ok = fmt(got) === fmt(c.want);
|
||||||
|
if (!ok) failed++;
|
||||||
|
console.log(
|
||||||
|
` ${c.name.padEnd(32)} ${c.edge.padEnd(5)} ${fmt(got).padEnd(22)} ${ok ? "ok" : `FAIL: want ${fmt(c.want)}`}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Structural check: every edge must be exercised above, so adding a ninth
|
||||||
|
// handle without a case cannot slip through.
|
||||||
|
const covered = new Set(CASES.map((c) => c.edge));
|
||||||
|
const missing = RESIZE_EDGES.filter((e) => !covered.has(e));
|
||||||
|
if (missing.length) {
|
||||||
|
console.log(`\n[resize-math] no case covers: ${missing.join(", ")}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The clamp must never produce a frame smaller than the minimum, whatever the
|
||||||
|
// drag — a cheap sweep over every edge and a range of deltas.
|
||||||
|
for (const edge of RESIZE_EDGES) {
|
||||||
|
for (const d of [-9999, -1000, -1, 0, 1, 1000, 9999]) {
|
||||||
|
const got = resizeFrame(edge, START, d, d);
|
||||||
|
if (got.width < MIN_WIDTH || got.height < MIN_HEIGHT) {
|
||||||
|
console.log(`\n[resize-math] ${edge} at delta ${d} produced ${fmt(got)} — under the minimum`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
console.log(`\n[resize-math] ${failed} failure(s)`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
console.log("\n[resize-math] clean");
|
||||||
+106
@@ -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);
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import { ContextMenu, Utils, type ApplicationMenuItemConfig } from "electrobun/bun";
|
|
||||||
import type { ColumnKey } from "../shared/types";
|
|
||||||
import { t } from "./i18n";
|
|
||||||
|
|
||||||
/** Optional list columns, in display order (Name is fixed). */
|
|
||||||
const COLUMN_KEYS: ColumnKey[] = ["size", "mtime", "kind", "rights", "owner", "group"];
|
|
||||||
|
|
||||||
// PORTABILITY: shell-integration wording per OS (resolved at show() for locale)
|
|
||||||
const IS_WIN = process.platform === "win32";
|
|
||||||
const revealLabel = (): string =>
|
|
||||||
t(
|
|
||||||
process.platform === "darwin"
|
|
||||||
? "ctx.revealFinder"
|
|
||||||
: IS_WIN
|
|
||||||
? "ctx.revealExplorer"
|
|
||||||
: "ctx.revealFileManager",
|
|
||||||
);
|
|
||||||
const trashLabel = (): string => t(IS_WIN ? "ctx.moveToRecycleBin" : "ctx.moveToTrash");
|
|
||||||
|
|
||||||
export interface RowMenuContext {
|
|
||||||
path: string;
|
|
||||||
name: string;
|
|
||||||
type: "file" | "dir" | "symlink";
|
|
||||||
isLocal: boolean;
|
|
||||||
hasEntry: boolean; // false = right-click on empty panel space
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Native right-click menus for the file panels. Local-only items run directly
|
|
||||||
* here via Utils (reveal, open with default app, copy path); everything else
|
|
||||||
* forwards to the webview through the menuAction channel — the UI kept its own
|
|
||||||
* context (which panel/row was clicked) when it requested the menu.
|
|
||||||
*/
|
|
||||||
export class PanelContextMenu {
|
|
||||||
private context: RowMenuContext | null = null;
|
|
||||||
/** Forwards actions the UI must handle (ctx-open, ctx-delete, ...). */
|
|
||||||
onAction: (action: string) => void = () => {};
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
ContextMenu.on("context-menu-clicked", (event) => {
|
|
||||||
const action = (event as { data?: { action?: string } }).data?.action;
|
|
||||||
if (!action) return;
|
|
||||||
// column toggles carry no row context
|
|
||||||
if (action.startsWith("col:")) {
|
|
||||||
this.onAction(action);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const ctx = this.context;
|
|
||||||
if (!ctx) return;
|
|
||||||
switch (action) {
|
|
||||||
case "ctx-reveal":
|
|
||||||
Utils.showItemInFolder(ctx.path);
|
|
||||||
break;
|
|
||||||
case "ctx-open-default":
|
|
||||||
Utils.openPath(ctx.path);
|
|
||||||
break;
|
|
||||||
case "ctx-copy-path":
|
|
||||||
Utils.clipboardWriteText(ctx.path);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
if (action.startsWith("ctx-")) this.onAction(action);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
show(context: RowMenuContext): void {
|
|
||||||
this.context = context;
|
|
||||||
ContextMenu.showContextMenu(this.build(context));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Column picker for the list headers: checkable toggles per column. */
|
|
||||||
showColumns(visible: ColumnKey[]): void {
|
|
||||||
ContextMenu.showContextMenu(
|
|
||||||
COLUMN_KEYS.map((key) => ({
|
|
||||||
label: t(`columns.${key}`),
|
|
||||||
action: `col:${key}`,
|
|
||||||
checked: visible.includes(key),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private build(ctx: RowMenuContext): ApplicationMenuItemConfig[] {
|
|
||||||
const items: ApplicationMenuItemConfig[] = [];
|
|
||||||
|
|
||||||
if (!ctx.hasEntry || ctx.name === "..") {
|
|
||||||
// empty space (or the parent row): folder-level actions
|
|
||||||
items.push({ label: t("ctx.newFolder"), action: "ctx-new-folder" });
|
|
||||||
items.push({ label: t("ctx.newFile"), action: "ctx-new-file" });
|
|
||||||
items.push({ type: "separator" });
|
|
||||||
items.push({ label: t("ctx.refresh"), action: "ctx-refresh" });
|
|
||||||
if (ctx.isLocal) {
|
|
||||||
items.push({ type: "separator" });
|
|
||||||
items.push({ label: revealLabel(), action: "ctx-reveal" });
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
items.push({ label: t("ctx.copyToOther"), action: "ctx-copy" });
|
|
||||||
if (ctx.type === "dir") {
|
|
||||||
items.push({ label: t("ctx.open"), action: "ctx-open" });
|
|
||||||
} else {
|
|
||||||
if (ctx.isLocal) {
|
|
||||||
items.push({ label: t("ctx.open"), action: "ctx-open-default", tooltip: t("ctx.openDefault") });
|
|
||||||
}
|
|
||||||
items.push({ label: t("ctx.edit"), action: "ctx-edit", accelerator: "e" });
|
|
||||||
}
|
|
||||||
items.push({ label: t("ctx.rename"), action: "ctx-rename" });
|
|
||||||
// chmod on Windows local files only flips the read-only bit — hide the lie
|
|
||||||
if (!(ctx.isLocal && IS_WIN)) {
|
|
||||||
items.push({ label: t("ctx.permissions"), action: "ctx-chmod" });
|
|
||||||
}
|
|
||||||
items.push({ type: "separator" });
|
|
||||||
items.push({ label: t("ctx.copyPath"), action: "ctx-copy-path" });
|
|
||||||
if (ctx.isLocal) {
|
|
||||||
items.push({ label: revealLabel(), action: "ctx-reveal" });
|
|
||||||
}
|
|
||||||
items.push({ type: "separator" });
|
|
||||||
if (ctx.isLocal) {
|
|
||||||
items.push({
|
|
||||||
label: trashLabel(),
|
|
||||||
action: "ctx-trash",
|
|
||||||
...(process.platform === "darwin" ? { tooltip: t("ctx.trashTooltip") } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
items.push({ label: t("ctx.delete"), action: "ctx-delete" });
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+15
-7
@@ -2,7 +2,6 @@ import Electrobun, { ApplicationMenu, BrowserWindow, Updater, Utils } from "elec
|
|||||||
import { BRANDING } from "../shared/branding";
|
import { BRANDING } from "../shared/branding";
|
||||||
import type { EditorOpenContext } from "../shared/rpc";
|
import type { EditorOpenContext } from "../shared/rpc";
|
||||||
import { effectiveConcurrency, type AppSettings } from "../shared/types";
|
import { effectiveConcurrency, type AppSettings } from "../shared/types";
|
||||||
import { PanelContextMenu } from "./context-menu";
|
|
||||||
import { EditManager } from "./edits";
|
import { EditManager } from "./edits";
|
||||||
import { EditorWindows } from "./editor-windows";
|
import { EditorWindows } from "./editor-windows";
|
||||||
import { SessionRegistry } from "./fs/registry";
|
import { SessionRegistry } from "./fs/registry";
|
||||||
@@ -16,7 +15,13 @@ import { migrateLegacyDataDir } from "./store/paths";
|
|||||||
import { SettingsStore } from "./store/settings";
|
import { SettingsStore } from "./store/settings";
|
||||||
import { AppTray } from "./tray";
|
import { AppTray } from "./tray";
|
||||||
import { TransferEngine } from "./transfers/engine";
|
import { TransferEngine } from "./transfers/engine";
|
||||||
import { buildPreload, chromeOptions, showWhenReady, trackMaximizeState } from "./windows";
|
import {
|
||||||
|
buildPreload,
|
||||||
|
chromeOptions,
|
||||||
|
initSessionSkin,
|
||||||
|
showWhenReady,
|
||||||
|
trackMaximizeState,
|
||||||
|
} from "./windows";
|
||||||
|
|
||||||
const PROD_URL = "views://app/index.html";
|
const PROD_URL = "views://app/index.html";
|
||||||
|
|
||||||
@@ -46,7 +51,6 @@ const engine = new TransferEngine(registry);
|
|||||||
const store = new ConnectionStore();
|
const store = new ConnectionStore();
|
||||||
const settings = new SettingsStore();
|
const settings = new SettingsStore();
|
||||||
const edits = new EditManager(registry, engine, settings);
|
const edits = new EditManager(registry, engine, settings);
|
||||||
const contextMenu = new PanelContextMenu();
|
|
||||||
const watcher = new LocalDirWatcher();
|
const watcher = new LocalDirWatcher();
|
||||||
const events = {
|
const events = {
|
||||||
sessionClosed: (_info: { sessionId: string; reason: string }) => {},
|
sessionClosed: (_info: { sessionId: string; reason: string }) => {},
|
||||||
@@ -65,7 +69,6 @@ const rpc = createAppRPC({
|
|||||||
store,
|
store,
|
||||||
settings,
|
settings,
|
||||||
edits,
|
edits,
|
||||||
contextMenu,
|
|
||||||
watcher,
|
watcher,
|
||||||
mainWindow: () => mainWin,
|
mainWindow: () => mainWin,
|
||||||
events,
|
events,
|
||||||
@@ -79,6 +82,11 @@ const url = await getMainViewUrl();
|
|||||||
// fresh via the saveSettings flow below.
|
// fresh via the saveSettings flow below.
|
||||||
let currentSettings = await settings.load();
|
let currentSettings = await settings.load();
|
||||||
setBackendLocale(currentSettings.locale); // native menus/errors follow the UI language
|
setBackendLocale(currentSettings.locale); // native menus/errors follow the UI language
|
||||||
|
// Freeze the skin for this process BEFORE any window is constructed. It picks
|
||||||
|
// a component tree and a native frame, neither of which can change on a live
|
||||||
|
// window, so the persisted value may only take effect at the next start — see
|
||||||
|
// initSessionSkin.
|
||||||
|
initSessionSkin(currentSettings.skin);
|
||||||
|
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
title: BRANDING.appName,
|
title: BRANDING.appName,
|
||||||
@@ -89,8 +97,9 @@ const win = new BrowserWindow({
|
|||||||
x: 120,
|
x: 120,
|
||||||
y: 120,
|
y: 120,
|
||||||
},
|
},
|
||||||
// Frame decision (per skin) lives in chromeOptions — see windows.ts.
|
// Frame decision lives in chromeOptions — see windows.ts. Constructor-only,
|
||||||
...chromeOptions(currentSettings.skin),
|
// and read from the pinned session skin, not from currentSettings.
|
||||||
|
...chromeOptions(),
|
||||||
// lets the UI pick the fused layout + theme before first paint
|
// lets the UI pick the fused layout + theme before first paint
|
||||||
preload: buildPreload("main", currentSettings),
|
preload: buildPreload("main", currentSettings),
|
||||||
rpc,
|
rpc,
|
||||||
@@ -158,7 +167,6 @@ win.on("close", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
new AppTray(win);
|
new AppTray(win);
|
||||||
contextMenu.onAction = (action) => rpc.send.menuAction({ action });
|
|
||||||
|
|
||||||
// notify on finished transfers while the window is in the background
|
// notify on finished transfers while the window is in the background
|
||||||
let windowFocused = true;
|
let windowFocused = true;
|
||||||
|
|||||||
+30
-12
@@ -1,7 +1,6 @@
|
|||||||
import { BrowserView, type BrowserWindow, Updater, Utils } from "electrobun/bun";
|
import { BrowserView, type BrowserWindow, Updater, Utils } from "electrobun/bun";
|
||||||
import type { AppRPC, EditorOpenContext } from "../shared/rpc";
|
import type { AppRPC, EditorOpenContext } from "../shared/rpc";
|
||||||
import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types";
|
import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types";
|
||||||
import type { PanelContextMenu } from "./context-menu";
|
|
||||||
import { readFileText, writeFileText, type EditManager } from "./edits";
|
import { readFileText, writeFileText, type EditManager } from "./edits";
|
||||||
import { listLocalDrives } from "./fs/local";
|
import { listLocalDrives } from "./fs/local";
|
||||||
import type { SessionRegistry } from "./fs/registry";
|
import type { SessionRegistry } from "./fs/registry";
|
||||||
@@ -24,7 +23,6 @@ export interface AppDeps {
|
|||||||
store: ConnectionStore;
|
store: ConnectionStore;
|
||||||
settings: SettingsStore;
|
settings: SettingsStore;
|
||||||
edits: EditManager;
|
edits: EditManager;
|
||||||
contextMenu: PanelContextMenu;
|
|
||||||
watcher: LocalDirWatcher;
|
watcher: LocalDirWatcher;
|
||||||
/**
|
/**
|
||||||
* The main BrowserWindow, for the win7 skin's custom title-bar controls.
|
* The main BrowserWindow, for the win7 skin's custom title-bar controls.
|
||||||
@@ -95,6 +93,31 @@ export function windowControlHandlers(
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
windowIsMaximized: () => wrap(() => ({ maximized: getWindow()?.isMaximized() ?? false })),
|
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 {};
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +198,6 @@ export function createAppRPC({
|
|||||||
store,
|
store,
|
||||||
settings,
|
settings,
|
||||||
edits,
|
edits,
|
||||||
contextMenu,
|
|
||||||
watcher,
|
watcher,
|
||||||
mainWindow,
|
mainWindow,
|
||||||
events,
|
events,
|
||||||
@@ -339,10 +361,12 @@ export function createAppRPC({
|
|||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// ---- context menu + local shell integration ----
|
// ---- local shell integration (menu itself is HTML, see ui/) ----
|
||||||
showRowMenu: (params) =>
|
shellItemAction: ({ action, path }) =>
|
||||||
wrap(async () => {
|
wrap(async () => {
|
||||||
contextMenu.show(params);
|
if (action === "reveal") Utils.showItemInFolder(path);
|
||||||
|
else if (action === "open") Utils.openPath(path);
|
||||||
|
else Utils.clipboardWriteText(path);
|
||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -352,12 +376,6 @@ export function createAppRPC({
|
|||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
showColumnsMenu: ({ visible }) =>
|
|
||||||
wrap(async () => {
|
|
||||||
contextMenu.showColumns(visible);
|
|
||||||
return {};
|
|
||||||
}),
|
|
||||||
|
|
||||||
// ---- editor windows ----
|
// ---- editor windows ----
|
||||||
openEditor: (ctx) =>
|
openEditor: (ctx) =>
|
||||||
wrap(async () => {
|
wrap(async () => {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { DEFAULT_SETTINGS, type AppSettings } from "../../shared/types";
|
import { clampUiScale, DEFAULT_SETTINGS, type AppSettings } from "../../shared/types";
|
||||||
import { appDataDir } from "./paths";
|
import { appDataDir } from "./paths";
|
||||||
|
|
||||||
|
/** The retired 3-tier axis, mapped onto the scale that replaced it. */
|
||||||
|
const LEGACY_TEXT_SIZE: Record<string, number> = { compact: 0.9, default: 1, large: 1.2 };
|
||||||
|
|
||||||
/** App settings persisted bun-side (WKWebView localStorage on views:// is unreliable). */
|
/** App settings persisted bun-side (WKWebView localStorage on views:// is unreliable). */
|
||||||
export class SettingsStore {
|
export class SettingsStore {
|
||||||
private readonly file = path.join(appDataDir(), "settings.json");
|
private readonly file = path.join(appDataDir(), "settings.json");
|
||||||
@@ -10,7 +13,18 @@ export class SettingsStore {
|
|||||||
async load(): Promise<AppSettings> {
|
async load(): Promise<AppSettings> {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(await fs.readFile(this.file, "utf8"));
|
const data = JSON.parse(await fs.readFile(this.file, "utf8"));
|
||||||
return { ...DEFAULT_SETTINGS, ...data };
|
const settings: AppSettings = { ...DEFAULT_SETTINGS, ...data };
|
||||||
|
// migrate the retired textSize tier into the scale that replaced it —
|
||||||
|
// but never over an explicit uiScale (a file carrying both has already
|
||||||
|
// been migrated once and then adjusted)
|
||||||
|
if (data.uiScale === undefined && typeof data.textSize === "string") {
|
||||||
|
settings.uiScale = LEGACY_TEXT_SIZE[data.textSize] ?? 1;
|
||||||
|
}
|
||||||
|
// clamp ALWAYS, not just on migration: buildPreload writes this value
|
||||||
|
// into every window's root font-size, so a hand-edited settings.json
|
||||||
|
// (uiScale: 100, "2", NaN) must not be able to render the UI unusable
|
||||||
|
settings.uiScale = clampUiScale(Number(settings.uiScale));
|
||||||
|
return settings;
|
||||||
} catch {
|
} catch {
|
||||||
return { ...DEFAULT_SETTINGS };
|
return { ...DEFAULT_SETTINGS };
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-25
@@ -1,7 +1,27 @@
|
|||||||
import { BrowserWindow } from "electrobun/bun";
|
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
|
* The preload globals every window gets. Single composition point: the UI
|
||||||
@@ -19,15 +39,19 @@ export function buildPreload(
|
|||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
ctx?: unknown,
|
ctx?: unknown,
|
||||||
): string {
|
): string {
|
||||||
|
// sessionSkin, NOT settings.skin — a pending skin change must not leak into
|
||||||
|
// a window opened before the restart. See initSessionSkin.
|
||||||
let script =
|
let script =
|
||||||
`window.__APP_PLATFORM=${JSON.stringify(process.platform)};` +
|
`window.__APP_PLATFORM=${JSON.stringify(process.platform)};` +
|
||||||
`window.__APP_MODE=${JSON.stringify(mode)};` +
|
`window.__APP_MODE=${JSON.stringify(mode)};` +
|
||||||
`window.__APP_SKIN=${JSON.stringify(settings.skin)};`;
|
`window.__APP_SKIN=${JSON.stringify(sessionSkin)};`;
|
||||||
if (ctx !== undefined) script += `window.__APP_CTX=${JSON.stringify(ctx)};`;
|
if (ctx !== undefined) script += `window.__APP_CTX=${JSON.stringify(ctx)};`;
|
||||||
|
|
||||||
// A class, not a data-* attribute: 7.scoped.css is precompiled to require a
|
// A class, not a data-* attribute: 7.scoped.css is precompiled to require a
|
||||||
// literal `.win7` ancestor (mirrors SettingsService.applySkin).
|
// literal `.win7` ancestor, so the name is not ours to choose. This is the
|
||||||
if (settings.skin === "win7") {
|
// 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");`;
|
script += `document.documentElement.classList.add("win7");`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +59,12 @@ export function buildPreload(
|
|||||||
if (settings.theme !== "system") dataset["theme"] = settings.theme;
|
if (settings.theme !== "system") dataset["theme"] = settings.theme;
|
||||||
if (settings.darkTone !== "dark") dataset["darkTone"] = settings.darkTone;
|
if (settings.darkTone !== "dark") dataset["darkTone"] = settings.darkTone;
|
||||||
if (settings.accent !== "graphite") dataset["accent"] = settings.accent;
|
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)) {
|
for (const [key, value] of Object.entries(dataset)) {
|
||||||
script += `document.documentElement.dataset[${JSON.stringify(key)}]=${JSON.stringify(value)};`;
|
script += `document.documentElement.dataset[${JSON.stringify(key)}]=${JSON.stringify(value)};`;
|
||||||
}
|
}
|
||||||
@@ -43,25 +72,33 @@ export function buildPreload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Native chrome options for a window, per skin. The ONE place either skin's
|
* Native chrome options for a window. The ONE place the frame decision is made —
|
||||||
* frame decision is made — both the main window (src/bun/index.ts) and
|
* both the main window (src/bun/index.ts) and openSecondaryWindow spread this.
|
||||||
* openSecondaryWindow spread this.
|
|
||||||
*
|
*
|
||||||
* - "win7" draws its own Aero title bar in the webview (drag region + our own
|
* Both values are BrowserWindow CONSTRUCTOR options — there is no API to
|
||||||
* minimize/maximize/close), so the window must be frameless on EVERY
|
* re-decorate a live window — which is half of why the skin is a launch-time
|
||||||
* platform. `titleBarStyle: "hidden"` clears `Titled` from the Cocoa style
|
* choice. Takes no argument on purpose: it reads the pinned sessionSkin, so no
|
||||||
* mask, which is also what removes the macOS traffic lights — there is no
|
* call site can accidentally frame a window from a pending setting.
|
||||||
* separate hide-traffic-lights API.
|
|
||||||
* - "modern" keeps today's split: macOS fuses the tab bar into an inset
|
|
||||||
* titlebar (traffic lights overlay the UI's h-10 tab row, offset centers
|
|
||||||
* them); other platforms keep the native titlebar.
|
|
||||||
*
|
|
||||||
* Skin changes therefore only reach the frame on the NEXT window open — the
|
|
||||||
* CSS half applies live. The settings UI says so.
|
|
||||||
*/
|
*/
|
||||||
export function chromeOptions(skin: AppSettings["skin"]) {
|
export function chromeOptions() {
|
||||||
if (skin === "win7") return { titleBarStyle: "hidden" as const };
|
const skin = sessionSkin;
|
||||||
return isMac
|
// 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 } }
|
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
||||||
: {};
|
: {};
|
||||||
}
|
}
|
||||||
@@ -127,14 +164,14 @@ export interface SecondaryWindowOptions {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* App-chromed secondary window. The frame decision is delegated to
|
* App-chromed secondary window. The frame decision is delegated to
|
||||||
* chromeOptions (which owns both the per-skin and the per-platform branch).
|
* chromeOptions (the single frame decision).
|
||||||
*/
|
*/
|
||||||
export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow {
|
export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow {
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
title: opts.title,
|
title: opts.title,
|
||||||
url: opts.url,
|
url: opts.url,
|
||||||
frame: opts.frame,
|
frame: opts.frame,
|
||||||
...chromeOptions(opts.settings.skin),
|
...chromeOptions(),
|
||||||
preload: buildPreload(opts.mode, opts.settings, opts.ctx),
|
preload: buildPreload(opts.mode, opts.settings, opts.ctx),
|
||||||
rpc: opts.rpc,
|
rpc: opts.rpc,
|
||||||
hidden: true,
|
hidden: true,
|
||||||
|
|||||||
+12
-1
@@ -33,6 +33,12 @@ export const de = {
|
|||||||
title: "Optionen",
|
title: "Optionen",
|
||||||
language: "Sprache",
|
language: "Sprache",
|
||||||
system: "System",
|
system: "System",
|
||||||
|
skin: {
|
||||||
|
label: "Optik",
|
||||||
|
modern: "Modern",
|
||||||
|
win7: "Windows 7",
|
||||||
|
restartHint: "Wird beim nächsten Start von miniSCP übernommen",
|
||||||
|
},
|
||||||
theme: { label: "Design", system: "System", light: "Hell", dark: "Dunkel" },
|
theme: { label: "Design", system: "System", light: "Hell", dark: "Dunkel" },
|
||||||
tone: { label: "Ton", dark: "Dunkel", darker: "Dunkler" },
|
tone: { label: "Ton", dark: "Dunkel", darker: "Dunkler" },
|
||||||
accent: { label: "Akzent" },
|
accent: { label: "Akzent" },
|
||||||
@@ -47,7 +53,7 @@ export const de = {
|
|||||||
teal: "Petrol",
|
teal: "Petrol",
|
||||||
},
|
},
|
||||||
fileIcons: { label: "Datei-Symbole", plain: "Einfach", multi: "Farbig" },
|
fileIcons: { label: "Datei-Symbole", plain: "Einfach", multi: "Farbig" },
|
||||||
interface: { label: "Oberfläche", compact: "Kompakt", default: "Normal", large: "Groß" },
|
interface: { label: "Oberfläche", smaller: "Kleiner", larger: "Größer", reset: "Auf 100% zurücksetzen" },
|
||||||
speedUnit: { label: "Geschwindigkeitseinheit" },
|
speedUnit: { label: "Geschwindigkeitseinheit" },
|
||||||
transfers: { label: "Übertragungen", single: "Einzeln", multi: "Mehrere" },
|
transfers: { label: "Übertragungen", single: "Einzeln", multi: "Mehrere" },
|
||||||
atOnce: "Gleichzeitig",
|
atOnce: "Gleichzeitig",
|
||||||
@@ -288,6 +294,11 @@ export const de = {
|
|||||||
copyPath: "Pfad kopieren",
|
copyPath: "Pfad kopieren",
|
||||||
delete: "Löschen",
|
delete: "Löschen",
|
||||||
columns: "Spalten",
|
columns: "Spalten",
|
||||||
|
tabNew: "Neuer Tab",
|
||||||
|
tabClose: "Tab schließen",
|
||||||
|
tabCloseOthers: "Andere Tabs schließen",
|
||||||
|
tabCloseRight: "Tabs rechts schließen",
|
||||||
|
tabDisconnect: "Trennen",
|
||||||
trashTooltip: "Finders Wiederherstellen ist für von hier gelöschte Elemente nicht verfügbar",
|
trashTooltip: "Finders Wiederherstellen ist für von hier gelöschte Elemente nicht verfügbar",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export const en = {
|
|||||||
label: "Skin",
|
label: "Skin",
|
||||||
modern: "Modern",
|
modern: "Modern",
|
||||||
win7: "Windows 7",
|
win7: "Windows 7",
|
||||||
restartHint: "Reopen the window to change its frame",
|
restartHint: "Applies next time you start miniSCP",
|
||||||
},
|
},
|
||||||
theme: { label: "Theme", system: "System", light: "Light", dark: "Dark" },
|
theme: { label: "Theme", system: "System", light: "Light", dark: "Dark" },
|
||||||
tone: { label: "Tone", dark: "Dark", darker: "Darker" },
|
tone: { label: "Tone", dark: "Dark", darker: "Darker" },
|
||||||
@@ -57,7 +57,7 @@ export const en = {
|
|||||||
teal: "Teal",
|
teal: "Teal",
|
||||||
},
|
},
|
||||||
fileIcons: { label: "File icons", plain: "Plain", multi: "Multi" },
|
fileIcons: { label: "File icons", plain: "Plain", multi: "Multi" },
|
||||||
interface: { label: "Interface", compact: "Compact", default: "Default", large: "Large" },
|
interface: { label: "Interface", smaller: "Smaller", larger: "Larger", reset: "Reset to 100%" },
|
||||||
speedUnit: { label: "Speed unit" },
|
speedUnit: { label: "Speed unit" },
|
||||||
transfers: { label: "Transfers", single: "Single", multi: "Multi" },
|
transfers: { label: "Transfers", single: "Single", multi: "Multi" },
|
||||||
atOnce: "At once",
|
atOnce: "At once",
|
||||||
@@ -298,6 +298,11 @@ export const en = {
|
|||||||
copyPath: "Copy Path",
|
copyPath: "Copy Path",
|
||||||
delete: "Delete",
|
delete: "Delete",
|
||||||
columns: "Columns",
|
columns: "Columns",
|
||||||
|
tabNew: "New Tab",
|
||||||
|
tabClose: "Close Tab",
|
||||||
|
tabCloseOthers: "Close Other Tabs",
|
||||||
|
tabCloseRight: "Close Tabs to the Right",
|
||||||
|
tabDisconnect: "Disconnect",
|
||||||
trashTooltip: "Finder's Restore is not available for items trashed from here",
|
trashTooltip: "Finder's Restore is not available for items trashed from here",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
|
|||||||
+12
-1
@@ -33,6 +33,12 @@ export const es = {
|
|||||||
title: "Opciones",
|
title: "Opciones",
|
||||||
language: "Idioma",
|
language: "Idioma",
|
||||||
system: "Sistema",
|
system: "Sistema",
|
||||||
|
skin: {
|
||||||
|
label: "Aspecto",
|
||||||
|
modern: "Moderno",
|
||||||
|
win7: "Windows 7",
|
||||||
|
restartHint: "Se aplica la próxima vez que abras miniSCP",
|
||||||
|
},
|
||||||
theme: { label: "Tema", system: "Sistema", light: "Claro", dark: "Oscuro" },
|
theme: { label: "Tema", system: "Sistema", light: "Claro", dark: "Oscuro" },
|
||||||
tone: { label: "Tono", dark: "Oscuro", darker: "Más oscuro" },
|
tone: { label: "Tono", dark: "Oscuro", darker: "Más oscuro" },
|
||||||
accent: { label: "Acento" },
|
accent: { label: "Acento" },
|
||||||
@@ -47,7 +53,7 @@ export const es = {
|
|||||||
teal: "Turquesa",
|
teal: "Turquesa",
|
||||||
},
|
},
|
||||||
fileIcons: { label: "Iconos", plain: "Simple", multi: "Color" },
|
fileIcons: { label: "Iconos", plain: "Simple", multi: "Color" },
|
||||||
interface: { label: "Interfaz", compact: "Compacta", default: "Normal", large: "Grande" },
|
interface: { label: "Interfaz", smaller: "Más pequeña", larger: "Más grande", reset: "Restablecer al 100%" },
|
||||||
speedUnit: { label: "Unidad de velocidad" },
|
speedUnit: { label: "Unidad de velocidad" },
|
||||||
transfers: { label: "Transferencias", single: "Una", multi: "Varias" },
|
transfers: { label: "Transferencias", single: "Una", multi: "Varias" },
|
||||||
atOnce: "A la vez",
|
atOnce: "A la vez",
|
||||||
@@ -288,6 +294,11 @@ export const es = {
|
|||||||
copyPath: "Copiar ruta",
|
copyPath: "Copiar ruta",
|
||||||
delete: "Eliminar",
|
delete: "Eliminar",
|
||||||
columns: "Columnas",
|
columns: "Columnas",
|
||||||
|
tabNew: "Nueva pestaña",
|
||||||
|
tabClose: "Cerrar pestaña",
|
||||||
|
tabCloseOthers: "Cerrar las demás pestañas",
|
||||||
|
tabCloseRight: "Cerrar pestañas a la derecha",
|
||||||
|
tabDisconnect: "Desconectar",
|
||||||
trashTooltip: "La opción Restaurar de Finder no está disponible para elementos enviados a la papelera desde aquí",
|
trashTooltip: "La opción Restaurar de Finder no está disponible para elementos enviados a la papelera desde aquí",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
|
|||||||
+12
-1
@@ -35,6 +35,12 @@ export const eu = {
|
|||||||
title: "Aukerak",
|
title: "Aukerak",
|
||||||
language: "Hizkuntza",
|
language: "Hizkuntza",
|
||||||
system: "Sistema",
|
system: "Sistema",
|
||||||
|
skin: {
|
||||||
|
label: "Itxura",
|
||||||
|
modern: "Modernoa",
|
||||||
|
win7: "Windows 7",
|
||||||
|
restartHint: "Hurrengoan miniSCP abiaraztean aplikatuko da",
|
||||||
|
},
|
||||||
theme: { label: "Gaia", system: "Sistema", light: "Argia", dark: "Iluna" },
|
theme: { label: "Gaia", system: "Sistema", light: "Argia", dark: "Iluna" },
|
||||||
tone: { label: "Tonua", dark: "Iluna", darker: "Ilunagoa" },
|
tone: { label: "Tonua", dark: "Iluna", darker: "Ilunagoa" },
|
||||||
accent: { label: "Azentua" },
|
accent: { label: "Azentua" },
|
||||||
@@ -49,7 +55,7 @@ export const eu = {
|
|||||||
teal: "Turkesa",
|
teal: "Turkesa",
|
||||||
},
|
},
|
||||||
fileIcons: { label: "Fitxategi-ikonoak", plain: "Soila", multi: "Koloretsua" },
|
fileIcons: { label: "Fitxategi-ikonoak", plain: "Soila", multi: "Koloretsua" },
|
||||||
interface: { label: "Interfazea", compact: "Trinkoa", default: "Lehenetsia", large: "Handia" },
|
interface: { label: "Interfazea", smaller: "Txikiagoa", larger: "Handiagoa", reset: "Berrezarri %100era" },
|
||||||
speedUnit: { label: "Abiadura-unitatea" },
|
speedUnit: { label: "Abiadura-unitatea" },
|
||||||
transfers: { label: "Transferentziak", single: "Banaka", multi: "Anitz" },
|
transfers: { label: "Transferentziak", single: "Banaka", multi: "Anitz" },
|
||||||
atOnce: "Aldi berean",
|
atOnce: "Aldi berean",
|
||||||
@@ -290,6 +296,11 @@ export const eu = {
|
|||||||
copyPath: "Kopiatu bidea",
|
copyPath: "Kopiatu bidea",
|
||||||
delete: "Ezabatu",
|
delete: "Ezabatu",
|
||||||
columns: "Zutabeak",
|
columns: "Zutabeak",
|
||||||
|
tabNew: "Fitxa berria",
|
||||||
|
tabClose: "Itxi fitxa",
|
||||||
|
tabCloseOthers: "Itxi beste fitxak",
|
||||||
|
tabCloseRight: "Itxi eskuineko fitxak",
|
||||||
|
tabDisconnect: "Deskonektatu",
|
||||||
trashTooltip: "Finder-en «Jarri berriro» ez dago erabilgarri hemendik zakarrontzira botatako elementuentzat",
|
trashTooltip: "Finder-en «Jarri berriro» ez dago erabilgarri hemendik zakarrontzira botatako elementuentzat",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
|
|||||||
+12
-1
@@ -33,6 +33,12 @@ export const fr = {
|
|||||||
title: "Options",
|
title: "Options",
|
||||||
language: "Langue",
|
language: "Langue",
|
||||||
system: "Système",
|
system: "Système",
|
||||||
|
skin: {
|
||||||
|
label: "Apparence",
|
||||||
|
modern: "Moderne",
|
||||||
|
win7: "Windows 7",
|
||||||
|
restartHint: "Prend effet au prochain démarrage de miniSCP",
|
||||||
|
},
|
||||||
theme: { label: "Thème", system: "Système", light: "Clair", dark: "Sombre" },
|
theme: { label: "Thème", system: "Système", light: "Clair", dark: "Sombre" },
|
||||||
tone: { label: "Ton", dark: "Sombre", darker: "Plus sombre" },
|
tone: { label: "Ton", dark: "Sombre", darker: "Plus sombre" },
|
||||||
accent: { label: "Accent" },
|
accent: { label: "Accent" },
|
||||||
@@ -47,7 +53,7 @@ export const fr = {
|
|||||||
teal: "Sarcelle",
|
teal: "Sarcelle",
|
||||||
},
|
},
|
||||||
fileIcons: { label: "Icônes", plain: "Simple", multi: "Couleur" },
|
fileIcons: { label: "Icônes", plain: "Simple", multi: "Couleur" },
|
||||||
interface: { label: "Interface", compact: "Compacte", default: "Normale", large: "Grande" },
|
interface: { label: "Interface", smaller: "Plus petite", larger: "Plus grande", reset: "Réinitialiser à 100%" },
|
||||||
speedUnit: { label: "Unité de vitesse" },
|
speedUnit: { label: "Unité de vitesse" },
|
||||||
transfers: { label: "Transferts", single: "Un", multi: "Plusieurs" },
|
transfers: { label: "Transferts", single: "Un", multi: "Plusieurs" },
|
||||||
atOnce: "Simultanés",
|
atOnce: "Simultanés",
|
||||||
@@ -288,6 +294,11 @@ export const fr = {
|
|||||||
copyPath: "Copier le chemin",
|
copyPath: "Copier le chemin",
|
||||||
delete: "Supprimer",
|
delete: "Supprimer",
|
||||||
columns: "Colonnes",
|
columns: "Colonnes",
|
||||||
|
tabNew: "Nouvel onglet",
|
||||||
|
tabClose: "Fermer l'onglet",
|
||||||
|
tabCloseOthers: "Fermer les autres onglets",
|
||||||
|
tabCloseRight: "Fermer les onglets à droite",
|
||||||
|
tabDisconnect: "Déconnecter",
|
||||||
trashTooltip: "La restauration du Finder n'est pas disponible pour les éléments mis à la corbeille d'ici",
|
trashTooltip: "La restauration du Finder n'est pas disponible pour les éléments mis à la corbeille d'ici",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
|
|||||||
+12
-1
@@ -34,6 +34,12 @@ export const ja = {
|
|||||||
title: "オプション",
|
title: "オプション",
|
||||||
language: "言語",
|
language: "言語",
|
||||||
system: "システム",
|
system: "システム",
|
||||||
|
skin: {
|
||||||
|
label: "外観",
|
||||||
|
modern: "モダン",
|
||||||
|
win7: "Windows 7",
|
||||||
|
restartHint: "次回 miniSCP を起動したときに適用されます",
|
||||||
|
},
|
||||||
theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" },
|
theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" },
|
||||||
tone: { label: "トーン", dark: "ダーク", darker: "より暗く" },
|
tone: { label: "トーン", dark: "ダーク", darker: "より暗く" },
|
||||||
accent: { label: "アクセント" },
|
accent: { label: "アクセント" },
|
||||||
@@ -48,7 +54,7 @@ export const ja = {
|
|||||||
teal: "ティール",
|
teal: "ティール",
|
||||||
},
|
},
|
||||||
fileIcons: { label: "ファイルアイコン", plain: "シンプル", multi: "カラー" },
|
fileIcons: { label: "ファイルアイコン", plain: "シンプル", multi: "カラー" },
|
||||||
interface: { label: "インターフェース", compact: "コンパクト", default: "標準", large: "大" },
|
interface: { label: "インターフェース", smaller: "縮小", larger: "拡大", reset: "100%に戻す" },
|
||||||
speedUnit: { label: "速度の単位" },
|
speedUnit: { label: "速度の単位" },
|
||||||
transfers: { label: "転送", single: "1件ずつ", multi: "複数" },
|
transfers: { label: "転送", single: "1件ずつ", multi: "複数" },
|
||||||
atOnce: "同時数",
|
atOnce: "同時数",
|
||||||
@@ -288,6 +294,11 @@ export const ja = {
|
|||||||
copyPath: "パスをコピー",
|
copyPath: "パスをコピー",
|
||||||
delete: "削除",
|
delete: "削除",
|
||||||
columns: "列",
|
columns: "列",
|
||||||
|
tabNew: "新しいタブ",
|
||||||
|
tabClose: "タブを閉じる",
|
||||||
|
tabCloseOthers: "他のタブを閉じる",
|
||||||
|
tabCloseRight: "右側のタブを閉じる",
|
||||||
|
tabDisconnect: "切断",
|
||||||
trashTooltip: "ここからゴミ箱に入れた項目は Finder の「戻す」を利用できません",
|
trashTooltip: "ここからゴミ箱に入れた項目は Finder の「戻す」を利用できません",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
|
|||||||
+12
-1
@@ -34,6 +34,12 @@ export const zh = {
|
|||||||
title: "选项",
|
title: "选项",
|
||||||
language: "语言",
|
language: "语言",
|
||||||
system: "系统",
|
system: "系统",
|
||||||
|
skin: {
|
||||||
|
label: "外观",
|
||||||
|
modern: "现代",
|
||||||
|
win7: "Windows 7",
|
||||||
|
restartHint: "下次启动 miniSCP 时生效",
|
||||||
|
},
|
||||||
theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" },
|
theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" },
|
||||||
tone: { label: "色调", dark: "深色", darker: "更深" },
|
tone: { label: "色调", dark: "深色", darker: "更深" },
|
||||||
accent: { label: "强调色" },
|
accent: { label: "强调色" },
|
||||||
@@ -48,7 +54,7 @@ export const zh = {
|
|||||||
teal: "青色",
|
teal: "青色",
|
||||||
},
|
},
|
||||||
fileIcons: { label: "文件图标", plain: "简约", multi: "彩色" },
|
fileIcons: { label: "文件图标", plain: "简约", multi: "彩色" },
|
||||||
interface: { label: "界面", compact: "紧凑", default: "默认", large: "大" },
|
interface: { label: "界面", smaller: "缩小", larger: "放大", reset: "重置为100%" },
|
||||||
speedUnit: { label: "速度单位" },
|
speedUnit: { label: "速度单位" },
|
||||||
transfers: { label: "传输", single: "单个", multi: "多个" },
|
transfers: { label: "传输", single: "单个", multi: "多个" },
|
||||||
atOnce: "同时",
|
atOnce: "同时",
|
||||||
@@ -288,6 +294,11 @@ export const zh = {
|
|||||||
copyPath: "复制路径",
|
copyPath: "复制路径",
|
||||||
delete: "删除",
|
delete: "删除",
|
||||||
columns: "列",
|
columns: "列",
|
||||||
|
tabNew: "新建标签页",
|
||||||
|
tabClose: "关闭标签页",
|
||||||
|
tabCloseOthers: "关闭其他标签页",
|
||||||
|
tabCloseRight: "关闭右侧标签页",
|
||||||
|
tabDisconnect: "断开连接",
|
||||||
trashTooltip: "从此处移入废纸篓的项目无法使用访达的放回原处功能",
|
trashTooltip: "从此处移入废纸篓的项目无法使用访达的放回原处功能",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
|
|||||||
+24
-12
@@ -1,6 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
AppSettings,
|
AppSettings,
|
||||||
ColumnKey,
|
|
||||||
ConnectionConfig,
|
ConnectionConfig,
|
||||||
ConnectionsFile,
|
ConnectionsFile,
|
||||||
EntryType,
|
EntryType,
|
||||||
@@ -126,20 +125,18 @@ export type AppRPC = {
|
|||||||
response: Result<{ names: string[] }>;
|
response: Result<{ names: string[] }>;
|
||||||
};
|
};
|
||||||
clearEditLeftovers: { params: Record<string, never>; response: Result };
|
clearEditLeftovers: { params: Record<string, never>; response: Result };
|
||||||
// context menu + local shell integration
|
// local shell integration
|
||||||
showRowMenu: {
|
/**
|
||||||
params: {
|
* Shell actions the context menu triggers on a LOCAL path. The menu
|
||||||
path: string;
|
* itself is HTML (ui/src/app/context-menu.ts) — electrobun's native
|
||||||
name: string;
|
* context menu is a no-op on Linux — but these three still need the
|
||||||
type: EntryType;
|
* bun side's Utils.
|
||||||
isLocal: boolean;
|
*/
|
||||||
hasEntry: boolean;
|
shellItemAction: {
|
||||||
};
|
params: { action: "reveal" | "open" | "copyPath"; path: string };
|
||||||
response: Result;
|
response: Result;
|
||||||
};
|
};
|
||||||
moveToTrash: { params: { path: string }; response: Result };
|
moveToTrash: { params: { path: string }; response: Result };
|
||||||
/** Native column-picker menu (right-click on the list headers). */
|
|
||||||
showColumnsMenu: { params: { visible: ColumnKey[] }; response: Result };
|
|
||||||
/** Native About box (name, Build n, commit, engine versions). */
|
/** Native About box (name, Build n, commit, engine versions). */
|
||||||
showAbout: { params: Record<string, never>; response: Result };
|
showAbout: { params: Record<string, never>; response: Result };
|
||||||
/** Windows drive roots for the local panel ("C:\", …); empty elsewhere. */
|
/** Windows drive roots for the local panel ("C:\", …); empty elsewhere. */
|
||||||
@@ -171,6 +168,21 @@ export type AppRPC = {
|
|||||||
params: Record<string, never>;
|
params: Record<string, never>;
|
||||||
response: Result<{ maximized: boolean }>;
|
response: Result<{ maximized: boolean }>;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* STOPGAP — the two halves of the synthesized resize. A frameless GTK
|
||||||
|
* window has no resize borders and electrobun exposes no native resize
|
||||||
|
* gesture, so the webview drags its own handles and moves the frame
|
||||||
|
* itself. Delete both with the rest of the hack; see the removal
|
||||||
|
* checklist in ui/src/app/skins/7like/window-resize/window-resize.ts.
|
||||||
|
*/
|
||||||
|
windowGetFrame: {
|
||||||
|
params: Record<string, never>;
|
||||||
|
response: Result<{ x: number; y: number; width: number; height: number }>;
|
||||||
|
};
|
||||||
|
windowSetFrame: {
|
||||||
|
params: { x: number; y: number; width: number; height: number };
|
||||||
|
response: Result;
|
||||||
|
};
|
||||||
// auto-updater (electrobun Updater)
|
// auto-updater (electrobun Updater)
|
||||||
/** Main window: re-run the update check on demand (menu item). */
|
/** Main window: re-run the update check on demand (menu item). */
|
||||||
checkForUpdate: {
|
checkForUpdate: {
|
||||||
|
|||||||
+32
-5
@@ -79,8 +79,13 @@ export interface AppSettings {
|
|||||||
/** UI language; "system" resolves to the OS locale (fallback English). */
|
/** UI language; "system" resolves to the OS locale (fallback English). */
|
||||||
locale: "system" | SupportedLocale;
|
locale: "system" | SupportedLocale;
|
||||||
/**
|
/**
|
||||||
* Visual skin. "modern" is the native shadcn-style look; "win7" swaps in
|
* Visual skin. "modern" is the shadcn-style look; "win7" swaps in 7.css
|
||||||
* 7.css (Windows 7 Aero) and switches the window to custom frameless chrome.
|
* (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 —
|
* Changing this needs a window reopen to take effect on the frame itself —
|
||||||
* the CSS half applies live.
|
* the CSS half applies live.
|
||||||
*/
|
*/
|
||||||
@@ -95,8 +100,13 @@ export interface AppSettings {
|
|||||||
/** Visible optional file-list columns (both panels; Name always shows). */
|
/** Visible optional file-list columns (both panels; Name always shows). */
|
||||||
columns: ColumnKey[];
|
columns: ColumnKey[];
|
||||||
speedUnit: "MBps" | "Mbps";
|
speedUnit: "MBps" | "Mbps";
|
||||||
/** Size of the panel interface: file list, path bar, header controls. */
|
/**
|
||||||
textSize: "compact" | "default" | "large";
|
* Interface scale factor, 0.5–4 in 0.1 steps (shown as 50%–400%). Works by
|
||||||
|
* setting the root font-size — every size in the UI is rem, so one lever
|
||||||
|
* scales 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. */
|
/** Run transfer jobs one at a time, or several in parallel. */
|
||||||
transferMode: "single" | "multi";
|
transferMode: "single" | "multi";
|
||||||
/** How many jobs run at once when transferMode is "multi". */
|
/** How many jobs run at once when transferMode is "multi". */
|
||||||
@@ -122,7 +132,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
iconColors: "multi",
|
iconColors: "multi",
|
||||||
columns: ["size", "mtime"],
|
columns: ["size", "mtime"],
|
||||||
speedUnit: "MBps",
|
speedUnit: "MBps",
|
||||||
textSize: "default",
|
uiScale: 1,
|
||||||
transferMode: "single",
|
transferMode: "single",
|
||||||
transferConcurrency: 3,
|
transferConcurrency: 3,
|
||||||
autoClearCompleted: true,
|
autoClearCompleted: true,
|
||||||
@@ -131,6 +141,23 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
// autoRefresh: true,
|
// autoRefresh: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const UI_SCALE_MIN = 0.5;
|
||||||
|
export const UI_SCALE_MAX = 4;
|
||||||
|
export const UI_SCALE_STEP = 0.1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamp + snap a scale factor to the legal range and step grid. Shared by the
|
||||||
|
* store (a hand-edited settings.json must not be able to render the UI
|
||||||
|
* unusable), the settings service and the stepper — one definition of "valid",
|
||||||
|
* same as effectiveConcurrency below.
|
||||||
|
*/
|
||||||
|
export function clampUiScale(value: number): number {
|
||||||
|
if (!Number.isFinite(value)) return 1;
|
||||||
|
const clamped = Math.min(UI_SCALE_MAX, Math.max(UI_SCALE_MIN, value));
|
||||||
|
// snap to the step grid; toFixed strips float noise (0.7000000000000001 -> 0.7)
|
||||||
|
return Number((Math.round(clamped / UI_SCALE_STEP) * UI_SCALE_STEP).toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
export function effectiveConcurrency(settings: AppSettings): number {
|
export function effectiveConcurrency(settings: AppSettings): number {
|
||||||
if (settings.transferMode === "single") return 1;
|
if (settings.transferMode === "single") return 1;
|
||||||
return Math.max(1, Math.min(8, Math.round(settings.transferConcurrency) || 1));
|
return Math.max(1, Math.min(8, Math.round(settings.transferConcurrency) || 1));
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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,5 +1,6 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import type { FileEntry } from '@shared/types';
|
import type { FileEntry } from '@shared/types';
|
||||||
|
import { ContextMenuService } from './context-menu.service';
|
||||||
import { DialogsService } from './dialogs.service';
|
import { DialogsService } from './dialogs.service';
|
||||||
import { joinPath } from './format';
|
import { joinPath } from './format';
|
||||||
import { type PanelState, type Side } from './panels.service';
|
import { type PanelState, type Side } from './panels.service';
|
||||||
@@ -26,21 +27,25 @@ export class FileActionsService {
|
|||||||
private readonly rpc = inject(RpcService);
|
private readonly rpc = inject(RpcService);
|
||||||
private readonly toast = inject(ToastService);
|
private readonly toast = inject(ToastService);
|
||||||
private readonly dialogs = inject(DialogsService);
|
private readonly dialogs = inject(DialogsService);
|
||||||
|
private readonly menu = inject(ContextMenuService);
|
||||||
private readonly settings = inject(SettingsService);
|
private readonly settings = inject(SettingsService);
|
||||||
|
|
||||||
menuTarget: MenuTarget | null = null;
|
menuTarget: MenuTarget | null = null;
|
||||||
|
|
||||||
/** Right-click entry point: remember the target and ask bun for a native menu. */
|
/** Right-click entry point: remember the target, then open the menu on it. */
|
||||||
async openContextMenu(target: MenuTarget): Promise<void> {
|
openContextMenu(event: MouseEvent, target: MenuTarget): void {
|
||||||
this.menuTarget = target;
|
this.menuTarget = target;
|
||||||
const { panel, entry } = target;
|
const { panel, entry } = target;
|
||||||
await this.rpc.request.showRowMenu({
|
this.menu.open(
|
||||||
path: entry?.path ?? panel.cwd(),
|
event,
|
||||||
name: entry?.name ?? '',
|
this.menu.rowItems({
|
||||||
type: entry?.type ?? 'dir',
|
path: entry?.path ?? panel.cwd(),
|
||||||
isLocal: panel.sessionId() === 'local',
|
name: entry?.name ?? '',
|
||||||
hasEntry: !!entry,
|
type: entry?.type ?? 'dir',
|
||||||
});
|
isLocal: panel.sessionId() === 'local',
|
||||||
|
hasEntry: !!entry,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async trash(panel: PanelState, entry: FileEntry): Promise<void> {
|
async trash(panel: PanelState, entry: FileEntry): Promise<void> {
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Rem-to-pixel conversion at the CURRENT root font size.
|
||||||
|
*
|
||||||
|
* The interface scale (settings.uiScale) works by setting the root font-size,
|
||||||
|
* so any TypeScript that positions or measures against rem-sized CSS must
|
||||||
|
* convert through the LIVE root — never through an assumed 16. Both context
|
||||||
|
* menus' flip/clamp math is the canonical consumer: their widths and row
|
||||||
|
* heights are rem in CSS, and hardcoding their px mirrors is exactly the bug
|
||||||
|
* that made the shadlike menu flip at the wrong edge once scaling landed.
|
||||||
|
*
|
||||||
|
* Read per call, not cached: the scale can change mid-session (stepper,
|
||||||
|
* Ctrl+/-) and a menu opened after that must measure against the new root.
|
||||||
|
*/
|
||||||
|
export function remPx(rem: number): number {
|
||||||
|
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||||
|
}
|
||||||
@@ -398,6 +398,46 @@ export class PanelsService {
|
|||||||
this.refocus();
|
this.refocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close every tab except one — the tab menu's "Close Other Tabs".
|
||||||
|
*
|
||||||
|
* Snapshot the ids FIRST: closeWorkspace mutates the list on every call, so
|
||||||
|
* iterating the live signal would skip half of them. It also refuses the
|
||||||
|
* last tab, which is exactly the wanted floor here.
|
||||||
|
*/
|
||||||
|
closeOtherWorkspaces(id: number): void {
|
||||||
|
for (const other of this.workspaces().map((w) => w.id)) {
|
||||||
|
if (other !== id) this.closeWorkspace(other);
|
||||||
|
}
|
||||||
|
this.setActiveWorkspace(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close every tab to the right of one, in visual order. */
|
||||||
|
closeWorkspacesToRight(id: number): void {
|
||||||
|
const list = this.workspaces();
|
||||||
|
const index = list.findIndex((w) => w.id === id);
|
||||||
|
if (index < 0) return;
|
||||||
|
for (const target of list.slice(index + 1).map((w) => w.id)) {
|
||||||
|
this.closeWorkspace(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop both of a tab's remote sessions, keeping the tab itself. Unlike
|
||||||
|
* `disconnectPanel` this targets a tab by id, because the right-clicked tab
|
||||||
|
* is not necessarily the active one.
|
||||||
|
*/
|
||||||
|
async disconnectWorkspace(id: number): Promise<void> {
|
||||||
|
const workspace = this.workspaces().find((w) => w.id === id);
|
||||||
|
if (!workspace) return;
|
||||||
|
for (const panel of [workspace.left, workspace.right]) {
|
||||||
|
const sessionId = panel.sessionId();
|
||||||
|
if (sessionId === 'local') continue;
|
||||||
|
await this.rpc.request.disconnect({ sessionId });
|
||||||
|
this.resetToLocal(panel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setActiveWorkspace(id: number): void {
|
setActiveWorkspace(id: number): void {
|
||||||
if (this.activeWorkspaceId() !== id) {
|
if (this.activeWorkspaceId() !== id) {
|
||||||
this.activeWorkspaceId.set(id);
|
this.activeWorkspaceId.set(id);
|
||||||
@@ -8,8 +8,8 @@ declare global {
|
|||||||
__APP_PLATFORM?: string;
|
__APP_PLATFORM?: string;
|
||||||
/** Which root component this window boots. */
|
/** Which root component this window boots. */
|
||||||
__APP_MODE?: 'main' | 'editor' | 'manager';
|
__APP_MODE?: 'main' | 'editor' | 'manager';
|
||||||
/** Persisted skin — decides the window's native chrome, so it has to be
|
/** Which skin tree this window boots. Baked into the preload because
|
||||||
* known before first paint (see chromeOptions in src/bun/windows.ts). */
|
* settings resolve too late to decide layout without a flash. */
|
||||||
__APP_SKIN?: 'modern' | 'win7';
|
__APP_SKIN?: 'modern' | 'win7';
|
||||||
/** Window-kind specific context (EditorOpenContext for editor windows). */
|
/** Window-kind specific context (EditorOpenContext for editor windows). */
|
||||||
__APP_CTX?: unknown;
|
__APP_CTX?: unknown;
|
||||||
@@ -18,30 +18,14 @@ declare global {
|
|||||||
|
|
||||||
export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?? '') : '';
|
export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?? '') : '';
|
||||||
|
|
||||||
const IS_MAC = PLATFORM === 'darwin';
|
export const IS_MAC = PLATFORM === 'darwin';
|
||||||
|
|
||||||
/**
|
|
||||||
* The win7 skin makes the window frameless on EVERY platform and draws its own
|
|
||||||
* Aero title bar, so this is a window-chrome fact, not just a settings value —
|
|
||||||
* it is baked into the preload rather than read from SettingsService, which
|
|
||||||
* resolves too late for layout. Toggling the setting only affects windows
|
|
||||||
* opened afterwards.
|
|
||||||
*/
|
|
||||||
export const IS_WIN7_CHROME =
|
|
||||||
typeof window !== 'undefined' && window.__APP_SKIN === 'win7';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True when macOS traffic lights overlay the app's 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, so
|
|
||||||
* reserving space for them would leave a gap.
|
|
||||||
*/
|
|
||||||
export const IS_MAC_CHROME = IS_MAC && !IS_WIN7_CHROME;
|
|
||||||
|
|
||||||
export const IS_WINDOWS = PLATFORM === 'win32';
|
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
|
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. Keyed on the real
|
||||||
* platform, NOT IS_MAC_CHROME — the keyboard does not change with the skin. */
|
* platform — the keyboard does not change with the window chrome. */
|
||||||
export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+';
|
export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+';
|
||||||
export const ALT_LABEL = IS_MAC ? '⌥' : 'Alt+';
|
export const ALT_LABEL = IS_MAC ? '⌥' : 'Alt+';
|
||||||
|
|
||||||
@@ -51,6 +35,26 @@ export const APP_MODE: 'main' | 'editor' | 'manager' =
|
|||||||
export const EDITOR_CONTEXT =
|
export const EDITOR_CONTEXT =
|
||||||
typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined;
|
typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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…)
|
* Kill the webview's default right-click menu (Reload, Inspect Element…)
|
||||||
* everywhere the app doesn't show its own native menu. Text fields keep the
|
* everywhere the app doesn't show its own native menu. Text fields keep the
|
||||||
@@ -103,6 +103,15 @@ export class RpcService {
|
|||||||
this.menuListeners.add(listener);
|
this.menuListeners.add(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch an action that originated in the UI (the HTML context menu) as if
|
||||||
|
* it had arrived from bun, so app.ts keeps ONE handler for menu actions
|
||||||
|
* regardless of whether the app menubar or a right-click produced them.
|
||||||
|
*/
|
||||||
|
emitMenuAction(action: string): void {
|
||||||
|
for (const listener of this.menuListeners) listener(action);
|
||||||
|
}
|
||||||
|
|
||||||
onSettingsChanged(listener: (settings: AppSettings) => void): void {
|
onSettingsChanged(listener: (settings: AppSettings) => void): void {
|
||||||
this.settingsListeners.add(listener);
|
this.settingsListeners.add(listener);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
import { resolveLocale } from '@shared/i18n';
|
import { resolveLocale } from '@shared/i18n';
|
||||||
import { DEFAULT_SETTINGS, type AppSettings, type ColumnKey } from '@shared/types';
|
import { clampUiScale, DEFAULT_SETTINGS, type AppSettings, type ColumnKey } from '@shared/types';
|
||||||
import { I18nService } from './i18n.service';
|
import { I18nService } from './i18n.service';
|
||||||
import { RpcService } from './rpc.service';
|
import { RpcService } from './rpc.service';
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ export class SettingsService {
|
|||||||
readonly iconColors = signal<AppSettings['iconColors']>(DEFAULT_SETTINGS.iconColors);
|
readonly iconColors = signal<AppSettings['iconColors']>(DEFAULT_SETTINGS.iconColors);
|
||||||
readonly columns = signal<ColumnKey[]>(DEFAULT_SETTINGS.columns);
|
readonly columns = signal<ColumnKey[]>(DEFAULT_SETTINGS.columns);
|
||||||
readonly speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit);
|
readonly speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit);
|
||||||
readonly textSize = signal<AppSettings['textSize']>(DEFAULT_SETTINGS.textSize);
|
readonly uiScale = signal<number>(DEFAULT_SETTINGS.uiScale);
|
||||||
readonly transferMode = signal<AppSettings['transferMode']>(DEFAULT_SETTINGS.transferMode);
|
readonly transferMode = signal<AppSettings['transferMode']>(DEFAULT_SETTINGS.transferMode);
|
||||||
readonly transferConcurrency = signal(DEFAULT_SETTINGS.transferConcurrency);
|
readonly transferConcurrency = signal(DEFAULT_SETTINGS.transferConcurrency);
|
||||||
readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted);
|
readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted);
|
||||||
@@ -41,7 +41,6 @@ export class SettingsService {
|
|||||||
.addEventListener('change', (e) => this.systemDark.set(e.matches));
|
.addEventListener('change', (e) => this.systemDark.set(e.matches));
|
||||||
// the default accent is not the CSS default — apply before first paint
|
// the default accent is not the CSS default — apply before first paint
|
||||||
this.applyAccent();
|
this.applyAccent();
|
||||||
this.applySkin();
|
|
||||||
if (!this.rpc.available) return;
|
if (!this.rpc.available) return;
|
||||||
void this.rpc.request.getSettings({}).then((res) => {
|
void this.rpc.request.getSettings({}).then((res) => {
|
||||||
if (res.ok) this.applyAll(res.settings);
|
if (res.ok) this.applyAll(res.settings);
|
||||||
@@ -59,17 +58,19 @@ export class SettingsService {
|
|||||||
this.iconColors.set(settings.iconColors);
|
this.iconColors.set(settings.iconColors);
|
||||||
this.columns.set(settings.columns);
|
this.columns.set(settings.columns);
|
||||||
this.speedUnit.set(settings.speedUnit);
|
this.speedUnit.set(settings.speedUnit);
|
||||||
this.textSize.set(settings.textSize);
|
this.uiScale.set(settings.uiScale);
|
||||||
this.transferMode.set(settings.transferMode);
|
this.transferMode.set(settings.transferMode);
|
||||||
this.transferConcurrency.set(settings.transferConcurrency);
|
this.transferConcurrency.set(settings.transferConcurrency);
|
||||||
this.autoClearCompleted.set(settings.autoClearCompleted);
|
this.autoClearCompleted.set(settings.autoClearCompleted);
|
||||||
this.editor.set(settings.editor);
|
this.editor.set(settings.editor);
|
||||||
this.editorApp.set(settings.editorApp);
|
this.editorApp.set(settings.editorApp);
|
||||||
this.applySkin();
|
// 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.applyTheme();
|
||||||
this.applyDarkTone();
|
this.applyDarkTone();
|
||||||
this.applyAccent();
|
this.applyAccent();
|
||||||
this.applyTextSize();
|
this.applyUiScale();
|
||||||
this.applyLocale();
|
this.applyLocale();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,9 +80,27 @@ export class SettingsService {
|
|||||||
void this.persist();
|
void this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the preference and NOTHING else — the skin is a launch-time choice.
|
||||||
|
*
|
||||||
|
* It deliberately does not touch the `.win7` class, which is the whole bug
|
||||||
|
* this replaced: each skin's CSS is written against its OWN component tree's
|
||||||
|
* markup, and the tree is fixed at bootstrap from the preload-baked
|
||||||
|
* __APP_SKIN. Flipping the class live therefore painted 7.css onto shadlike
|
||||||
|
* markup that has no .window.glass, no .title-bar, no menu[role=tablist] and
|
||||||
|
* no <table> ListView — a broken window, not a reskinned one.
|
||||||
|
*
|
||||||
|
* A reload would not have helped either: the preload string is frozen at
|
||||||
|
* webview construction (a persistent WebKitUserScript), and titleBarStyle /
|
||||||
|
* transparent have no setters, so the native frame cannot change on a live
|
||||||
|
* window. win7 -> modern without a reframe is the dangerous direction — the
|
||||||
|
* modern tree draws no title bar, so it would land in a frameless window
|
||||||
|
* with no way to move, minimize or close it.
|
||||||
|
*
|
||||||
|
* The options row shows the saved value as pending until the next start.
|
||||||
|
*/
|
||||||
setSkin(skin: AppSettings['skin']): void {
|
setSkin(skin: AppSettings['skin']): void {
|
||||||
this.skin.set(skin);
|
this.skin.set(skin);
|
||||||
this.applySkin();
|
|
||||||
void this.persist();
|
void this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,9 +140,9 @@ export class SettingsService {
|
|||||||
void this.persist();
|
void this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
setTextSize(size: AppSettings['textSize']): void {
|
setUiScale(scale: number): void {
|
||||||
this.textSize.set(size);
|
this.uiScale.set(clampUiScale(scale));
|
||||||
this.applyTextSize();
|
this.applyUiScale();
|
||||||
void this.persist();
|
void this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,15 +171,11 @@ export class SettingsService {
|
|||||||
void this.persist();
|
void this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// There is deliberately no applySkin(). The `.win7` class is written once by
|
||||||
* The odd one out: a CLASS, not a data-* attribute. 7.scoped.css is
|
// buildPreload at DOCUMENT_START for the skin this window actually booted,
|
||||||
* precompiled to require a literal `.win7` ancestor, so the class is not
|
// and must never change afterwards — see setSkin for why. Every other apply*
|
||||||
* ours to choose. Default ("modern") = class absent, matching the
|
// below is safe to run live because it only re-points tokens, and both skins
|
||||||
* delete-when-default rule the other axes follow.
|
// consume the same token system.
|
||||||
*/
|
|
||||||
private applySkin(): void {
|
|
||||||
document.documentElement.classList.toggle('win7', this.skin() === 'win7');
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyTheme(): void {
|
private applyTheme(): void {
|
||||||
const theme = this.theme();
|
const theme = this.theme();
|
||||||
@@ -189,12 +204,17 @@ export class SettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyTextSize(): void {
|
/** The whole interface scale is ONE declaration: every size in the app is
|
||||||
const size = this.textSize();
|
* rem, so moving the root font-size moves both skins in lockstep. 1 = the
|
||||||
if (size === 'default') {
|
* browser-default 16px root; delete-when-default keeps the pristine state
|
||||||
delete document.documentElement.dataset['text'];
|
* attribute-free like the other axes. buildPreload mirrors this for a
|
||||||
|
* flash-free first paint. */
|
||||||
|
private applyUiScale(): void {
|
||||||
|
const scale = this.uiScale();
|
||||||
|
if (scale === 1) {
|
||||||
|
document.documentElement.style.removeProperty('font-size');
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.dataset['text'] = size;
|
document.documentElement.style.fontSize = `${16 * scale}px`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +232,7 @@ export class SettingsService {
|
|||||||
iconColors: this.iconColors(),
|
iconColors: this.iconColors(),
|
||||||
columns: this.columns(),
|
columns: this.columns(),
|
||||||
speedUnit: this.speedUnit(),
|
speedUnit: this.speedUnit(),
|
||||||
textSize: this.textSize(),
|
uiScale: this.uiScale(),
|
||||||
transferMode: this.transferMode(),
|
transferMode: this.transferMode(),
|
||||||
transferConcurrency: this.transferConcurrency(),
|
transferConcurrency: this.transferConcurrency(),
|
||||||
autoClearCompleted: this.autoClearCompleted(),
|
autoClearCompleted: this.autoClearCompleted(),
|
||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Component, computed, inject, input, signal } from '@angular/core';
|
import { Component, computed, inject, input, signal } from '@angular/core';
|
||||||
import { I18nService } from '../i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import { DialogsService, type ChmodRequest } from '../dialogs.service';
|
import { DialogsService, type ChmodRequest } from '@core/dialogs.service';
|
||||||
import { FocusService } from '../focus.service';
|
import { FocusService } from '@core/focus.service';
|
||||||
|
|
||||||
const ROLES = ['roleOwner', 'roleGroup', 'roleOthers'] as const;
|
const ROLES = ['roleOwner', 'roleGroup', 'roleOthers'] as const;
|
||||||
const PERMS = ['r', 'w', 'x'] as const;
|
const PERMS = ['r', 'w', 'x'] as const;
|
||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core';
|
import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core';
|
||||||
import { I18nService } from '../i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import { DialogsService, type ConfirmRequest } from '../dialogs.service';
|
import { DialogsService, type ConfirmRequest } from '@core/dialogs.service';
|
||||||
import { FocusService } from '../focus.service';
|
import { FocusService } from '@core/focus.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-confirm-dialog',
|
selector: 'app-confirm-dialog',
|
||||||
+7
-7
@@ -8,9 +8,9 @@ import {
|
|||||||
viewChild,
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import { I18nService } from '../i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '../dialogs.service';
|
import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '@core/dialogs.service';
|
||||||
import { FocusService } from '../focus.service';
|
import { FocusService } from '@core/focus.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-overwrite-dialog',
|
selector: 'app-overwrite-dialog',
|
||||||
@@ -30,7 +30,7 @@ import { FocusService } from '../focus.service';
|
|||||||
</h2>
|
</h2>
|
||||||
@if (request().total > 1) {
|
@if (request().total > 1) {
|
||||||
<span
|
<span
|
||||||
class="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground"
|
class="shrink-0 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 }) }}
|
{{ i18n.t('dialogs.ofTotal', { index: request().index, total: request().total }) }}
|
||||||
</span>
|
</span>
|
||||||
@@ -49,11 +49,11 @@ import { FocusService } from '../focus.service';
|
|||||||
@for (side of comparison(); track side.label) {
|
@for (side of comparison(); track side.label) {
|
||||||
<div class="rounded-md border border-border p-2.5">
|
<div class="rounded-md border border-border p-2.5">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
|
<span class="text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
|
||||||
{{ i18n.t('dialogs.' + side.label) }}
|
{{ i18n.t('dialogs.' + side.label) }}
|
||||||
</span>
|
</span>
|
||||||
@if (side.newer) {
|
@if (side.newer) {
|
||||||
<span class="rounded-full bg-success/15 px-1.5 text-[10px] font-medium text-success">
|
<span class="rounded-full bg-success/15 px-1.5 text-[0.625rem] font-medium text-success">
|
||||||
{{ i18n.t('dialogs.newer') }}
|
{{ i18n.t('dialogs.newer') }}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -68,7 +68,7 @@ import { FocusService } from '../focus.service';
|
|||||||
{{ isDir() ? '—' : i18n.size(side.entry.size) }}
|
{{ isDir() ? '—' : i18n.size(side.entry.size) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 font-mono text-[11px] tabular-nums text-muted-foreground">
|
<div class="mt-1 font-mono text-[0.6875rem] tabular-nums text-muted-foreground">
|
||||||
{{ i18n.date(side.entry.mtime) }}
|
{{ i18n.date(side.entry.mtime) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
+3
-3
@@ -7,9 +7,9 @@ import {
|
|||||||
signal,
|
signal,
|
||||||
viewChild,
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { I18nService } from '../i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import { DialogsService, type PromptRequest } from '../dialogs.service';
|
import { DialogsService, type PromptRequest } from '@core/dialogs.service';
|
||||||
import { FocusService } from '../focus.service';
|
import { FocusService } from '@core/focus.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-prompt-dialog',
|
selector: 'app-prompt-dialog',
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
<!-- session strip.
|
||||||
|
w7-toolbar: these are Explorer command-bar verbs, not dialog push buttons,
|
||||||
|
so under the win7 skin they go flat with a hover glow instead of wearing
|
||||||
|
the full Aero slab — the same treatment the Connections button gets from
|
||||||
|
the tab-bar row. -->
|
||||||
|
<div class="w7-toolbar relative flex items-center gap-1.5 border-b border-border px-2 py-1">
|
||||||
|
<button
|
||||||
|
(click)="toggleMenu()"
|
||||||
|
class="flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1 text-(length:--path-font) font-medium hover:bg-accent"
|
||||||
|
[title]="i18n.t('panel.choosePanel')"
|
||||||
|
>
|
||||||
|
@if (panel().connecting()) {
|
||||||
|
<ng-icon name="mynaSpinner" class="shrink-0 animate-spin text-primary" size="var(--action-icon)" />
|
||||||
|
} @else {
|
||||||
|
<ng-icon
|
||||||
|
[name]="isRemote() ? 'mynaServers' : 'mynaHardDrive'"
|
||||||
|
[class]="isRemote() ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'"
|
||||||
|
size="var(--action-icon)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<span class="truncate">{{ isRemote() ? panel().title() : i18n.t('common.local') }}</span>
|
||||||
|
<ng-icon
|
||||||
|
name="mynaChevronDown"
|
||||||
|
class="shrink-0 text-muted-foreground"
|
||||||
|
size="calc(var(--action-icon) - 0.1rem)"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
|
<!-- panel actions: act on THIS panel. Frequent/destructive verbs grow text
|
||||||
|
labels when the panel is wide enough (container query). -->
|
||||||
|
<button
|
||||||
|
(click)="copyToOther()"
|
||||||
|
[title]="side() === 'left' ? i18n.t('panel.copyToRight') : i18n.t('panel.copyToLeft')"
|
||||||
|
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-primary"
|
||||||
|
>
|
||||||
|
<ng-icon [name]="copyIcon()" size="var(--action-icon)" />
|
||||||
|
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.copy') }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="run('edit')"
|
||||||
|
[title]="i18n.t('panel.editFile')"
|
||||||
|
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaFileText" size="var(--action-icon)" />
|
||||||
|
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.edit') }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="run('rename')"
|
||||||
|
[title]="i18n.t('panel.rename')"
|
||||||
|
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaEdit" size="var(--action-icon)" />
|
||||||
|
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.rename') }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="run('mkdir')"
|
||||||
|
[title]="i18n.t('panel.newFolder')"
|
||||||
|
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaFolderPlus" size="var(--action-icon)" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="run('createFile')"
|
||||||
|
[title]="i18n.t('panel.newFile')"
|
||||||
|
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaFilePlus" size="var(--action-icon)" />
|
||||||
|
</button>
|
||||||
|
@if (chmodAvailable()) {
|
||||||
|
<button
|
||||||
|
(click)="run('chmod')"
|
||||||
|
[title]="i18n.t('panel.permissions')"
|
||||||
|
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaLock" size="var(--action-icon)" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
(click)="run('delete')"
|
||||||
|
[title]="i18n.t('panel.deleteHint')"
|
||||||
|
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-destructive"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaTrash" size="var(--action-icon)" />
|
||||||
|
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.delete') }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (menuOpen()) {
|
||||||
|
<div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div>
|
||||||
|
<div
|
||||||
|
class="has-scrollbar absolute top-full left-2 z-30 mt-1 max-h-80 w-64 overflow-y-auto rounded-lg border border-border bg-card py-1 text-card-foreground shadow-xl"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
(click)="pickLocal()"
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
||||||
|
<span class="flex-1">{{ i18n.t('common.local') }}</span>
|
||||||
|
@if (!isRemote()) {
|
||||||
|
<ng-icon name="mynaCheck" size="0.85rem" class="shrink-0 text-primary" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Windows drive roots (empty list elsewhere) -->
|
||||||
|
@for (drive of drives(); track drive) {
|
||||||
|
<button
|
||||||
|
(click)="pickDrive(drive)"
|
||||||
|
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-6 text-left text-(length:--path-font) hover:bg-accent"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
||||||
|
<span class="flex-1 font-mono">{{ drive }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (connections.grouped().loose.length || connections.grouped().stacks.length) {
|
||||||
|
<div class="my-1 border-t border-border"></div>
|
||||||
|
}
|
||||||
|
@for (connection of connections.grouped().loose; track connection.id) {
|
||||||
|
<button
|
||||||
|
(click)="pickConnection(connection)"
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
||||||
|
<span class="flex-1 truncate">{{ connection.name }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@for (group of connections.grouped().stacks; track group.stack.id) {
|
||||||
|
<div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
|
||||||
|
{{ group.stack.name }}
|
||||||
|
</div>
|
||||||
|
@for (connection of group.connections; track connection.id) {
|
||||||
|
<button
|
||||||
|
(click)="pickConnection(connection)"
|
||||||
|
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
||||||
|
<span class="flex-1 truncate">{{ connection.name }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (connections.sshHosts().length) {
|
||||||
|
<div class="my-1 border-t border-border"></div>
|
||||||
|
<div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
|
||||||
|
~/.ssh/config
|
||||||
|
</div>
|
||||||
|
@for (host of connections.sshHosts(); track host.name) {
|
||||||
|
<button
|
||||||
|
(click)="pickSshHost(host)"
|
||||||
|
[disabled]="host.unsupported"
|
||||||
|
[title]="sshHostTitle(host)"
|
||||||
|
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent disabled:opacity-60 disabled:hover:bg-transparent"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
||||||
|
<span class="flex-1 truncate">{{ host.name }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- path bar. Also w7-toolbar: the up/refresh controls sit next to the action
|
||||||
|
row, so leaving them as Aero slabs would read as arbitrary. The class only
|
||||||
|
targets buttons — the path <input> keeps its 7.css textbox treatment. -->
|
||||||
|
<div class="w7-toolbar flex items-center gap-1 border-b border-border px-2 py-1.5">
|
||||||
|
<button
|
||||||
|
(click)="panel().up()"
|
||||||
|
[disabled]="blocked()"
|
||||||
|
[title]="i18n.t('panel.parentDir')"
|
||||||
|
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaArrowUp" size="1rem" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
[value]="pathDraft()"
|
||||||
|
(input)="pathDraft.set($any($event.target).value)"
|
||||||
|
(keydown.enter)="commitPath()"
|
||||||
|
(keydown.escape)="resetPath()"
|
||||||
|
(keydown)="$event.stopPropagation()"
|
||||||
|
[disabled]="blocked()"
|
||||||
|
spellcheck="false"
|
||||||
|
class="h-(--path-ctl-h) min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono text-(length:--path-font) outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
(click)="panel().refresh()"
|
||||||
|
[disabled]="blocked()"
|
||||||
|
[title]="i18n.t('panel.refresh')"
|
||||||
|
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
|
||||||
|
>
|
||||||
|
@if (panel().loading()) {
|
||||||
|
<ng-icon name="mynaSpinner" size="1rem" class="animate-spin" />
|
||||||
|
} @else {
|
||||||
|
<ng-icon name="mynaRefresh" size="1rem" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Column headers live INSIDE the table now (7.css makes thead sticky), so
|
||||||
|
there is no separate header row here — see the ListView below. -->
|
||||||
|
|
||||||
|
<!-- type-to-filter bar (appears while a query is active) -->
|
||||||
|
@if (panel().filter(); as query) {
|
||||||
|
<div class="flex items-center gap-1.5 border-b border-border bg-accent/50 px-2 py-1">
|
||||||
|
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
||||||
|
<span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span>
|
||||||
|
<span class="shrink-0 text-[0.6875rem] text-muted-foreground">
|
||||||
|
{{ i18n.t('panel.matches', { count: filterMatches() }) }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
(click)="clearFilter()"
|
||||||
|
[title]="i18n.t('panel.clearFilter')"
|
||||||
|
class="grid size-5 shrink-0 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaX" size="0.8rem" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- file list -->
|
||||||
|
<div class="relative min-h-0 flex-1">
|
||||||
|
<div
|
||||||
|
#list
|
||||||
|
tabindex="0"
|
||||||
|
(keydown)="onKeydown($event)"
|
||||||
|
(mousedown)="activate()"
|
||||||
|
(contextmenu)="listContextMenu($event)"
|
||||||
|
class="has-scrollbar h-full overflow-y-auto outline-none"
|
||||||
|
>
|
||||||
|
@if (panel().connecting()) {
|
||||||
|
<div class="panel-fade-in grid h-full place-items-center">
|
||||||
|
<div class="flex flex-col items-center gap-3">
|
||||||
|
<ng-icon name="mynaSpinner" size="1.4rem" class="animate-spin text-primary" />
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
{{ i18n.t('panel.connectingTo', { name: panel().connectingTo() ?? '' }) }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
(click)="cancelConnect()"
|
||||||
|
class="rounded-md border border-border px-3 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
{{ i18n.t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<!-- 7.css ListView: a plain <table>. The header is <thead> (7.css makes it
|
||||||
|
position:sticky, which is why it moved in here), the sorted column is
|
||||||
|
.highlighted.indicator[.up], and the selection band is tr.highlighted.
|
||||||
|
Widths come from <colgroup> because 7.css sets table-layout:fixed.
|
||||||
|
`w-full` is Tailwind's job and safe here: 7.css never declares `width` on
|
||||||
|
table, so the `skin` layer has nothing to outrank `utilities` with.
|
||||||
|
Without it the fixed layout shrinks to content and leaves a dead grey
|
||||||
|
gutter down the right of the pane; with it the unsized first <col>
|
||||||
|
absorbs the slack, which is what makes Name the elastic column. -->
|
||||||
|
<table class="w-full" [class.is-active]="isActive()">
|
||||||
|
<colgroup>
|
||||||
|
<col />
|
||||||
|
@for (col of visibleColumns(); track col.key) {
|
||||||
|
<col [style.width]="col.width" />
|
||||||
|
}
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr (contextmenu)="headerContextMenu($event)">
|
||||||
|
<th
|
||||||
|
(click)="panel().toggleSort('name')"
|
||||||
|
[class.highlighted]="panel().sortKey() === 'name'"
|
||||||
|
[class.indicator]="panel().sortKey() === 'name'"
|
||||||
|
[class.up]="panel().sortKey() === 'name' && panel().sortAsc()"
|
||||||
|
>
|
||||||
|
{{ i18n.t('columns.name') }}
|
||||||
|
</th>
|
||||||
|
@for (col of visibleColumns(); track col.key) {
|
||||||
|
<th
|
||||||
|
(click)="panel().toggleSort(col.key)"
|
||||||
|
[class.highlighted]="panel().sortKey() === col.key"
|
||||||
|
[class.indicator]="panel().sortKey() === col.key"
|
||||||
|
[class.up]="panel().sortKey() === col.key && panel().sortAsc()"
|
||||||
|
[style.text-align]="col.key === 'size' ? 'right' : 'left'"
|
||||||
|
>
|
||||||
|
{{ i18n.t('columns.' + col.key) }}
|
||||||
|
</th>
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (entry of panel().sorted(); track entry.name; let i = $index) {
|
||||||
|
<tr
|
||||||
|
[attr.data-index]="i"
|
||||||
|
(click)="rowClick($event, i)"
|
||||||
|
(dblclick)="openAt(i)"
|
||||||
|
(contextmenu)="rowContextMenu($event, i)"
|
||||||
|
[class.highlighted]="isSelected(entry) || i === panel().cursor()"
|
||||||
|
[class.is-cursor]="i === panel().cursor() && isActive()"
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<span class="flex min-w-0 items-center gap-1.5">
|
||||||
|
<ng-icon [name]="iconFor(entry)" size="0.95rem" [class]="iconClass(entry)" />
|
||||||
|
<span class="truncate" [class.italic]="entry.type === 'symlink'">{{ entry.name }}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
@for (col of visibleColumns(); track col.key) {
|
||||||
|
@switch (col.key) {
|
||||||
|
@case ('size') {
|
||||||
|
<td style="text-align: right">{{ entry.type === 'dir' ? '—' : formatSize(entry.size) }}</td>
|
||||||
|
}
|
||||||
|
@case ('mtime') {
|
||||||
|
<td>{{ formatDate(entry.mtime) }}</td>
|
||||||
|
}
|
||||||
|
@case ('kind') {
|
||||||
|
<!-- fileKind is localized in file-panel.ts via i18n -->
|
||||||
|
<td>{{ fileKind(entry) }}</td>
|
||||||
|
}
|
||||||
|
@case ('rights') {
|
||||||
|
<td>{{ entry.name === '..' ? '' : formatMode(entry.mode) }}</td>
|
||||||
|
}
|
||||||
|
@case ('owner') {
|
||||||
|
<td>{{ entry.name === '..' ? '' : (entry.owner ?? '—') }}</td>
|
||||||
|
}
|
||||||
|
@case ('group') {
|
||||||
|
<td>{{ entry.name === '..' ? '' : (entry.group ?? '—') }}</td>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td [attr.colspan]="visibleColumns().length + 1" style="text-align: center; padding: 1.5rem">
|
||||||
|
{{ i18n.t('panel.emptyDir') }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
@if (panel().filter() && filterMatches() === 0) {
|
||||||
|
<tr>
|
||||||
|
<td [attr.colspan]="visibleColumns().length + 1" style="text-align: center; padding: 1.5rem">
|
||||||
|
{{ i18n.t('panel.noMatches') }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (panel().slowLoad() && !panel().connecting()) {
|
||||||
|
<div class="panel-fade-in absolute inset-0 z-10 grid place-items-center bg-background/60">
|
||||||
|
<ng-icon name="mynaSpinner" size="1.4rem" class="animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
signal,
|
||||||
|
viewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { NgIcon } from '@ng-icons/core';
|
||||||
|
import { I18nService } from '@core/i18n.service';
|
||||||
|
import type { ColumnKey, FileEntry, SavedConnection, SshConfigHost } from '@shared/types';
|
||||||
|
import { ConnectionsService } from '@core/connections.service';
|
||||||
|
import { ContextMenuService } from '@core/context-menu.service';
|
||||||
|
import { DialogsService } from '@core/dialogs.service';
|
||||||
|
import { FileActionsService } from '@core/file-actions.service';
|
||||||
|
import { fileGlyph, fileIconClass, fileKindKey } from '@core/file-icons';
|
||||||
|
import { formatMode } from '@core/format';
|
||||||
|
import { PanelsService, type Side, type SortKey } from '@core/panels.service';
|
||||||
|
import { IS_WINDOWS } from '@core/platform';
|
||||||
|
import { RpcService } from '@core/rpc.service';
|
||||||
|
import { SettingsService } from '@core/settings.service';
|
||||||
|
import { ShortcutsService } from '@core/shortcuts.service';
|
||||||
|
import { TransfersService } from '@core/transfers.service';
|
||||||
|
|
||||||
|
/** Optional columns in fixed display order, with their track widths. */
|
||||||
|
const COLUMN_DEFS: { key: ColumnKey; label: string; width: string }[] = [
|
||||||
|
{ key: 'size', label: 'Size', width: '5.5rem' },
|
||||||
|
{ key: 'mtime', label: 'Modified', width: '8.5rem' },
|
||||||
|
{ key: 'kind', label: 'Kind', width: '5.5rem' },
|
||||||
|
{ key: 'rights', label: 'Rights', width: '6.5rem' },
|
||||||
|
{ key: 'owner', label: 'Owner', width: '6rem' },
|
||||||
|
{ key: 'group', label: 'Group', width: '6rem' },
|
||||||
|
];
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-file-panel',
|
||||||
|
imports: [NgIcon],
|
||||||
|
templateUrl: './file-panel.html',
|
||||||
|
host: { class: '@container flex min-w-0 flex-1 flex-col overflow-hidden' },
|
||||||
|
})
|
||||||
|
export class FilePanel {
|
||||||
|
readonly i18n = inject(I18nService);
|
||||||
|
readonly side = input.required<Side>();
|
||||||
|
|
||||||
|
readonly panels = inject(PanelsService);
|
||||||
|
readonly actions = inject(FileActionsService);
|
||||||
|
readonly transfers = inject(TransfersService);
|
||||||
|
readonly connections = inject(ConnectionsService);
|
||||||
|
readonly dialogs = inject(DialogsService);
|
||||||
|
private readonly menu = inject(ContextMenuService);
|
||||||
|
private readonly settings = inject(SettingsService);
|
||||||
|
private readonly shortcuts = inject(ShortcutsService);
|
||||||
|
private readonly rpc = inject(RpcService);
|
||||||
|
readonly panel = computed(() => this.panels.get(this.side()));
|
||||||
|
readonly isActive = computed(() => this.panels.activeSide() === this.side());
|
||||||
|
readonly isRemote = computed(() => this.panel().sessionId() !== 'local');
|
||||||
|
/** Input is ignored while the panel is connecting or a listing is in flight. */
|
||||||
|
readonly blocked = computed(() => this.panel().busy());
|
||||||
|
/** The copy button points toward the other panel. */
|
||||||
|
readonly copyIcon = computed(() => (this.side() === 'left' ? 'mynaArrowRight' : 'mynaArrowLeft'));
|
||||||
|
|
||||||
|
readonly pathDraft = signal('');
|
||||||
|
readonly menuOpen = signal(false);
|
||||||
|
/** Windows drive roots for the source picker; always empty elsewhere. */
|
||||||
|
readonly drives = signal<string[]>([]);
|
||||||
|
/** chmod on Windows local files only flips the read-only bit — hide it. */
|
||||||
|
readonly chmodAvailable = computed(() => !(IS_WINDOWS && !this.isRemote()));
|
||||||
|
|
||||||
|
private readonly listEl = viewChild.required<ElementRef<HTMLDivElement>>('list');
|
||||||
|
private lastClickedIndex = 0;
|
||||||
|
|
||||||
|
// locale-reactive wrappers: reading i18n.locale() inside makes the file-list
|
||||||
|
// cells re-render on a language switch (see i18n.service reactive t())
|
||||||
|
readonly formatSize = (bytes: number): string => this.i18n.size(bytes);
|
||||||
|
readonly formatDate = (ms: number): string => this.i18n.date(ms);
|
||||||
|
readonly formatMode = formatMode;
|
||||||
|
readonly fileKind = (entry: FileEntry): string => {
|
||||||
|
const key = fileKindKey(entry);
|
||||||
|
return key ? this.i18n.t('kinds.' + key) : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The optional columns currently shown, in canonical order. */
|
||||||
|
readonly visibleColumns = computed(() => {
|
||||||
|
const visible = this.settings.columns();
|
||||||
|
return COLUMN_DEFS.filter((c) => visible.includes(c.key));
|
||||||
|
});
|
||||||
|
|
||||||
|
headerContextMenu(event: MouseEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
this.menu.open(event, this.menu.columnItems(this.settings.columns()));
|
||||||
|
}
|
||||||
|
|
||||||
|
pickDrive(drive: string): void {
|
||||||
|
this.menuOpen.set(false);
|
||||||
|
this.activate();
|
||||||
|
void this.panel().load(drive);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (IS_WINDOWS && this.rpc.available) {
|
||||||
|
void this.rpc.request.listDrives({}).then((res) => {
|
||||||
|
if (res.ok) this.drives.set(res.drives);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// keep the editable path bar in sync with the actual cwd
|
||||||
|
effect(() => this.pathDraft.set(this.panel().cwd()));
|
||||||
|
// focus follows the active side (Tab switches panels, dialogs refocus on close)
|
||||||
|
effect(() => {
|
||||||
|
this.panels.focusTick();
|
||||||
|
if (this.isActive()) this.listEl().nativeElement.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
activate(): void {
|
||||||
|
this.panels.setActiveSide(this.side());
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelConnect(): void {
|
||||||
|
this.panels.cancelConnect(this.side());
|
||||||
|
}
|
||||||
|
|
||||||
|
copyToOther(): void {
|
||||||
|
if (this.blocked()) return;
|
||||||
|
this.activate();
|
||||||
|
void this.transfers.copyToOtherPanel(this.side());
|
||||||
|
}
|
||||||
|
|
||||||
|
run(action: 'edit' | 'rename' | 'mkdir' | 'createFile' | 'chmod' | 'delete'): void {
|
||||||
|
if (this.blocked()) return;
|
||||||
|
this.activate();
|
||||||
|
void this.actions[action](this.panel());
|
||||||
|
}
|
||||||
|
|
||||||
|
pickLocal(): void {
|
||||||
|
this.menuOpen.set(false);
|
||||||
|
void this.panels.disconnectPanel(this.side());
|
||||||
|
}
|
||||||
|
|
||||||
|
pickConnection(connection: SavedConnection): void {
|
||||||
|
this.menuOpen.set(false);
|
||||||
|
this.activate();
|
||||||
|
void this.panels.connectPanel(this.side(), connection, connection.name, connection.localDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleMenu(): void {
|
||||||
|
this.menuOpen.set(!this.menuOpen());
|
||||||
|
// re-read ~/.ssh/config on each open so external edits show up
|
||||||
|
if (this.menuOpen()) void this.connections.reloadSshHosts();
|
||||||
|
}
|
||||||
|
|
||||||
|
pickSshHost(host: SshConfigHost): void {
|
||||||
|
this.menuOpen.set(false);
|
||||||
|
this.activate();
|
||||||
|
void this.panels.connectPanel(
|
||||||
|
this.side(),
|
||||||
|
{
|
||||||
|
host: host.hostname,
|
||||||
|
port: host.port,
|
||||||
|
username: host.username,
|
||||||
|
auth: host.identityFile
|
||||||
|
? { kind: 'key', privateKeyPath: host.identityFile }
|
||||||
|
: { kind: 'agent' },
|
||||||
|
initialPath: null,
|
||||||
|
},
|
||||||
|
host.name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
sshHostTitle(host: SshConfigHost): string {
|
||||||
|
if (host.unsupported === 'proxy') return 'Uses ProxyJump/ProxyCommand — not supported';
|
||||||
|
return `${host.username}@${host.hostname}:${host.port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
iconFor(entry: FileEntry): string {
|
||||||
|
return fileGlyph(entry, this.settings.iconColors() === 'multi');
|
||||||
|
}
|
||||||
|
|
||||||
|
iconClass(entry: FileEntry): string {
|
||||||
|
return fileIconClass(entry, this.settings.iconColors() === 'multi');
|
||||||
|
}
|
||||||
|
|
||||||
|
isSelected(entry: FileEntry): boolean {
|
||||||
|
return this.panel().selected().has(entry.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFilter(): void {
|
||||||
|
this.panel().setFilter('');
|
||||||
|
this.listEl().nativeElement.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
filterMatches(): number {
|
||||||
|
return this.panel()
|
||||||
|
.sorted()
|
||||||
|
.filter((e) => e.name !== '..').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
commitPath(): void {
|
||||||
|
if (this.blocked()) return;
|
||||||
|
const value = this.pathDraft().trim();
|
||||||
|
if (value) void this.panel().load(value);
|
||||||
|
this.listEl().nativeElement.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
resetPath(): void {
|
||||||
|
this.pathDraft.set(this.panel().cwd());
|
||||||
|
this.listEl().nativeElement.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
rowClick(event: MouseEvent, index: number): void {
|
||||||
|
if (this.blocked()) return;
|
||||||
|
this.activate();
|
||||||
|
const panel = this.panel();
|
||||||
|
if (event.shiftKey) {
|
||||||
|
panel.selectRange(this.lastClickedIndex, index);
|
||||||
|
} else if (this.shortcuts.isMod(event)) {
|
||||||
|
const entry = panel.sorted()[index];
|
||||||
|
if (entry) panel.toggleSelect(entry.name);
|
||||||
|
} else {
|
||||||
|
panel.clearSelection();
|
||||||
|
}
|
||||||
|
panel.cursor.set(index);
|
||||||
|
this.lastClickedIndex = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
rowContextMenu(event: MouseEvent, index: number): void {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (this.blocked()) return;
|
||||||
|
this.activate();
|
||||||
|
const panel = this.panel();
|
||||||
|
const entry = panel.sorted()[index] ?? null;
|
||||||
|
// standard behavior: keep the selection when right-clicking inside it
|
||||||
|
if (entry && !panel.selected().has(entry.name)) panel.clearSelection();
|
||||||
|
panel.cursor.set(index);
|
||||||
|
this.actions.openContextMenu(event, { side: this.side(), panel, entry });
|
||||||
|
}
|
||||||
|
|
||||||
|
listContextMenu(event: MouseEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
if (this.blocked()) return;
|
||||||
|
this.activate();
|
||||||
|
this.actions.openContextMenu(event, { side: this.side(), panel: this.panel(), entry: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
openAt(index: number): void {
|
||||||
|
if (this.blocked()) return;
|
||||||
|
const entry = this.panel().sorted()[index];
|
||||||
|
if (entry) this.panel().open(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeydown(event: KeyboardEvent): void {
|
||||||
|
if (this.blocked()) return;
|
||||||
|
const panel = this.panel();
|
||||||
|
const key = event.key;
|
||||||
|
|
||||||
|
if (key === 'Tab') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.panels.setActiveSide(this.panels.otherSide(this.side()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.shortcuts.isMod(event) && key.toLowerCase() === 'a') {
|
||||||
|
event.preventDefault();
|
||||||
|
panel.selectAll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// type-to-filter: printable characters build the query (Space only once a
|
||||||
|
// query exists — an empty-filter Space keeps its toggle-select meaning)
|
||||||
|
if (
|
||||||
|
key.length === 1 &&
|
||||||
|
!event.metaKey &&
|
||||||
|
!event.ctrlKey &&
|
||||||
|
!event.altKey &&
|
||||||
|
(key !== ' ' || panel.filter() !== '')
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
panel.setFilter(panel.filter() + key);
|
||||||
|
this.scrollCursorIntoView();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'Backspace' && panel.filter() !== '') {
|
||||||
|
event.preventDefault();
|
||||||
|
panel.setFilter(panel.filter().slice(0, -1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'Escape' && panel.filter() !== '') {
|
||||||
|
event.preventDefault();
|
||||||
|
panel.setFilter('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
panel.moveCursor(1);
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
panel.moveCursor(-1);
|
||||||
|
break;
|
||||||
|
case 'PageDown':
|
||||||
|
panel.moveCursor(20);
|
||||||
|
break;
|
||||||
|
case 'PageUp':
|
||||||
|
panel.moveCursor(-20);
|
||||||
|
break;
|
||||||
|
case 'Home':
|
||||||
|
panel.cursor.set(0);
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
panel.cursor.set(Math.max(0, panel.sorted().length - 1));
|
||||||
|
break;
|
||||||
|
case ' ':
|
||||||
|
{
|
||||||
|
const entry = panel.sorted()[panel.cursor()];
|
||||||
|
if (entry) panel.toggleSelect(entry.name);
|
||||||
|
panel.moveCursor(1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
this.openAt(panel.cursor());
|
||||||
|
return void event.preventDefault();
|
||||||
|
case 'Backspace':
|
||||||
|
panel.up();
|
||||||
|
break;
|
||||||
|
case 'F2':
|
||||||
|
this.actions.rename(panel);
|
||||||
|
break;
|
||||||
|
case 'F4':
|
||||||
|
void this.actions.edit(panel);
|
||||||
|
break;
|
||||||
|
case 'F5':
|
||||||
|
void this.transfers.copyToOtherPanel(this.side());
|
||||||
|
break;
|
||||||
|
case 'F7':
|
||||||
|
this.actions.mkdir(panel);
|
||||||
|
break;
|
||||||
|
case 'F8':
|
||||||
|
case 'Delete':
|
||||||
|
this.actions.delete(panel);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return; // let unhandled keys through
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
this.scrollCursorIntoView();
|
||||||
|
}
|
||||||
|
|
||||||
|
private scrollCursorIntoView(): void {
|
||||||
|
const row = this.listEl().nativeElement.querySelector(
|
||||||
|
`[data-index="${this.panel().cursor()}"]`,
|
||||||
|
);
|
||||||
|
row?.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { App } from './app';
|
||||||
|
import { ConnectionManager } from './connection-manager/connection-manager';
|
||||||
|
import { EditorWindow } from './editor-window/editor-window';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three window roots of the Windows 7 skin, keyed by APP_MODE.
|
||||||
|
*
|
||||||
|
* Every skin exports this same shape, so main.ts selects a tree by skin and
|
||||||
|
* then a root by mode without knowing anything about either skin's internals.
|
||||||
|
* All three roots declare `selector: 'app-root'` — the single <app-root> in
|
||||||
|
* index.html hosts whichever one gets bootstrapped.
|
||||||
|
*/
|
||||||
|
export const SEVEN_ROOTS = {
|
||||||
|
main: App,
|
||||||
|
manager: ConnectionManager,
|
||||||
|
editor: EditorWindow,
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Component, computed, inject } from '@angular/core';
|
||||||
|
import { NgIcon } from '@ng-icons/core';
|
||||||
|
import { I18nService } from '@core/i18n.service';
|
||||||
|
import type { TransferSnapshot } from '@shared/types';
|
||||||
|
import { formatSpeed } from '@core/format';
|
||||||
|
import { PanelsService } from '@core/panels.service';
|
||||||
|
import { SettingsService } from '@core/settings.service';
|
||||||
|
import { TransfersService } from '@core/transfers.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-status-bar',
|
||||||
|
imports: [NgIcon],
|
||||||
|
template: `
|
||||||
|
<div
|
||||||
|
class="flex h-6 items-center justify-between gap-4 border-t border-border bg-chrome px-3 text-[0.6875rem] text-muted-foreground select-none"
|
||||||
|
>
|
||||||
|
<!-- transfers summary; click toggles the detailed list -->
|
||||||
|
<!-- w7-flat: this is a text toggle, not a push button. Without it 7.css
|
||||||
|
paints the full Aero slab and the status line reads as a stray
|
||||||
|
bordered button sitting in the status bar. -->
|
||||||
|
<button
|
||||||
|
(click)="transfers.expanded.set(!transfers.expanded())"
|
||||||
|
class="w7-flat flex min-w-0 items-center gap-1.5 hover:text-foreground"
|
||||||
|
[title]="i18n.t('transfer.showDetails')"
|
||||||
|
>
|
||||||
|
@if (transfers.currentJob(); as job) {
|
||||||
|
<ng-icon name="mynaSpinner" size="0.8rem" class="shrink-0 animate-spin text-primary" />
|
||||||
|
<span class="truncate">
|
||||||
|
{{ i18n.t('transfer.copying', { name: job.name, percent: percent(job), speed: speedSuffix(job) }) }}
|
||||||
|
@if (transfers.queuedCount(); as queued) {
|
||||||
|
· {{ i18n.t('transfer.queued', { count: queued }) }}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
} @else if (transfers.failedJobs().length; as failed) {
|
||||||
|
<ng-icon name="mynaXCircle" size="0.8rem" class="shrink-0 text-destructive" />
|
||||||
|
<span class="text-destructive">
|
||||||
|
{{ i18n.t('transfer.failed', { count: failed }) }}
|
||||||
|
</span>
|
||||||
|
} @else if (transfers.hasJobs()) {
|
||||||
|
<ng-icon name="mynaCheckCircle" size="0.8rem" class="shrink-0 text-success" />
|
||||||
|
<span>{{ i18n.t('transfer.finished') }}</span>
|
||||||
|
} @else {
|
||||||
|
<span>{{ i18n.t('transfer.ready') }}</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- active panel info -->
|
||||||
|
<div class="flex shrink-0 items-center gap-1.5">
|
||||||
|
<ng-icon
|
||||||
|
[name]="activePanel().sessionId() === 'local' ? 'mynaHardDrive' : 'mynaServers'"
|
||||||
|
size="0.8rem"
|
||||||
|
[class.text-primary]="activePanel().sessionId() !== 'local'"
|
||||||
|
/>
|
||||||
|
<span class="max-w-56 truncate">
|
||||||
|
{{ activePanel().sessionId() === 'local' ? i18n.t('common.local') : activePanel().title() }}
|
||||||
|
</span>
|
||||||
|
<span>· {{ i18n.t('transfer.items', { count: activePanel().sorted().length }) }}</span>
|
||||||
|
@if (activePanel().selected().size; as count) {
|
||||||
|
<span>· {{ i18n.t('transfer.selected', { count }) }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class StatusBar {
|
||||||
|
readonly i18n = inject(I18nService);
|
||||||
|
readonly transfers = inject(TransfersService);
|
||||||
|
readonly panels = inject(PanelsService);
|
||||||
|
readonly settings = inject(SettingsService);
|
||||||
|
readonly activePanel = computed(() => this.panels.get(this.panels.activeSide()));
|
||||||
|
|
||||||
|
percent(job: TransferSnapshot): number {
|
||||||
|
if (!job.totalBytes) return 0;
|
||||||
|
return Math.min(100, Math.round((job.doneBytes / job.totalBytes) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
speedSuffix(job: TransferSnapshot): string {
|
||||||
|
const speed = this.transfers.speeds().get(job.id);
|
||||||
|
return speed ? ` · ${formatSpeed(speed, this.settings.speedUnit())}` : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,621 @@
|
|||||||
|
import { Component, ElementRef, computed, effect, inject, signal, viewChild } from '@angular/core';
|
||||||
|
import { NgIcon } from '@ng-icons/core';
|
||||||
|
import { BRANDING } from '@shared/branding';
|
||||||
|
import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
|
||||||
|
import type { AccentTone } from '@shared/types';
|
||||||
|
import { ContextMenuService } from '@core/context-menu.service';
|
||||||
|
import { I18nService } from '@core/i18n.service';
|
||||||
|
import { PanelsService, type Workspace } from '@core/panels.service';
|
||||||
|
import { APP_SKIN, MOD_LABEL } from '@core/platform';
|
||||||
|
import { RpcService } from '@core/rpc.service';
|
||||||
|
import { SettingsService } from '@core/settings.service';
|
||||||
|
import { UpdaterService } from '@core/updater.service';
|
||||||
|
|
||||||
|
const SKINS = ['modern', 'win7'] as const;
|
||||||
|
const THEMES = ['system', 'light', 'dark'] as const;
|
||||||
|
const DARK_TONES = ['dark', 'darker'] as const;
|
||||||
|
// Swatch colors are fixed mid-tones so every dot reads on both light and dark
|
||||||
|
// cards (the live --primary values would vanish against one of them). Names are
|
||||||
|
// translated via options.accentNames.<value>.
|
||||||
|
const ACCENTS: readonly { value: AccentTone; swatch: string }[] = [
|
||||||
|
{ value: 'graphite', swatch: 'oklch(0.552 0.016 285.9)' },
|
||||||
|
{ value: 'blue', swatch: 'oklch(0.65 0.17 255)' },
|
||||||
|
{ value: 'indigo', swatch: 'oklch(0.65 0.19 277)' },
|
||||||
|
{ value: 'purple', swatch: 'oklch(0.65 0.17 305)' },
|
||||||
|
{ value: 'rose', swatch: 'oklch(0.65 0.17 8)' },
|
||||||
|
{ value: 'orange', swatch: 'oklch(0.68 0.17 50)' },
|
||||||
|
{ value: 'green', swatch: 'oklch(0.65 0.15 150)' },
|
||||||
|
{ value: 'teal', swatch: 'oklch(0.65 0.12 185)' },
|
||||||
|
];
|
||||||
|
const SPEED_UNITS = [
|
||||||
|
{ value: 'MBps', label: 'MB/s' },
|
||||||
|
{ value: 'Mbps', label: 'Mbps' },
|
||||||
|
] as const;
|
||||||
|
const ICON_COLOR_MODES = ['plain', 'multi'] as const;
|
||||||
|
const TRANSFER_MODES = ['single', 'multi'] as const;
|
||||||
|
const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-tab-bar',
|
||||||
|
imports: [NgIcon],
|
||||||
|
template: `
|
||||||
|
<!-- The workspace strip, directly under the title bar. Draggable so the
|
||||||
|
empty stretch between the tabs and the right-hand tools still moves the
|
||||||
|
frameless window. ONE class attribute: a second one is silently dropped
|
||||||
|
by the template parser, which is exactly how this row lost its height. -->
|
||||||
|
<div
|
||||||
|
class="w7-toolbar relative flex h-9 items-center gap-0.5 border-b border-border bg-chrome pt-1 pr-1.5 pl-1.5 electrobun-webkit-app-region-drag"
|
||||||
|
>
|
||||||
|
<!-- 7.css's real tab component. The contract is exact: menu[role=tablist]
|
||||||
|
for the strip and a button[aria-selected] per tab — 7.css then draws
|
||||||
|
the joined inactive tabs and raises the active one (white fill, no
|
||||||
|
bottom border, pulled 2px down over the row's border so it reads as
|
||||||
|
joined to the content). Everything the hand-rolled version painted
|
||||||
|
(bg-accent, the absolutely-positioned bg-primary underline) is gone:
|
||||||
|
it was a lookalike competing with the framework.
|
||||||
|
NOTE the selector is "menu[role=tablist] button" — a DESCENDANT, so
|
||||||
|
any button in here becomes a tab. The "+" and the right-hand tools
|
||||||
|
deliberately live OUTSIDE this element.
|
||||||
|
(No backticks in this template: it is a TS template literal.) -->
|
||||||
|
<menu
|
||||||
|
#tabScroll
|
||||||
|
role="tablist"
|
||||||
|
class="flex min-w-0 items-center overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||||
|
>
|
||||||
|
@for (ws of panels.workspaces(); track ws.id; let i = $index) {
|
||||||
|
<!-- No ✕ inside the tab: real Win7 tab controls had none, and a button
|
||||||
|
nested in a button is invalid HTML. Closing is Ctrl/Cmd+W or
|
||||||
|
middle-click (auxclick), so a pointer path survives. -->
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
[attr.aria-selected]="ws.id === panels.activeWorkspaceId()"
|
||||||
|
[attr.data-tab-id]="ws.id"
|
||||||
|
(click)="panels.setActiveWorkspace(ws.id)"
|
||||||
|
(auxclick)="onAuxClick($event, ws.id)"
|
||||||
|
(contextmenu)="onTabContextMenu($event, ws, i)"
|
||||||
|
class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5 electrobun-webkit-app-region-no-drag"
|
||||||
|
[class.opacity-50]="draggedId() === ws.id"
|
||||||
|
[style.box-shadow]="dropIndicatorShadow(i)"
|
||||||
|
[title]="panels.workspaceName(ws)"
|
||||||
|
draggable="true"
|
||||||
|
(dragstart)="onDragStart($event, ws.id)"
|
||||||
|
(dragover)="onDragOver($event, i)"
|
||||||
|
(drop)="onDrop($event, i)"
|
||||||
|
(dragend)="onDragEnd()"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
[name]="panels.workspaceIsRemote(ws) ? 'mynaServers' : 'mynaHardDrive'"
|
||||||
|
size="0.85rem"
|
||||||
|
class="shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{ panels.workspaceName(ws) }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</menu>
|
||||||
|
<button
|
||||||
|
(click)="panels.newWorkspace()"
|
||||||
|
[title]="i18n.t('tabBar.newTab', { mod: modLabel })"
|
||||||
|
class="ml-0.5 grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaPlus" size=".8rem" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
|
<!-- auto-update tool: one accent pill that announces the update, becomes its
|
||||||
|
own progress bar while downloading, then turns into "Restart to apply" -->
|
||||||
|
@if (updater.state() !== 'idle' && updater.state() !== 'error') {
|
||||||
|
<button
|
||||||
|
(click)="onUpdateClick()"
|
||||||
|
[disabled]="updater.state() === 'downloading'"
|
||||||
|
[title]="i18n.t('tabBar.updateTooltip', { version: updater.version() })"
|
||||||
|
class="relative mr-1 grid h-6 place-items-center overflow-hidden rounded-md px-2.5 text-[0.6875rem] font-medium bg-primary text-primary-foreground electrobun-webkit-app-region-no-drag hover:opacity-90 disabled:cursor-default"
|
||||||
|
>
|
||||||
|
@if (updater.state() === 'downloading') {
|
||||||
|
<span
|
||||||
|
class="absolute inset-y-0 left-0 bg-primary-foreground/25 transition-[width] duration-200"
|
||||||
|
[style.width.%]="updater.progress()"
|
||||||
|
></span>
|
||||||
|
}
|
||||||
|
<span class="relative flex items-center gap-1.5">
|
||||||
|
@switch (updater.state()) {
|
||||||
|
@case ('available') {
|
||||||
|
<ng-icon name="mynaDownload" size=".8rem" />{{ i18n.t('tabBar.update') }}
|
||||||
|
}
|
||||||
|
@case ('downloading') {
|
||||||
|
<ng-icon name="mynaSpinner" size=".8rem" class="animate-spin" />{{ i18n.t('tabBar.updating') }} {{ updater.progress() }}%
|
||||||
|
}
|
||||||
|
@case ('ready') {
|
||||||
|
<ng-icon name="mynaRefresh" size=".8rem" />{{ i18n.t('tabBar.restartToUpdate') }}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- connection manager (own native window) -->
|
||||||
|
<button
|
||||||
|
(click)="openManager()"
|
||||||
|
[title]="i18n.t('tabBar.manageConnections')"
|
||||||
|
class="grid h-6 px-2 py-1 place-items-center rounded-md text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<!-- <ng-icon name="mynaCog" size="0.9rem" /> -->
|
||||||
|
{{ i18n.t('tabBar.connections') }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- global options -->
|
||||||
|
<button
|
||||||
|
(click)="settingsOpen.set(!settingsOpen())"
|
||||||
|
[title]="i18n.t('tabBar.options')"
|
||||||
|
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaDots" size="1.5rem" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (settingsOpen()) {
|
||||||
|
<div class="fixed inset-0 z-20" (click)="settingsOpen.set(false)"></div>
|
||||||
|
<div
|
||||||
|
class="absolute top-full right-1.5 z-30 mt-1 w-70 rounded-lg border border-border bg-card p-3 text-card-foreground shadow-xl electrobun-webkit-app-region-no-drag"
|
||||||
|
>
|
||||||
|
<h3 class="text-xs font-semibold">{{ i18n.t('options.title') }}</h3>
|
||||||
|
|
||||||
|
<div class="mt-3 flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.language') }}</span>
|
||||||
|
<select
|
||||||
|
(change)="settings.setLocale($any($event.target).value)"
|
||||||
|
class="h-7 rounded-md border border-input bg-background px-1.5 text-[0.6875rem] outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="system" [selected]="settings.locale() === 'system'">
|
||||||
|
{{ i18n.t('options.system') }}
|
||||||
|
</option>
|
||||||
|
@for (loc of localeOptions; track loc) {
|
||||||
|
<option [value]="loc" [selected]="settings.locale() === loc">
|
||||||
|
{{ localeLabels[loc] }}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Skin: a LAUNCH-TIME choice. It swaps the whole component tree
|
||||||
|
(ui/src/app/skins/) and the native frame, and neither can change
|
||||||
|
on a live window — the tree is bootstrapped from the preload-baked
|
||||||
|
__APP_SKIN and titleBarStyle/transparent are BrowserWindow
|
||||||
|
constructor options. Clicking only saves; the running UI is left
|
||||||
|
alone. Highlight the SAVED value so the click registers, and show
|
||||||
|
the pending hint whenever it differs from the booted skin. -->
|
||||||
|
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.skin.label') }}</span>
|
||||||
|
<div class="flex rounded-md border border-border p-0.5">
|
||||||
|
@for (s of skins; track s) {
|
||||||
|
<button
|
||||||
|
(click)="settings.setSkin(s)"
|
||||||
|
[title]="i18n.t('options.skin.restartHint')"
|
||||||
|
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||||
|
[class.bg-accent]="settings.skin() === s"
|
||||||
|
[class.text-foreground]="settings.skin() === s"
|
||||||
|
[class.text-muted-foreground]="settings.skin() !== s"
|
||||||
|
>
|
||||||
|
{{ i18n.t('options.skin.' + s) }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (skinPending()) {
|
||||||
|
<p class="mt-1 text-right text-[0.625rem] text-muted-foreground">
|
||||||
|
{{ i18n.t('options.skin.restartHint') }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.theme.label') }}</span>
|
||||||
|
<div class="flex rounded-md border border-border p-0.5">
|
||||||
|
@for (theme of themes; track theme) {
|
||||||
|
<button
|
||||||
|
(click)="settings.setTheme(theme)"
|
||||||
|
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||||
|
[class.bg-accent]="settings.theme() === theme"
|
||||||
|
[class.text-foreground]="settings.theme() === theme"
|
||||||
|
[class.text-muted-foreground]="settings.theme() !== theme"
|
||||||
|
>
|
||||||
|
{{ i18n.t('options.theme.' + theme) }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (settings.effectiveDark()) {
|
||||||
|
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.tone.label') }}</span>
|
||||||
|
<div class="flex rounded-md border border-border p-0.5">
|
||||||
|
@for (tone of darkTones; track tone) {
|
||||||
|
<button
|
||||||
|
(click)="settings.setDarkTone(tone)"
|
||||||
|
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||||
|
[class.bg-accent]="settings.darkTone() === tone"
|
||||||
|
[class.text-foreground]="settings.darkTone() === tone"
|
||||||
|
[class.text-muted-foreground]="settings.darkTone() !== tone"
|
||||||
|
>
|
||||||
|
{{ i18n.t('options.tone.' + tone) }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.accent.label') }}</span>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
@for (tone of accents; track tone.value) {
|
||||||
|
<button
|
||||||
|
(click)="settings.setAccent(tone.value)"
|
||||||
|
[title]="i18n.t('options.accentNames.' + tone.value)"
|
||||||
|
class="size-3 rounded-full border border-foreground/15"
|
||||||
|
[style.background-color]="tone.swatch"
|
||||||
|
[class.ring-2]="settings.accent() === tone.value"
|
||||||
|
[class.ring-foreground]="settings.accent() === tone.value"
|
||||||
|
[class.ring-offset-1]="settings.accent() === tone.value"
|
||||||
|
[class.ring-offset-card]="settings.accent() === tone.value"
|
||||||
|
></button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.fileIcons.label') }}</span>
|
||||||
|
<div class="flex rounded-md border border-border p-0.5">
|
||||||
|
@for (mode of iconColorModes; track mode) {
|
||||||
|
<button
|
||||||
|
(click)="settings.setIconColors(mode)"
|
||||||
|
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||||
|
[class.bg-accent]="settings.iconColors() === mode"
|
||||||
|
[class.text-foreground]="settings.iconColors() === mode"
|
||||||
|
[class.text-muted-foreground]="settings.iconColors() !== mode"
|
||||||
|
>
|
||||||
|
{{ i18n.t('options.fileIcons.' + mode) }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Interface scale: a continuous stepper, 50%..400% in 10-point
|
||||||
|
steps. One lever moves the whole UI (root font-size; everything
|
||||||
|
is rem), which is also why this popover itself grows with it. -->
|
||||||
|
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.interface.label') }}</span>
|
||||||
|
<div class="flex items-center rounded-md border border-border p-0.5">
|
||||||
|
<button
|
||||||
|
(click)="settings.setUiScale(settings.uiScale() - 0.1)"
|
||||||
|
[disabled]="settings.uiScale() <= 0.5"
|
||||||
|
[title]="i18n.t('options.interface.smaller')"
|
||||||
|
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="settings.setUiScale(1)"
|
||||||
|
[title]="i18n.t('options.interface.reset')"
|
||||||
|
class="w-11 rounded px-1 py-0.5 text-center text-[0.6875rem] tabular-nums hover:bg-accent"
|
||||||
|
>
|
||||||
|
{{ scalePercent() }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="settings.setUiScale(settings.uiScale() + 0.1)"
|
||||||
|
[disabled]="settings.uiScale() >= 4"
|
||||||
|
[title]="i18n.t('options.interface.larger')"
|
||||||
|
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.speedUnit.label') }}</span>
|
||||||
|
<div class="flex rounded-md border border-border p-0.5">
|
||||||
|
@for (unit of speedUnits; track unit.value) {
|
||||||
|
<button
|
||||||
|
(click)="settings.setSpeedUnit(unit.value)"
|
||||||
|
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||||
|
[class.bg-accent]="settings.speedUnit() === unit.value"
|
||||||
|
[class.text-foreground]="settings.speedUnit() === unit.value"
|
||||||
|
[class.text-muted-foreground]="settings.speedUnit() !== unit.value"
|
||||||
|
>
|
||||||
|
{{ unit.label }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.transfers.label') }}</span>
|
||||||
|
<div class="flex rounded-md border border-border p-0.5">
|
||||||
|
@for (mode of transferModes; track mode) {
|
||||||
|
<button
|
||||||
|
(click)="settings.setTransferMode(mode)"
|
||||||
|
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||||
|
[class.bg-accent]="settings.transferMode() === mode"
|
||||||
|
[class.text-foreground]="settings.transferMode() === mode"
|
||||||
|
[class.text-muted-foreground]="settings.transferMode() !== mode"
|
||||||
|
>
|
||||||
|
{{ i18n.t('options.transfers.' + mode) }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (settings.transferMode() === 'multi') {
|
||||||
|
<div class="mt-2.5 flex items-center justify-between gap-2 pl-3">
|
||||||
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.atOnce') }}</span>
|
||||||
|
<div class="flex rounded-md border border-border p-0.5">
|
||||||
|
@for (count of concurrencyChoices; track count) {
|
||||||
|
<button
|
||||||
|
(click)="settings.setTransferConcurrency(count)"
|
||||||
|
class="rounded px-2 py-0.5 text-[0.6875rem] tabular-nums"
|
||||||
|
[class.bg-accent]="settings.transferConcurrency() === count"
|
||||||
|
[class.text-foreground]="settings.transferConcurrency() === count"
|
||||||
|
[class.text-muted-foreground]="settings.transferConcurrency() !== count"
|
||||||
|
>
|
||||||
|
{{ count }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.editor.label') }}</span>
|
||||||
|
<div class="flex rounded-md border border-border p-0.5">
|
||||||
|
<button
|
||||||
|
(click)="settings.setEditor('builtin')"
|
||||||
|
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||||
|
[class.bg-accent]="settings.editor() === 'builtin'"
|
||||||
|
[class.text-foreground]="settings.editor() === 'builtin'"
|
||||||
|
[class.text-muted-foreground]="settings.editor() !== 'builtin'"
|
||||||
|
>
|
||||||
|
{{ i18n.t('options.editor.builtin') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="settings.setEditor('custom')"
|
||||||
|
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||||
|
[class.bg-accent]="settings.editor() === 'custom'"
|
||||||
|
[class.text-foreground]="settings.editor() === 'custom'"
|
||||||
|
[class.text-muted-foreground]="settings.editor() !== 'custom'"
|
||||||
|
>
|
||||||
|
{{ i18n.t('options.editor.custom') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (settings.editor() === 'custom') {
|
||||||
|
<div class="mt-2.5 flex items-center justify-between gap-2 pl-3">
|
||||||
|
<span class="min-w-0 truncate text-xs text-muted-foreground" [title]="settings.editorApp()">
|
||||||
|
{{ editorAppName() }}
|
||||||
|
</span>
|
||||||
|
<div class="flex shrink-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
(click)="pickEditorApp()"
|
||||||
|
class="rounded-md border border-border px-2 py-0.5 text-[0.6875rem] hover:bg-accent"
|
||||||
|
>
|
||||||
|
{{ i18n.t('common.choose') }}
|
||||||
|
</button>
|
||||||
|
@if (settings.editorApp()) {
|
||||||
|
<button
|
||||||
|
(click)="settings.setEditorApp('')"
|
||||||
|
[title]="i18n.t('options.resetDefault')"
|
||||||
|
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaX" size="0.7rem" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.finishedTransfers.label') }}</span>
|
||||||
|
<div class="flex rounded-md border border-border p-0.5">
|
||||||
|
<button
|
||||||
|
(click)="settings.setAutoClearCompleted(true)"
|
||||||
|
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||||
|
[class.bg-accent]="settings.autoClearCompleted()"
|
||||||
|
[class.text-foreground]="settings.autoClearCompleted()"
|
||||||
|
[class.text-muted-foreground]="!settings.autoClearCompleted()"
|
||||||
|
>
|
||||||
|
{{ i18n.t('options.finishedTransfers.autoClear') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="settings.setAutoClearCompleted(false)"
|
||||||
|
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||||
|
[class.bg-accent]="!settings.autoClearCompleted()"
|
||||||
|
[class.text-foreground]="!settings.autoClearCompleted()"
|
||||||
|
[class.text-muted-foreground]="settings.autoClearCompleted()"
|
||||||
|
>
|
||||||
|
{{ i18n.t('options.finishedTransfers.keep') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto-refresh is always on for now. To expose a toggle, uncomment
|
||||||
|
the breadcrumbs in types.ts + settings.service, then this row:
|
||||||
|
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs text-muted-foreground">Auto-refresh</span>
|
||||||
|
<div class="flex rounded-md border border-border p-0.5">
|
||||||
|
<button (click)="settings.setAutoRefresh(true)" class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||||
|
[class.bg-accent]="settings.autoRefresh()"
|
||||||
|
[class.text-foreground]="settings.autoRefresh()"
|
||||||
|
[class.text-muted-foreground]="!settings.autoRefresh()">On</button>
|
||||||
|
<button (click)="settings.setAutoRefresh(false)" class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||||
|
[class.bg-accent]="!settings.autoRefresh()"
|
||||||
|
[class.text-foreground]="!settings.autoRefresh()"
|
||||||
|
[class.text-muted-foreground]="settings.autoRefresh()">Off</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!-- about footer -->
|
||||||
|
<button
|
||||||
|
(click)="showAbout()"
|
||||||
|
class="mt-3 w-full border-t border-border pt-2 text-center text-[0.6875rem] text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{{ appName }} · {{ buildLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class TabBar {
|
||||||
|
readonly panels = inject(PanelsService);
|
||||||
|
readonly settings = inject(SettingsService);
|
||||||
|
readonly i18n = inject(I18nService);
|
||||||
|
readonly updater = inject(UpdaterService);
|
||||||
|
private readonly contextMenu = inject(ContextMenuService);
|
||||||
|
private readonly rpc = inject(RpcService);
|
||||||
|
readonly settingsOpen = signal(false);
|
||||||
|
readonly localeOptions = SUPPORTED;
|
||||||
|
readonly localeLabels = LOCALE_LABELS;
|
||||||
|
readonly modLabel = MOD_LABEL;
|
||||||
|
readonly appName = BRANDING.appName;
|
||||||
|
readonly buildLabel = BRANDING.buildLabel;
|
||||||
|
/** What the window was BUILT with, not the current setting — the frameless
|
||||||
|
* chrome only changes on reopen, so the hint compares against this. */
|
||||||
|
readonly skins = SKINS;
|
||||||
|
/**
|
||||||
|
* The saved skin differs from the one this window actually booted, so the
|
||||||
|
* choice is waiting on a restart. APP_SKIN is the preload-baked value that
|
||||||
|
* picked the running component tree; settings.skin() is merely persisted.
|
||||||
|
*/
|
||||||
|
readonly skinPending = computed(() => this.settings.skin() !== APP_SKIN);
|
||||||
|
readonly themes = THEMES;
|
||||||
|
readonly darkTones = DARK_TONES;
|
||||||
|
readonly accents = ACCENTS;
|
||||||
|
readonly iconColorModes = ICON_COLOR_MODES;
|
||||||
|
readonly speedUnits = SPEED_UNITS;
|
||||||
|
/** "100%" for the stepper readout; the value itself lives in settings. */
|
||||||
|
readonly scalePercent = computed(() => `${Math.round(this.settings.uiScale() * 100)}%`);
|
||||||
|
readonly transferModes = TRANSFER_MODES;
|
||||||
|
readonly concurrencyChoices = CONCURRENCY_CHOICES;
|
||||||
|
|
||||||
|
// drag & drop reordering
|
||||||
|
readonly draggedId = signal<number | null>(null);
|
||||||
|
readonly dropTarget = signal<{ index: number; before: boolean } | null>(null);
|
||||||
|
|
||||||
|
private readonly tabScroll = viewChild.required<ElementRef<HTMLMenuElement>>('tabScroll');
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// keep the active tab visible when the strip overflows
|
||||||
|
effect(() => {
|
||||||
|
const id = this.panels.activeWorkspaceId();
|
||||||
|
this.tabScroll()
|
||||||
|
.nativeElement.querySelector(`[data-tab-id="${id}"]`)
|
||||||
|
?.scrollIntoView({ inline: 'nearest', block: 'nearest' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close(event: MouseEvent, id: number): void {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.panels.closeWorkspace(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middle-click closes the tab — the pointer path that replaced the per-tab ✕.
|
||||||
|
* `auxclick` is the non-primary-button click event; guard on button 1 so the
|
||||||
|
* right button (which also fires auxclick) never closes anything. Refuses the
|
||||||
|
* last tab, matching closeWorkspace's own floor.
|
||||||
|
*/
|
||||||
|
onAuxClick(event: MouseEvent, id: number): void {
|
||||||
|
if (event.button !== 1) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (this.panels.workspaces().length > 1) this.close(event, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Right-click a tab. The menu targets THIS tab, not the active one — its
|
||||||
|
* actions all carry the id — so right-clicking a background tab does not
|
||||||
|
* yank focus to it first, the way Explorer and every browser behave.
|
||||||
|
*/
|
||||||
|
onTabContextMenu(event: MouseEvent, workspace: Workspace, index: number): void {
|
||||||
|
event.preventDefault();
|
||||||
|
this.contextMenu.open(
|
||||||
|
event,
|
||||||
|
this.contextMenu.tabItems({
|
||||||
|
id: workspace.id,
|
||||||
|
index,
|
||||||
|
tabCount: this.panels.workspaces().length,
|
||||||
|
isRemote: this.panels.workspaceIsRemote(workspace),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */
|
||||||
|
editorAppName(): string {
|
||||||
|
const path = this.settings.editorApp();
|
||||||
|
if (!path) return this.i18n.t('options.systemDefault');
|
||||||
|
const base = path.split('/').filter(Boolean).pop() ?? path;
|
||||||
|
return base.replace(/\.app$/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
openManager(): void {
|
||||||
|
void this.rpc.request.openConnectionManager({});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update pill: first click downloads, second click (when ready) applies + relaunches. */
|
||||||
|
onUpdateClick(): void {
|
||||||
|
const state = this.updater.state();
|
||||||
|
if (state === 'available') void this.updater.download();
|
||||||
|
else if (state === 'ready') this.updater.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
showAbout(): void {
|
||||||
|
void this.rpc.request.showAbout({});
|
||||||
|
}
|
||||||
|
|
||||||
|
async pickEditorApp(): Promise<void> {
|
||||||
|
const res = await this.rpc.request.pickEditorApp({});
|
||||||
|
if (res.ok && res.path) this.settings.setEditorApp(res.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insertion line at the drop position (inset shadow: no layout shift). */
|
||||||
|
dropIndicatorShadow(index: number): string | null {
|
||||||
|
const target = this.dropTarget();
|
||||||
|
if (!target || target.index !== index || this.draggedId() === null) return null;
|
||||||
|
return target.before
|
||||||
|
? 'inset 2px 0 0 var(--primary)'
|
||||||
|
: 'inset -2px 0 0 var(--primary)';
|
||||||
|
}
|
||||||
|
|
||||||
|
onDragStart(event: DragEvent, id: number): void {
|
||||||
|
this.draggedId.set(id);
|
||||||
|
// WebKit refuses to start a drag without data
|
||||||
|
event.dataTransfer?.setData('text/plain', String(id));
|
||||||
|
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
||||||
|
}
|
||||||
|
|
||||||
|
onDragOver(event: DragEvent, index: number): void {
|
||||||
|
if (this.draggedId() === null) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
||||||
|
const el = event.currentTarget as HTMLElement;
|
||||||
|
const before = event.clientX < el.getBoundingClientRect().left + el.offsetWidth / 2;
|
||||||
|
this.dropTarget.set({ index, before });
|
||||||
|
}
|
||||||
|
|
||||||
|
onDrop(event: DragEvent, index: number): void {
|
||||||
|
event.preventDefault();
|
||||||
|
const id = this.draggedId();
|
||||||
|
const target = this.dropTarget();
|
||||||
|
this.onDragEnd();
|
||||||
|
if (id === null || !target) return;
|
||||||
|
const list = this.panels.workspaces();
|
||||||
|
const fromIndex = list.findIndex((w) => w.id === id);
|
||||||
|
if (fromIndex < 0) return;
|
||||||
|
let toIndex = target.before ? index : index + 1;
|
||||||
|
if (fromIndex < toIndex) toIndex--; // account for removal before insert
|
||||||
|
if (toIndex !== fromIndex) this.panels.moveWorkspace(id, toIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDragEnd(): void {
|
||||||
|
this.draggedId.set(null);
|
||||||
|
this.dropTarget.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,23 @@
|
|||||||
import { Component, inject, input, output, signal } from '@angular/core';
|
import { Component, inject, input, output, signal } from '@angular/core';
|
||||||
import { IS_WIN7_CHROME } from '../platform';
|
import { RpcService } from '@core/rpc.service';
|
||||||
import { RpcService } from '../rpc.service';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The win7 skin's Aero title bar. Rendered by all three window kinds, and only
|
* The Aero title bar. Rendered by all three window kinds. The native frame is
|
||||||
* when `IS_WIN7_CHROME` — under that skin the native frame is gone
|
* gone on every platform (titleBarStyle 'hidden'), so this row IS the window's
|
||||||
* (titleBarStyle 'hidden'), so this row IS the window's chrome.
|
* chrome.
|
||||||
*
|
*
|
||||||
* The markup is 7.css's contract, not ours: `.title-bar` > `.title-bar-text` +
|
* The markup is 7.css's contract, not ours: `.title-bar` > `.title-bar-text` +
|
||||||
* `.title-bar-controls` > buttons keyed by `aria-label` (Minimize / Maximize /
|
* `.title-bar-controls` > buttons keyed by `aria-label` (Minimize / Maximize /
|
||||||
* Restore / Close each get their own glyph). Nothing here is styled with
|
* Restore / Close each get their own glyph). Nothing here is styled with
|
||||||
* Tailwind on purpose — the whole row comes from @layer skin.
|
* Tailwind on purpose — the whole row comes from @layer skin.
|
||||||
*
|
*
|
||||||
|
* `.title-bar` lives on the HOST element, not on an inner div. 7.css couples
|
||||||
|
* the glass treatment with a DIRECT-child selector — `.window.glass >
|
||||||
|
* .title-bar` — and an inner div puts <app-title-bar> in between, so the rule
|
||||||
|
* never matched: the title bar rendered as the opaque fallback with its own
|
||||||
|
* border, visibly disconnected from the frame, instead of the translucent
|
||||||
|
* streaked glass that fuses with it.
|
||||||
|
*
|
||||||
* Dragging rides on electrobun's preload drag-region classes. Two things
|
* Dragging rides on electrobun's preload drag-region classes. Two things
|
||||||
* electrobun does NOT provide and this component implements:
|
* electrobun does NOT provide and this component implements:
|
||||||
* - double-click to maximize (it only wires mousedown/mouseup)
|
* - double-click to maximize (it only wires mousedown/mouseup)
|
||||||
@@ -20,22 +26,26 @@ import { RpcService } from '../rpc.service';
|
|||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-title-bar',
|
selector: 'app-title-bar',
|
||||||
|
host: {
|
||||||
|
class: 'title-bar electrobun-webkit-app-region-drag',
|
||||||
|
// Published so the FRAME can react: a maximized Win7 window has square
|
||||||
|
// corners. The window root reads this off its own child via :has() rather
|
||||||
|
// than subscribing to windowState a second time — see styles.css.
|
||||||
|
'[class.is-maximized]': 'maximized()',
|
||||||
|
'(dblclick)': 'toggle()',
|
||||||
|
},
|
||||||
template: `
|
template: `
|
||||||
@if (enabled) {
|
<div class="title-bar-text">{{ title() }}</div>
|
||||||
<div class="title-bar electrobun-webkit-app-region-drag" (dblclick)="toggle()">
|
<div class="title-bar-controls electrobun-webkit-app-region-no-drag">
|
||||||
<div class="title-bar-text">{{ title() }}</div>
|
@if (resizable()) {
|
||||||
<div class="title-bar-controls electrobun-webkit-app-region-no-drag">
|
<button aria-label="Minimize" (click)="minimize()"></button>
|
||||||
@if (resizable()) {
|
<button
|
||||||
<button aria-label="Minimize" (click)="minimize()"></button>
|
[attr.aria-label]="maximized() ? 'Restore' : 'Maximize'"
|
||||||
<button
|
(click)="toggle()"
|
||||||
[attr.aria-label]="maximized() ? 'Restore' : 'Maximize'"
|
></button>
|
||||||
(click)="toggle()"
|
}
|
||||||
></button>
|
<button aria-label="Close" (click)="closeRequested.emit()"></button>
|
||||||
}
|
</div>
|
||||||
<button aria-label="Close" (click)="closeRequested.emit()"></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class TitleBar {
|
export class TitleBar {
|
||||||
@@ -45,13 +55,12 @@ export class TitleBar {
|
|||||||
/** Hosts decide what closing means (quit / dirty prompt / managerClose). */
|
/** Hosts decide what closing means (quit / dirty prompt / managerClose). */
|
||||||
readonly closeRequested = output<void>();
|
readonly closeRequested = output<void>();
|
||||||
|
|
||||||
protected readonly enabled = IS_WIN7_CHROME;
|
|
||||||
protected readonly maximized = signal(false);
|
protected readonly maximized = signal(false);
|
||||||
|
|
||||||
private readonly rpc = inject(RpcService);
|
private readonly rpc = inject(RpcService);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (!this.enabled || !this.rpc.available) return;
|
if (!this.rpc.available) return;
|
||||||
// No maximize event exists, so seed from a one-shot read and then follow
|
// No maximize event exists, so seed from a one-shot read and then follow
|
||||||
// the resize-derived push (which also covers Aero Snap and Win+Up).
|
// the resize-derived push (which also covers Aero Snap and Win+Up).
|
||||||
void this.rpc.request.windowIsMaximized({}).then((res) => {
|
void this.rpc.request.windowIsMaximized({}).then((res) => {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import { ToastService } from '../toast.service';
|
import { ToastService } from '@core/toast.service';
|
||||||
|
|
||||||
/** Toast stack overlay; shared by the main window and editor windows. */
|
/** Toast stack overlay; shared by the main window and editor windows. */
|
||||||
@Component({
|
@Component({
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
@if (transfers.expanded() && transfers.hasJobs()) {
|
@if (transfers.expanded() && transfers.hasJobs()) {
|
||||||
<div class="flex items-center justify-between border-t border-border bg-chrome px-3 py-1 text-[11px] text-muted-foreground">
|
<div class="flex items-center justify-between border-t border-border bg-chrome px-3 py-1 text-[0.6875rem] text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
{{ i18n.t('transfer.summary', { active: transfers.activeJobs().length, finished: transfers.finishedJobs().length }) }}
|
{{ i18n.t('transfer.summary', { active: transfers.activeJobs().length, finished: transfers.finishedJobs().length }) }}
|
||||||
</span>
|
</span>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<ng-icon [name]="stateIcon(job)" size="0.95rem" [class]="stateClass(job)" />
|
<ng-icon [name]="stateIcon(job)" size="0.95rem" [class]="stateClass(job)" />
|
||||||
<span class="w-40 shrink-0 truncate font-medium" [title]="job.srcPath">{{ job.name }}</span>
|
<span class="w-40 shrink-0 truncate font-medium" [title]="job.srcPath">{{ job.name }}</span>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="truncate text-[11px] text-muted-foreground">{{ progressLabel(job) }}</div>
|
<div class="truncate text-[0.6875rem] text-muted-foreground">{{ progressLabel(job) }}</div>
|
||||||
@if (isActive(job)) {
|
@if (isActive(job)) {
|
||||||
<!-- role+aria are 7.css's hook for the Win7 progress bar
|
<!-- role+aria are 7.css's hook for the Win7 progress bar
|
||||||
([role=progressbar] > div); under the modern skin they are
|
([role=progressbar] > div); under the modern skin they are
|
||||||
+4
-4
@@ -1,10 +1,10 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import type { TransferSnapshot } from '@shared/types';
|
import type { TransferSnapshot } from '@shared/types';
|
||||||
import { formatEta } from '../format';
|
import { formatEta } from '@core/format';
|
||||||
import { I18nService } from '../i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import { SettingsService } from '../settings.service';
|
import { SettingsService } from '@core/settings.service';
|
||||||
import { TransfersService } from '../transfers.service';
|
import { TransfersService } from '@core/transfers.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-transfer-bar',
|
selector: 'app-transfer-bar',
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Frame arithmetic for the synthesized window resize. Deliberately a separate,
|
||||||
|
* DEPENDENCY-FREE module: `scripts/resize-math-probe.ts` imports it directly to
|
||||||
|
* assert all eight edges without booting Angular or opening a window, which is
|
||||||
|
* the only automated coverage this feature can have (pointer drags cannot be
|
||||||
|
* synthesized headlessly on this machine).
|
||||||
|
*
|
||||||
|
* Part of the resize stopgap — see the removal checklist in window-resize.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Frame {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compass point of the grabbed edge or corner. */
|
||||||
|
export type ResizeEdge = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
|
||||||
|
|
||||||
|
/** Every handle, in DOM order: edges first so the corners overlay them. */
|
||||||
|
export const RESIZE_EDGES: readonly ResizeEdge[] = ['n', 's', 'w', 'e', 'nw', 'ne', 'sw', 'se'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Floor for the synthesized resize. electrobun has no min-size window option,
|
||||||
|
* so this is enforced here or not at all. Chosen to keep both panels usable
|
||||||
|
* with their toolbars intact; the interface scale can still make the CONTENT
|
||||||
|
* too big for it, which is a separate (pre-existing) problem.
|
||||||
|
*/
|
||||||
|
export const MIN_WIDTH = 640;
|
||||||
|
export const MIN_HEIGHT = 420;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The frame a drag should produce.
|
||||||
|
*
|
||||||
|
* `dx`/`dy` are SCREEN-space deltas from where the drag began (see the note on
|
||||||
|
* screenX in window-resize.ts). North and west edges move the window's origin
|
||||||
|
* as well as its size — that is the whole reason this returns a full frame
|
||||||
|
* rather than a size, and why `setSize` alone would be wrong.
|
||||||
|
*
|
||||||
|
* The clamp holds the ANCHORED edge still: dragging the west edge east past the
|
||||||
|
* minimum must park `x` where the window is exactly MIN_WIDTH wide, not let it
|
||||||
|
* keep travelling while the width sits pinned — that would walk the window
|
||||||
|
* across the screen while it appears frozen.
|
||||||
|
*
|
||||||
|
* Results are rounded: fractional frames make the native setter jitter by a
|
||||||
|
* pixel between ticks.
|
||||||
|
*/
|
||||||
|
export function resizeFrame(
|
||||||
|
edge: ResizeEdge,
|
||||||
|
start: Frame,
|
||||||
|
dx: number,
|
||||||
|
dy: number,
|
||||||
|
min: { width: number; height: number } = { width: MIN_WIDTH, height: MIN_HEIGHT },
|
||||||
|
): Frame {
|
||||||
|
let { x, y, width, height } = start;
|
||||||
|
|
||||||
|
if (edge.includes('e')) width = start.width + dx;
|
||||||
|
if (edge.includes('w')) {
|
||||||
|
width = start.width - dx;
|
||||||
|
x = start.x + dx;
|
||||||
|
}
|
||||||
|
if (edge.includes('s')) height = start.height + dy;
|
||||||
|
if (edge.includes('n')) {
|
||||||
|
height = start.height - dy;
|
||||||
|
y = start.y + dy;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (width < min.width) {
|
||||||
|
// west drag: the east edge is the anchor, so x is derived from it
|
||||||
|
if (edge.includes('w')) x = start.x + start.width - min.width;
|
||||||
|
width = min.width;
|
||||||
|
}
|
||||||
|
if (height < min.height) {
|
||||||
|
if (edge.includes('n')) y = start.y + start.height - min.height;
|
||||||
|
height = min.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: Math.round(x),
|
||||||
|
y: Math.round(y),
|
||||||
|
width: Math.round(width),
|
||||||
|
height: Math.round(height),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { RpcService } from '@core/rpc.service';
|
||||||
|
import { type Frame, RESIZE_EDGES, type ResizeEdge, resizeFrame } from './resize-math';
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
* ⚠️ STOPGAP — DELETE THIS WHOLE FEATURE WHEN ELECTROBUN SHIPS WINDOW RESIZE
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* WHY IT EXISTS
|
||||||
|
*
|
||||||
|
* The win7 skin is frameless: `chromeOptions()` (src/bun/windows.ts) passes
|
||||||
|
* `titleBarStyle: "hidden"` so 7.css can draw the Aero caption itself, and on
|
||||||
|
* Linux the GTK wrapper honours that with `gtk_window_set_decorated(false)`.
|
||||||
|
* Undecorating the window also removes the window manager's resize borders and
|
||||||
|
* corner grips — so all three win7 windows became completely unresizable, on
|
||||||
|
* every platform. (The modern skin keeps the native frame and is unaffected;
|
||||||
|
* it must never mount this component.)
|
||||||
|
*
|
||||||
|
* Electrobun 1.18.1 offers nothing to re-enable. Verified against the SHIPPED
|
||||||
|
* binary rather than assumed:
|
||||||
|
*
|
||||||
|
* - libNativeWrapper.so exports `startWindowMove` / `stopWindowMove` and no
|
||||||
|
* resize counterpart whatsoever. Its only `resize*` exports are
|
||||||
|
* `resizeWebview` / `resizeAutoSizingWebviewsInWindow`, internal webview
|
||||||
|
* plumbing unrelated to the window frame.
|
||||||
|
* - It imports `gtk_window_begin_move_drag` but NOT
|
||||||
|
* `gtk_window_begin_resize_drag`, and not `gtk_window_set_resizable`.
|
||||||
|
* - `dist/api/bun/preload/dragRegions.ts` implements moving only: there is no
|
||||||
|
* `app-region: resize`, no per-edge variant.
|
||||||
|
* - `BrowserWindow` has no `setResizable`, and its constructor options carry
|
||||||
|
* no `resizable` / `minWidth` / `minHeight`.
|
||||||
|
*
|
||||||
|
* The window is still PROGRAMMATICALLY resizable — nothing ever calls
|
||||||
|
* `gtk_window_set_resizable(false)`, and `setFrame`/`getFrame` work. So only
|
||||||
|
* the input affordance is missing, and that is exactly what this synthesizes:
|
||||||
|
* eight handles → pointer drag → `windowSetFrame` RPC → `BrowserWindow.setFrame`.
|
||||||
|
*
|
||||||
|
* WHAT IT COSTS (i.e. why it should die as soon as it can)
|
||||||
|
*
|
||||||
|
* Every drag tick is an RPC hop plus a synchronous FFI call, where a real WM
|
||||||
|
* grab is a compositor-side operation — so it is jerkier than a native resize
|
||||||
|
* and always will be. Minimum size is enforced in JS because electrobun has no
|
||||||
|
* min-size option. And the handles occupy real estate inside the window that a
|
||||||
|
* native frame would have provided outside it.
|
||||||
|
*
|
||||||
|
* ────────────────────────────────────────────────────────────────────────────
|
||||||
|
* REMOVAL CHECKLIST — when upstream ships a resize gesture, delete ALL of:
|
||||||
|
*
|
||||||
|
* 1. this directory (window-resize.ts + resize-math.ts)
|
||||||
|
* 2. its three mount points: `<app-window-resize />` and the `WindowResize`
|
||||||
|
* entry in `imports:` for skins/7like/{app, connection-manager/
|
||||||
|
* connection-manager, editor-window/editor-window}
|
||||||
|
* 3. the `.w7-resize` rule in skins/7like/7like.css (@layer skinfix)
|
||||||
|
* 4. `windowGetFrame` / `windowSetFrame` in src/shared/rpc.ts
|
||||||
|
* 5. their handlers in `windowControlHandlers` (src/bun/rpc-handlers.ts)
|
||||||
|
* 6. scripts/check-electrobun-resize.ts and scripts/resize-math-probe.ts
|
||||||
|
* 7. the check-script call in package.json's `postinstall`
|
||||||
|
* 8. the resize paragraphs in docs/{gotchas,platform-notes,architecture}.md
|
||||||
|
*
|
||||||
|
* Then adopt whatever upstream provides — most likely a `no-drag`-style CSS
|
||||||
|
* class or a `startWindowResize(edge)` preload call, which needs handles for
|
||||||
|
* hit-testing but none of the frame arithmetic below.
|
||||||
|
*
|
||||||
|
* HOW WE FIND OUT: `scripts/check-electrobun-resize.ts` inspects the installed
|
||||||
|
* electrobun for exactly those symbols and prints this checklist when they
|
||||||
|
* appear. It runs from `postinstall`, so an electrobun bump surfaces it without
|
||||||
|
* anyone remembering to look.
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* IMPLEMENTATION NOTES
|
||||||
|
*
|
||||||
|
* - `electrobun-webkit-app-region-no-drag` on every handle is LOAD-BEARING, not
|
||||||
|
* decoration. `dragRegions.ts` starts a window MOVE from a `mousedown`
|
||||||
|
* listener on `document` in the bubble phase; it never checks
|
||||||
|
* `defaultPrevented`, so `preventDefault()` does not stop it, and a
|
||||||
|
* `pointerdown` handler does not either (the compatibility `mousedown` still
|
||||||
|
* fires afterwards, and stopping a POINTER event does nothing to a MOUSE
|
||||||
|
* event). `isAppRegionDrag` tests for the no-drag class first and
|
||||||
|
* short-circuits, so the class is the only reliable suppressor. Without it
|
||||||
|
* every resize drag would also drag the window.
|
||||||
|
* - Deltas come from `screenX/screenY`, never `clientX/clientY`. Resizing from
|
||||||
|
* the north or west edge moves the window's origin, which moves the webview's
|
||||||
|
* client origin under a stationary pointer — client coords would feed that
|
||||||
|
* movement back into the next delta and the window would run away.
|
||||||
|
* - The frame is read once per drag, asynchronously, but the pointer origin is
|
||||||
|
* recorded synchronously in the same handler: a move that arrives before the
|
||||||
|
* frame does is simply ignored, and no delta is measured from a stale origin.
|
||||||
|
* - At most one setFrame is in flight, with the newest target coalesced behind
|
||||||
|
* it. Firing one RPC per pointermove would queue faster than it drains and
|
||||||
|
* the window would keep resizing after the mouse stopped.
|
||||||
|
* - Handles are hidden when maximized by CSS alone (7like.css reads the title
|
||||||
|
* bar's own `is-maximized` through `:has()`), so this component never
|
||||||
|
* subscribes to `windowState`. That matters: RpcService's push registries
|
||||||
|
* have no `off()`, so every subscription is permanent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Edge → the Tailwind box that places and cues it. Edge thickness is 0.375rem
|
||||||
|
* = 6px at 1×, matching the gutter 7.css's `.window-body { margin: 0 6px 6px }`
|
||||||
|
* leaves around the content, so left/right/bottom handles sit on frame chrome
|
||||||
|
* instead of stealing clicks from the panels. Rem, so the grab area scales with
|
||||||
|
* the interface scale like everything else. Corners are listed last: same
|
||||||
|
* z-index, later in the DOM, so they win the overlap. */
|
||||||
|
const HANDLE_BOX: Record<ResizeEdge, string> = {
|
||||||
|
n: 'top-0 right-0 left-0 h-1.5 cursor-ns-resize',
|
||||||
|
s: 'right-0 bottom-0 left-0 h-1.5 cursor-ns-resize',
|
||||||
|
w: 'top-0 bottom-0 left-0 w-1.5 cursor-ew-resize',
|
||||||
|
e: 'top-0 right-0 bottom-0 w-1.5 cursor-ew-resize',
|
||||||
|
nw: 'top-0 left-0 size-3 cursor-nwse-resize',
|
||||||
|
ne: 'top-0 right-0 size-3 cursor-nesw-resize',
|
||||||
|
sw: 'bottom-0 left-0 size-3 cursor-nesw-resize',
|
||||||
|
se: 'right-0 bottom-0 size-3 cursor-nwse-resize',
|
||||||
|
};
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-window-resize',
|
||||||
|
template: `
|
||||||
|
@for (edge of edges; track edge) {
|
||||||
|
<div
|
||||||
|
class="pointer-events-auto absolute touch-none electrobun-webkit-app-region-no-drag {{ box[edge] }}"
|
||||||
|
(pointerdown)="onPointerDown($event, edge)"
|
||||||
|
(pointermove)="onPointerMove($event)"
|
||||||
|
(pointerup)="onPointerEnd()"
|
||||||
|
(pointercancel)="onPointerEnd()"
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
// The overlay spans the frame but is transparent to the pointer; only the
|
||||||
|
// handles inside it take hits. `w7-resize` is what the maximized rule hides.
|
||||||
|
host: { class: 'w7-resize pointer-events-none absolute inset-0 z-10' },
|
||||||
|
})
|
||||||
|
export class WindowResize {
|
||||||
|
protected readonly edges = RESIZE_EDGES;
|
||||||
|
protected readonly box = HANDLE_BOX;
|
||||||
|
|
||||||
|
private readonly rpc = inject(RpcService);
|
||||||
|
|
||||||
|
/** Null except during a drag. Set only once the frame read resolves. */
|
||||||
|
private drag: { edge: ResizeEdge; screenX: number; screenY: number; frame: Frame } | null = null;
|
||||||
|
/** Newest target frame not yet sent, if a send is already in flight. */
|
||||||
|
private pending: Frame | null = null;
|
||||||
|
private sending = false;
|
||||||
|
|
||||||
|
onPointerDown(event: PointerEvent, edge: ResizeEdge): void {
|
||||||
|
if (!this.rpc.available || event.button !== 0) return;
|
||||||
|
(event.target as HTMLElement).setPointerCapture(event.pointerId);
|
||||||
|
// captured synchronously — the frame read below is a round trip
|
||||||
|
const screenX = event.screenX;
|
||||||
|
const screenY = event.screenY;
|
||||||
|
void this.rpc.request.windowGetFrame({}).then((res) => {
|
||||||
|
if (!res.ok) return;
|
||||||
|
this.drag = {
|
||||||
|
edge,
|
||||||
|
screenX,
|
||||||
|
screenY,
|
||||||
|
frame: { x: res.x, y: res.y, width: res.width, height: res.height },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerMove(event: PointerEvent): void {
|
||||||
|
const drag = this.drag;
|
||||||
|
if (!drag) return; // moved before the frame arrived, or not dragging
|
||||||
|
this.pending = resizeFrame(
|
||||||
|
drag.edge,
|
||||||
|
drag.frame,
|
||||||
|
event.screenX - drag.screenX,
|
||||||
|
event.screenY - drag.screenY,
|
||||||
|
);
|
||||||
|
void this.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerEnd(): void {
|
||||||
|
this.drag = null; // capture releases itself on pointerup/cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drain `pending`, one request at a time. Re-entrant calls return
|
||||||
|
* immediately; the running loop picks up whatever they left behind. */
|
||||||
|
private async flush(): Promise<void> {
|
||||||
|
if (this.sending) return;
|
||||||
|
this.sending = true;
|
||||||
|
try {
|
||||||
|
while (this.pending) {
|
||||||
|
const frame = this.pending;
|
||||||
|
this.pending = null;
|
||||||
|
await this.rpc.request.windowSetFrame(frame);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.sending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,8 +10,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<!-- win7 skin only: the window is frameless, so this IS the chrome -->
|
|
||||||
<app-title-bar [title]="appName" (closeRequested)="closeWindow()" />
|
|
||||||
<app-tab-bar />
|
<app-tab-bar />
|
||||||
|
|
||||||
<main class="flex min-h-0 flex-1 divide-x divide-border">
|
<main class="flex min-h-0 flex-1 divide-x divide-border">
|
||||||
@@ -55,5 +53,6 @@
|
|||||||
<app-overwrite-dialog [request]="req" />
|
<app-overwrite-dialog [request]="req" />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<app-context-menu />
|
||||||
<app-toasts />
|
<app-toasts />
|
||||||
</div>
|
</div>
|
||||||
@@ -1,28 +1,29 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
import { Component, inject, signal } from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import { I18nService } from './i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import { BRANDING } from '@shared/branding';
|
import { BRANDING } from '@shared/branding';
|
||||||
import type { ColumnKey } from '@shared/types';
|
import type { ColumnKey } from '@shared/types';
|
||||||
import { ConnectionsService } from './connections.service';
|
import { ConnectionsService } from '@core/connections.service';
|
||||||
|
import { ContextMenu } from './context-menu';
|
||||||
import { ChmodDialog } from './dialogs/chmod-dialog';
|
import { ChmodDialog } from './dialogs/chmod-dialog';
|
||||||
import { ConfirmDialog } from './dialogs/confirm-dialog';
|
import { ConfirmDialog } from './dialogs/confirm-dialog';
|
||||||
import { OverwriteDialog } from './dialogs/overwrite-dialog';
|
import { OverwriteDialog } from './dialogs/overwrite-dialog';
|
||||||
import { PromptDialog } from './dialogs/prompt-dialog';
|
import { PromptDialog } from './dialogs/prompt-dialog';
|
||||||
import { DialogsService } from './dialogs.service';
|
import { DialogsService } from '@core/dialogs.service';
|
||||||
import { FileActionsService } from './file-actions.service';
|
import { FileActionsService } from '@core/file-actions.service';
|
||||||
import { FilePanel } from './file-panel/file-panel';
|
import { FilePanel } from './file-panel/file-panel';
|
||||||
import { PanelsService } from './panels.service';
|
import { PanelsService } from '@core/panels.service';
|
||||||
import { blockNativeContextMenu } from './platform';
|
import { blockNativeContextMenu } from '@core/platform';
|
||||||
import { RpcService } from './rpc.service';
|
import { RpcService } from '@core/rpc.service';
|
||||||
import { SettingsService } from './settings.service';
|
import { SettingsService } from '@core/settings.service';
|
||||||
|
import { ShortcutsService } from '@core/shortcuts.service';
|
||||||
import { StatusBar } from './status-bar/status-bar';
|
import { StatusBar } from './status-bar/status-bar';
|
||||||
import { TabBar } from './tab-bar/tab-bar';
|
import { TabBar } from './tab-bar/tab-bar';
|
||||||
import { TitleBar } from './title-bar/title-bar';
|
import { ToastService } from '@core/toast.service';
|
||||||
import { ToastService } from './toast.service';
|
|
||||||
import { Toasts } from './toasts/toasts';
|
import { Toasts } from './toasts/toasts';
|
||||||
import { TransferBar } from './transfer-bar/transfer-bar';
|
import { TransferBar } from './transfer-bar/transfer-bar';
|
||||||
import { TransfersService } from './transfers.service';
|
import { TransfersService } from '@core/transfers.service';
|
||||||
import { UpdaterService } from './updater.service';
|
import { UpdaterService } from '@core/updater.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@@ -37,7 +38,7 @@ import { UpdaterService } from './updater.service';
|
|||||||
OverwriteDialog,
|
OverwriteDialog,
|
||||||
Toasts,
|
Toasts,
|
||||||
TransferBar,
|
TransferBar,
|
||||||
TitleBar,
|
ContextMenu,
|
||||||
],
|
],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
host: {
|
host: {
|
||||||
@@ -55,23 +56,12 @@ export class App {
|
|||||||
readonly connections = inject(ConnectionsService);
|
readonly connections = inject(ConnectionsService);
|
||||||
private readonly transfers = inject(TransfersService);
|
private readonly transfers = inject(TransfersService);
|
||||||
private readonly settings = inject(SettingsService);
|
private readonly settings = inject(SettingsService);
|
||||||
|
private readonly shortcuts = inject(ShortcutsService);
|
||||||
private readonly updater = inject(UpdaterService);
|
private readonly updater = inject(UpdaterService);
|
||||||
|
|
||||||
/** The connection-manager window is open — this window is modal-blocked. */
|
/** The connection-manager window is open — this window is modal-blocked. */
|
||||||
readonly managerOpen = signal(false);
|
readonly managerOpen = signal(false);
|
||||||
|
|
||||||
/** 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() {
|
constructor() {
|
||||||
document.title = BRANDING.appName;
|
document.title = BRANDING.appName;
|
||||||
if (this.rpc.available) {
|
if (this.rpc.available) {
|
||||||
@@ -131,6 +121,10 @@ export class App {
|
|||||||
this.settings.toggleColumn(action.slice('col:'.length) as ColumnKey);
|
this.settings.toggleColumn(action.slice('col:'.length) as ColumnKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action.startsWith('tab:')) {
|
||||||
|
this.handleTabAction(action.slice('tab:'.length));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (action.startsWith('open-stack:')) {
|
if (action.startsWith('open-stack:')) {
|
||||||
const id = action.slice('open-stack:'.length);
|
const id = action.slice('open-stack:'.length);
|
||||||
let stackConnections = this.connections.connections().filter((c) => c.stackId === id);
|
let stackConnections = this.connections.connections().filter((c) => c.stackId === id);
|
||||||
@@ -166,38 +160,96 @@ export class App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tab (workspace) shortcuts — panel-level keys live on the panels themselves. */
|
/** Window shortcuts — matching lives in ShortcutsService (one keymap for the
|
||||||
|
* app); this switch only maps ids onto actions. Panel-level keys stay on
|
||||||
|
* the panels themselves. */
|
||||||
onWindowKeydown(event: KeyboardEvent): void {
|
onWindowKeydown(event: KeyboardEvent): void {
|
||||||
if (this.managerOpen()) return; // modal-blocked behind the manager
|
if (this.managerOpen()) return; // modal-blocked behind the manager
|
||||||
const mod = event.metaKey || event.ctrlKey;
|
const match = this.shortcuts.resolve(event, 'main');
|
||||||
if (mod && event.key.toLowerCase() === 't') {
|
if (!match) return;
|
||||||
event.preventDefault();
|
// scale keys are hostile mid-typing ('-' in a rename prompt must type '-');
|
||||||
this.panels.newWorkspace();
|
// the tab shortcuts keep firing there, as they always did
|
||||||
} else if (mod && event.key.toLowerCase() === 'w') {
|
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
|
switch (match.id) {
|
||||||
} else if (event.ctrlKey && event.key === 'Tab') {
|
case 'new-tab':
|
||||||
event.preventDefault();
|
this.panels.newWorkspace();
|
||||||
this.panels.cycleWorkspace(event.shiftKey ? -1 : 1);
|
break;
|
||||||
} else if (mod && !event.shiftKey && !event.altKey) {
|
case 'close-tab':
|
||||||
// ⌘1–⌘8 jump to that tab, ⌘9 to the last (Ctrl+digit elsewhere), like
|
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
|
||||||
// Firefox. Matched on event.code so the digit row works on any layout.
|
break;
|
||||||
const digit = /^(?:Digit|Numpad)([1-9])$/.exec(event.code)?.[1];
|
case 'cycle-tab':
|
||||||
if (!digit) return;
|
this.panels.cycleWorkspace(1);
|
||||||
event.preventDefault();
|
break;
|
||||||
this.panels.goToWorkspace(digit === '9' ? -1 : Number(digit) - 1);
|
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. */
|
/** Context-menu actions apply to the row/panel remembered at right-click time. */
|
||||||
|
/**
|
||||||
|
* Workspace-tab menu actions. The target id travels INSIDE the action string
|
||||||
|
* because the right-clicked tab is not necessarily the active one — nothing
|
||||||
|
* here may fall back to activeWorkspaceId.
|
||||||
|
*/
|
||||||
|
private handleTabAction(action: string): void {
|
||||||
|
if (action === 'new') {
|
||||||
|
this.panels.newWorkspace();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = /^([a-z-]+):(\d+)$/.exec(action);
|
||||||
|
if (!parsed) return;
|
||||||
|
const id = Number(parsed[2]);
|
||||||
|
switch (parsed[1]) {
|
||||||
|
case 'close':
|
||||||
|
this.panels.closeWorkspace(id);
|
||||||
|
break;
|
||||||
|
case 'close-others':
|
||||||
|
this.panels.closeOtherWorkspaces(id);
|
||||||
|
break;
|
||||||
|
case 'close-right':
|
||||||
|
this.panels.closeWorkspacesToRight(id);
|
||||||
|
break;
|
||||||
|
case 'disconnect':
|
||||||
|
void this.panels.disconnectWorkspace(id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private handleContextAction(action: string): void {
|
private handleContextAction(action: string): void {
|
||||||
const target = this.actions.menuTarget;
|
const target = this.actions.menuTarget;
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
const { panel, side, entry } = target;
|
const { panel, side, entry } = target;
|
||||||
|
// the empty-space menu acts on the directory itself
|
||||||
|
const path = entry?.path ?? panel.cwd();
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'ctx-open':
|
case 'ctx-open':
|
||||||
if (entry) panel.open(entry);
|
if (entry) panel.open(entry);
|
||||||
break;
|
break;
|
||||||
|
// local shell integration — only bun can reach the OS shell/clipboard
|
||||||
|
case 'ctx-reveal':
|
||||||
|
void this.rpc.request.shellItemAction({ action: 'reveal', path });
|
||||||
|
break;
|
||||||
|
case 'ctx-open-default':
|
||||||
|
void this.rpc.request.shellItemAction({ action: 'open', path });
|
||||||
|
break;
|
||||||
|
case 'ctx-copy-path':
|
||||||
|
void this.rpc.request.shellItemAction({ action: 'copyPath', path });
|
||||||
|
break;
|
||||||
case 'ctx-edit':
|
case 'ctx-edit':
|
||||||
void this.actions.edit(panel);
|
void this.actions.edit(panel);
|
||||||
break;
|
break;
|
||||||
+12
-18
@@ -1,14 +1,8 @@
|
|||||||
<div class="flex h-screen flex-col bg-background text-foreground">
|
<div class="flex h-screen flex-col bg-background text-foreground">
|
||||||
<!-- win7 skin only: frameless window. Fixed-size modal, so Close only. -->
|
|
||||||
<app-title-bar
|
|
||||||
[title]="i18n.t('connMgr.title')"
|
|
||||||
[resizable]="false"
|
|
||||||
(closeRequested)="close()"
|
|
||||||
/>
|
|
||||||
<!-- header: on macOS this row IS the titlebar (hiddenInset + drag region),
|
<!-- header: on macOS this row IS the titlebar (hiddenInset + drag region),
|
||||||
elsewhere the native titlebar sits above and this is a compact strip -->
|
elsewhere the native titlebar sits above and this is a compact strip -->
|
||||||
<header
|
<header
|
||||||
class="w7-toolbar flex shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2"
|
class="flex shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2"
|
||||||
[class.h-10]="isMacChrome"
|
[class.h-10]="isMacChrome"
|
||||||
[class.pl-24]="isMacChrome"
|
[class.pl-24]="isMacChrome"
|
||||||
[class.electrobun-webkit-app-region-drag]="isMacChrome"
|
[class.electrobun-webkit-app-region-drag]="isMacChrome"
|
||||||
@@ -22,7 +16,7 @@
|
|||||||
(click)="importWinScp()"
|
(click)="importWinScp()"
|
||||||
[disabled]="importing()"
|
[disabled]="importing()"
|
||||||
[title]="i18n.t('connMgr.importWinscpHint')"
|
[title]="i18n.t('connMgr.importWinscpHint')"
|
||||||
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground disabled:opacity-50"
|
class="flex items-center gap-1.5 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()) {
|
@if (importing()) {
|
||||||
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
|
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
|
||||||
@@ -53,7 +47,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="has-scrollbar flex-1 overflow-y-auto py-1">
|
<div class="flex-1 overflow-y-auto py-1">
|
||||||
<!-- stacks -->
|
<!-- stacks -->
|
||||||
@for (group of store.grouped().stacks; track group.stack.id) {
|
@for (group of store.grouped().stacks; track group.stack.id) {
|
||||||
<div
|
<div
|
||||||
@@ -77,14 +71,14 @@
|
|||||||
size="0.8rem"
|
size="0.8rem"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<span class="truncate text-[11px] font-medium text-muted-foreground uppercase">
|
<span class="truncate text-[0.6875rem] font-medium text-muted-foreground uppercase">
|
||||||
{{ group.stack.name }}
|
{{ group.stack.name }}
|
||||||
</span>
|
</span>
|
||||||
<div class="min-w-0 flex-1"></div>
|
<div class="min-w-0 flex-1"></div>
|
||||||
<!-- count and actions swap on hover: they never share the space,
|
<!-- count and actions swap on hover: they never share the space,
|
||||||
so a long stack name can't collide with the buttons -->
|
so a long stack name can't collide with the buttons -->
|
||||||
<span
|
<span
|
||||||
class="shrink-0 text-[10px] text-muted-foreground/70 tabular-nums group-hover:hidden"
|
class="shrink-0 text-[0.625rem] text-muted-foreground/70 tabular-nums group-hover:hidden"
|
||||||
>
|
>
|
||||||
{{ group.connections.length }}
|
{{ group.connections.length }}
|
||||||
</span>
|
</span>
|
||||||
@@ -129,7 +123,7 @@
|
|||||||
<span class="truncate">{{ connection.name }}</span>
|
<span class="truncate">{{ connection.name }}</span>
|
||||||
</button>
|
</button>
|
||||||
} @empty {
|
} @empty {
|
||||||
<div class="py-1 pl-6 text-[11px] text-muted-foreground italic">{{ i18n.t('connMgr.empty') }}</div>
|
<div class="py-1 pl-6 text-[0.6875rem] text-muted-foreground italic">{{ i18n.t('connMgr.empty') }}</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -147,7 +141,7 @@
|
|||||||
[class.ring-primary]="dropTarget() === 'loose'"
|
[class.ring-primary]="dropTarget() === 'loose'"
|
||||||
>
|
>
|
||||||
@if (store.grouped().stacks.length && store.grouped().loose.length) {
|
@if (store.grouped().stacks.length && store.grouped().loose.length) {
|
||||||
<div class="px-2 pt-2 pb-0.5 text-[11px] font-medium text-muted-foreground uppercase">
|
<div class="px-2 pt-2 pb-0.5 text-[0.6875rem] font-medium text-muted-foreground uppercase">
|
||||||
{{ i18n.t('connMgr.connectionsHeader') }}
|
{{ i18n.t('connMgr.connectionsHeader') }}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -168,10 +162,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!store.connections().length && !store.stacks().length) {
|
@if (!store.connections().length && !store.stacks().length) {
|
||||||
<p class="px-3 py-2 text-[11px] text-muted-foreground">
|
<p class="px-3 py-2 text-[0.6875rem] text-muted-foreground">
|
||||||
{{ i18n.t('connMgr.noneTitle') }}
|
{{ i18n.t('connMgr.noneTitle') }}
|
||||||
</p>
|
</p>
|
||||||
<p class="px-3 py-2 text-[11px] text-muted-foreground">
|
<p class="px-3 py-2 text-[0.6875rem] text-muted-foreground">
|
||||||
{{ i18n.t('connMgr.noneBody') }}
|
{{ i18n.t('connMgr.noneBody') }}
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
@@ -181,7 +175,7 @@
|
|||||||
<!-- edit form -->
|
<!-- edit form -->
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
@if (draft(); as d) {
|
@if (draft(); as d) {
|
||||||
<div class="has-scrollbar grid flex-1 auto-rows-min grid-cols-2 gap-x-4 gap-y-3 overflow-y-auto p-5">
|
<div class="grid flex-1 auto-rows-min grid-cols-2 gap-x-4 gap-y-3 overflow-y-auto p-5">
|
||||||
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
||||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.name') }}</span>
|
<span class="text-muted-foreground">{{ i18n.t('connMgr.name') }}</span>
|
||||||
<input
|
<input
|
||||||
@@ -299,7 +293,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="pickKeyFile()"
|
(click)="pickKeyFile()"
|
||||||
class="h-7 shrink-0 rounded-md border border-border px-2 text-[11px] hover:bg-accent"
|
class="h-7 shrink-0 rounded-md border border-border px-2 text-[0.6875rem] hover:bg-accent"
|
||||||
>
|
>
|
||||||
{{ i18n.t('common.choose') }}
|
{{ i18n.t('common.choose') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -326,7 +320,7 @@
|
|||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
<span class="text-[10px] text-muted-foreground/80">
|
<span class="text-[0.625rem] text-muted-foreground/80">
|
||||||
{{ i18n.t('connMgr.localDirHint') }}
|
{{ i18n.t('connMgr.localDirHint') }}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
+20
-8
@@ -1,15 +1,15 @@
|
|||||||
import { Component, computed, inject, signal } from '@angular/core';
|
import { Component, computed, inject, signal } from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import { I18nService } from '../i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import type { SavedConnection, Stack } from '@shared/types';
|
import type { SavedConnection, Stack } from '@shared/types';
|
||||||
import { ConnectionsService } from '../connections.service';
|
import { ConnectionsService } from '@core/connections.service';
|
||||||
import { ConfirmDialog } from '../dialogs/confirm-dialog';
|
import { ConfirmDialog } from '../dialogs/confirm-dialog';
|
||||||
import { PromptDialog } from '../dialogs/prompt-dialog';
|
import { PromptDialog } from '../dialogs/prompt-dialog';
|
||||||
import { DialogsService } from '../dialogs.service';
|
import { DialogsService } from '@core/dialogs.service';
|
||||||
import { blockNativeContextMenu, IS_MAC_CHROME } from '../platform';
|
import { blockNativeContextMenu, IS_MAC_CHROME } from '@core/platform';
|
||||||
import { RpcService } from '../rpc.service';
|
import { RpcService } from '@core/rpc.service';
|
||||||
import { SettingsService } from '../settings.service';
|
import { SettingsService } from '@core/settings.service';
|
||||||
import { TitleBar } from '../title-bar/title-bar';
|
import { ShortcutsService } from '@core/shortcuts.service';
|
||||||
import { Toasts } from '../toasts/toasts';
|
import { Toasts } from '../toasts/toasts';
|
||||||
|
|
||||||
interface Draft {
|
interface Draft {
|
||||||
@@ -73,10 +73,11 @@ function fromDraft(d: Draft): SavedConnection {
|
|||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts, TitleBar],
|
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts],
|
||||||
templateUrl: './connection-manager.html',
|
templateUrl: './connection-manager.html',
|
||||||
host: {
|
host: {
|
||||||
'(window:keydown.escape)': 'onEscape()',
|
'(window:keydown.escape)': 'onEscape()',
|
||||||
|
'(window:keydown)': 'onWindowKeydown($event)',
|
||||||
'(window:contextmenu)': 'blockContextMenu($event)',
|
'(window:contextmenu)': 'blockContextMenu($event)',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -89,6 +90,7 @@ export class ConnectionManager {
|
|||||||
// injected for its side effects: fetches settings and applies theme/accent/
|
// injected for its side effects: fetches settings and applies theme/accent/
|
||||||
// text-size data attributes to this window's document root
|
// text-size data attributes to this window's document root
|
||||||
private readonly settings = inject(SettingsService);
|
private readonly settings = inject(SettingsService);
|
||||||
|
private readonly shortcuts = inject(ShortcutsService);
|
||||||
|
|
||||||
readonly draft = signal<Draft | null>(null);
|
readonly draft = signal<Draft | null>(null);
|
||||||
readonly busy = signal(false);
|
readonly busy = signal(false);
|
||||||
@@ -144,6 +146,16 @@ export class ConnectionManager {
|
|||||||
if (!this.dialogs.anyOpen) this.close();
|
if (!this.dialogs.anyOpen) this.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Any-window shortcuts (interface scale). Escape has its own binding above. */
|
||||||
|
onWindowKeydown(event: KeyboardEvent): void {
|
||||||
|
const match = this.shortcuts.resolve(event, 'manager');
|
||||||
|
if (!match || this.shortcuts.isTypingTarget(event)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (match.id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
||||||
|
else if (match.id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1);
|
||||||
|
else if (match.id === 'scale-reset') this.settings.setUiScale(1);
|
||||||
|
}
|
||||||
|
|
||||||
blockContextMenu(event: Event): void {
|
blockContextMenu(event: Event): void {
|
||||||
blockNativeContextMenu(event);
|
blockNativeContextMenu(event);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
viewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { NgIcon } from '@ng-icons/core';
|
||||||
|
import { ContextMenuService, type MenuItem } from '@core/context-menu.service';
|
||||||
|
import { FocusService } from '@core/focus.service';
|
||||||
|
import { remPx } from '@core/metrics';
|
||||||
|
|
||||||
|
/** Rough per-item metrics in REM, used only to decide whether the menu must
|
||||||
|
* flip. Converted through remPx() at open time so the math tracks the same
|
||||||
|
* root font-size the CSS scales with — the old px constants (`MENU_W = 224`)
|
||||||
|
* were 16px-root mirrors of rem classes, so the box grew with the interface
|
||||||
|
* scale while the flip math still believed 224 and fired at the wrong edge. */
|
||||||
|
const ITEM_H_REM = 1.625; // px-3 py-1 row + --path-font line ≈ 26px @16
|
||||||
|
const SEPARATOR_H_REM = 0.4375; // my-1 + 1px border ≈ 7px @16
|
||||||
|
const MENU_W_REM = 14; // w-56
|
||||||
|
const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel right-click menu. Replaces electrobun's native ContextMenu, which
|
||||||
|
* is a no-op on Linux — see ContextMenuService for the why. Emits the same
|
||||||
|
* `ctx-*` / `col:*` action strings the native menu did.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-context-menu',
|
||||||
|
imports: [NgIcon],
|
||||||
|
template: `
|
||||||
|
@if (menu.request(); as req) {
|
||||||
|
<!-- click-outside catcher; mousedown so it beats the panel's own handlers -->
|
||||||
|
<div class="fixed inset-0 z-50" (mousedown)="close()" (contextmenu)="onBackdropContext($event)"></div>
|
||||||
|
<div
|
||||||
|
#menuEl
|
||||||
|
role="menu"
|
||||||
|
tabindex="-1"
|
||||||
|
(keydown)="onKeydown($event)"
|
||||||
|
[style.left.px]="position().x"
|
||||||
|
[style.top.px]="position().y"
|
||||||
|
class="fixed z-50 w-56 rounded-lg border border-border bg-card py-1 text-card-foreground shadow-xl outline-none"
|
||||||
|
>
|
||||||
|
@for (item of req.items; track $index) {
|
||||||
|
@if (item.type === 'separator') {
|
||||||
|
<div class="my-1 border-t border-border"></div>
|
||||||
|
} @else {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
[title]="item.tooltip ?? ''"
|
||||||
|
[disabled]="!!item.disabled"
|
||||||
|
[class.bg-accent]="$index === activeIndex() && !item.disabled"
|
||||||
|
(mouseenter)="activeIndex.set($index)"
|
||||||
|
(click)="pick(item)"
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-1 text-left text-(length:--path-font) hover:bg-accent disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span class="w-3.5 shrink-0">
|
||||||
|
@if (item.checked) {
|
||||||
|
<ng-icon name="mynaCheck" size="0.85rem" class="text-primary" />
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span class="flex-1 truncate">{{ item.label }}</span>
|
||||||
|
@if (item.accelerator) {
|
||||||
|
<span class="shrink-0 text-muted-foreground">{{ item.accelerator }}</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
// Closing on scroll/blur matches native menu behavior: the anchor row moves
|
||||||
|
// out from under a menu that would otherwise float over stale content.
|
||||||
|
host: {
|
||||||
|
'(window:blur)': 'close()',
|
||||||
|
'(window:resize)': 'close()',
|
||||||
|
'(document:scroll)': 'close()',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class ContextMenu {
|
||||||
|
readonly menu = inject(ContextMenuService);
|
||||||
|
private readonly focus = inject(FocusService);
|
||||||
|
private readonly menuEl = viewChild<ElementRef<HTMLDivElement>>('menuEl');
|
||||||
|
|
||||||
|
readonly activeIndex = signal(-1);
|
||||||
|
|
||||||
|
/** Click point, flipped/clamped so the menu never overflows the viewport.
|
||||||
|
* All UI metrics convert through the live root font-size; window.inner* is
|
||||||
|
* viewport CSS px, so both sides of every comparison share units. */
|
||||||
|
readonly position = computed(() => {
|
||||||
|
const req = this.menu.request();
|
||||||
|
if (!req) return { x: 0, y: 0 };
|
||||||
|
const itemH = remPx(ITEM_H_REM);
|
||||||
|
const separatorH = remPx(SEPARATOR_H_REM);
|
||||||
|
const menuW = remPx(MENU_W_REM);
|
||||||
|
const height =
|
||||||
|
req.items.reduce((sum, i) => sum + (i.type === 'separator' ? separatorH : itemH), 0) +
|
||||||
|
remPx(0.5); // the menu's py-1 (0.25rem x2)
|
||||||
|
const x =
|
||||||
|
req.x + menuW > window.innerWidth - EDGE_GAP ? Math.max(EDGE_GAP, req.x - menuW) : req.x;
|
||||||
|
const y =
|
||||||
|
req.y + height > window.innerHeight - EDGE_GAP
|
||||||
|
? Math.max(EDGE_GAP, req.y - height)
|
||||||
|
: req.y;
|
||||||
|
return { x, y };
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Focus the menu once it exists so arrow keys / Escape reach it. Keyed on
|
||||||
|
// the viewChild signal, NOT on request(): under zoneless CD an effect can
|
||||||
|
// run before the @if has rendered the element.
|
||||||
|
effect(() => this.menuEl()?.nativeElement.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
if (!this.menu.request()) return;
|
||||||
|
this.menu.close();
|
||||||
|
this.activeIndex.set(-1); // a fresh menu starts with nothing highlighted
|
||||||
|
this.focus.bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
pick(item: MenuItem): void {
|
||||||
|
// the keyboard path never consults the disabled attribute — guard here
|
||||||
|
if (item.disabled) return;
|
||||||
|
if (!item.action) return;
|
||||||
|
const action = item.action;
|
||||||
|
this.close();
|
||||||
|
this.menu.onPick(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Right-clicking the backdrop closes rather than stacking a second menu. */
|
||||||
|
onBackdropContext(event: MouseEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeydown(event: KeyboardEvent): void {
|
||||||
|
const items = this.menu.request()?.items ?? [];
|
||||||
|
switch (event.key) {
|
||||||
|
case 'Escape':
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.close();
|
||||||
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
event.preventDefault();
|
||||||
|
this.step(items, 1);
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
event.preventDefault();
|
||||||
|
this.step(items, -1);
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
case ' ': {
|
||||||
|
event.preventDefault();
|
||||||
|
const item = items[this.activeIndex()];
|
||||||
|
if (item) this.pick(item);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move the highlight by one selectable item, skipping separators and
|
||||||
|
* disabled entries, wrapping. */
|
||||||
|
private step(items: MenuItem[], delta: number): void {
|
||||||
|
const count = items.length;
|
||||||
|
if (!count) return;
|
||||||
|
let index = this.activeIndex();
|
||||||
|
for (let hops = 0; hops < count; hops++) {
|
||||||
|
index = (index + delta + count) % count;
|
||||||
|
if (items[index].type !== 'separator' && !items[index].disabled) {
|
||||||
|
this.activeIndex.set(index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||||
|
>
|
||||||
|
{{ i18n.t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="submit()"
|
||||||
|
class="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="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
|
||||||
|
? 'rounded-md bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground hover:bg-destructive/90'
|
||||||
|
: 'rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ 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="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="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="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="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="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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-11
@@ -10,13 +10,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<!-- win7 skin only: 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()" />
|
|
||||||
<!-- header: on macOS this row IS the titlebar (hiddenInset + drag region),
|
<!-- header: on macOS this row IS the titlebar (hiddenInset + drag region),
|
||||||
elsewhere the native titlebar sits above and this is a compact strip -->
|
elsewhere the native titlebar sits above and this is a compact strip -->
|
||||||
<header
|
<header
|
||||||
class="w7-toolbar flex shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2"
|
class="flex shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2"
|
||||||
[class.h-10]="isMacChrome"
|
[class.h-10]="isMacChrome"
|
||||||
[class.pl-24]="isMacChrome"
|
[class.pl-24]="isMacChrome"
|
||||||
[class.electrobun-webkit-app-region-drag]="isMacChrome"
|
[class.electrobun-webkit-app-region-drag]="isMacChrome"
|
||||||
@@ -27,12 +24,12 @@
|
|||||||
<h1 class="min-w-0 truncate font-mono text-sm font-semibold">
|
<h1 class="min-w-0 truncate font-mono text-sm font-semibold">
|
||||||
{{ ctx.name }}{{ dirty() ? ' •' : '' }}
|
{{ ctx.name }}{{ dirty() ? ' •' : '' }}
|
||||||
</h1>
|
</h1>
|
||||||
<span class="shrink-0 text-[11px] text-muted-foreground">{{ ctx.sessionTitle }}</span>
|
<span class="shrink-0 text-[0.6875rem] text-muted-foreground">{{ ctx.sessionTitle }}</span>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button
|
<button
|
||||||
(click)="toggleWrap()"
|
(click)="toggleWrap()"
|
||||||
[title]="i18n.t('editor.wrapHint', { alt: altLabel })"
|
[title]="i18n.t('editor.wrapHint', { alt: altLabel })"
|
||||||
class="rounded-md px-2 py-1 text-[11px] electrobun-webkit-app-region-no-drag hover:bg-accent"
|
class="rounded-md px-2 py-1 text-[0.6875rem] electrobun-webkit-app-region-no-drag hover:bg-accent"
|
||||||
[class.bg-accent]="wrap()"
|
[class.bg-accent]="wrap()"
|
||||||
[class.text-foreground]="wrap()"
|
[class.text-foreground]="wrap()"
|
||||||
[class.text-muted-foreground]="!wrap()"
|
[class.text-muted-foreground]="!wrap()"
|
||||||
@@ -61,7 +58,7 @@
|
|||||||
class="h-6 w-56 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-2 focus:ring-ring"
|
class="h-6 w-56 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-2 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
class="text-[11px] tabular-nums"
|
class="text-[0.6875rem] tabular-nums"
|
||||||
[class.text-destructive]="findQuery() && !matches().length"
|
[class.text-destructive]="findQuery() && !matches().length"
|
||||||
[class.text-muted-foreground]="!findQuery() || matches().length"
|
[class.text-muted-foreground]="!findQuery() || matches().length"
|
||||||
>
|
>
|
||||||
@@ -94,7 +91,7 @@
|
|||||||
|
|
||||||
@if (sessionDead()) {
|
@if (sessionDead()) {
|
||||||
<div
|
<div
|
||||||
class="flex shrink-0 items-center gap-2 border-b border-border bg-destructive/10 px-3 py-1.5 text-[11px] text-destructive"
|
class="flex shrink-0 items-center gap-2 border-b border-border bg-destructive/10 px-3 py-1.5 text-[0.6875rem] text-destructive"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaXCircle" size="0.85rem" class="shrink-0" />
|
<ng-icon name="mynaXCircle" size="0.85rem" class="shrink-0" />
|
||||||
{{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }}
|
{{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }}
|
||||||
@@ -144,9 +141,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="flex shrink-0 items-center gap-2 border-t border-border bg-chrome px-4 py-2">
|
<footer class="flex shrink-0 items-center gap-2 border-t border-border bg-chrome px-4 py-2">
|
||||||
<span class="text-[11px] text-muted-foreground">{{ i18n.t('editor.shortcuts', { mod: modLabel }) }}</span>
|
<span class="text-[0.6875rem] text-muted-foreground">{{ i18n.t('editor.shortcuts', { mod: modLabel }) }}</span>
|
||||||
<span class="text-[11px] text-muted-foreground">·</span>
|
<span class="text-[0.6875rem] text-muted-foreground">·</span>
|
||||||
<span class="text-[11px] tabular-nums text-muted-foreground">
|
<span class="text-[0.6875rem] tabular-nums text-muted-foreground">
|
||||||
{{ i18n.t('editor.lines', { count: lineCount() }) }}
|
{{ i18n.t('editor.lines', { count: lineCount() }) }}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
+59
-27
@@ -10,12 +10,12 @@ import {
|
|||||||
viewChild,
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import { I18nService } from '../i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME, MOD_LABEL } from '../platform';
|
import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME, MOD_LABEL } from '@core/platform';
|
||||||
import { RpcService } from '../rpc.service';
|
import { RpcService } from '@core/rpc.service';
|
||||||
import { SettingsService } from '../settings.service';
|
import { SettingsService } from '@core/settings.service';
|
||||||
import { ToastService } from '../toast.service';
|
import { ShortcutsService } from '@core/shortcuts.service';
|
||||||
import { TitleBar } from '../title-bar/title-bar';
|
import { ToastService } from '@core/toast.service';
|
||||||
import { Toasts } from '../toasts/toasts';
|
import { Toasts } from '../toasts/toasts';
|
||||||
|
|
||||||
/** Debounce for mirroring the draft to bun (the traffic-light-close safety net). */
|
/** Debounce for mirroring the draft to bun (the traffic-light-close safety net). */
|
||||||
@@ -31,7 +31,7 @@ const DRAFT_DEBOUNCE_MS = 400;
|
|||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [NgIcon, Toasts, TitleBar],
|
imports: [NgIcon, Toasts],
|
||||||
templateUrl: './editor-window.html',
|
templateUrl: './editor-window.html',
|
||||||
host: {
|
host: {
|
||||||
'(window:keydown)': 'onKeydown($event)',
|
'(window:keydown)': 'onKeydown($event)',
|
||||||
@@ -95,12 +95,14 @@ export class EditorWindow {
|
|||||||
private readonly gutter = viewChild<ElementRef<HTMLElement>>('gutter');
|
private readonly gutter = viewChild<ElementRef<HTMLElement>>('gutter');
|
||||||
private readonly findBox = viewChild<ElementRef<HTMLInputElement>>('findBox');
|
private readonly findBox = viewChild<ElementRef<HTMLInputElement>>('findBox');
|
||||||
private cachedCharWidth = 0;
|
private cachedCharWidth = 0;
|
||||||
|
private cachedCharFont = '';
|
||||||
private readonly rpc = inject(RpcService);
|
private readonly rpc = inject(RpcService);
|
||||||
private readonly toast = inject(ToastService);
|
private readonly toast = inject(ToastService);
|
||||||
private readonly document = inject(DOCUMENT);
|
private readonly document = inject(DOCUMENT);
|
||||||
// injected for its side effects: fetches settings and applies theme/accent/
|
// injected for its side effects: fetches settings and applies theme/accent/
|
||||||
// text-size data attributes to this window's document root
|
// text-size data attributes to this window's document root
|
||||||
private readonly settings = inject(SettingsService);
|
private readonly settings = inject(SettingsService);
|
||||||
|
private readonly shortcuts = inject(ShortcutsService);
|
||||||
|
|
||||||
private draftTimer: ReturnType<typeof setTimeout> | null = null;
|
private draftTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private lastSentDirty = false;
|
private lastSentDirty = false;
|
||||||
@@ -215,7 +217,11 @@ export class EditorWindow {
|
|||||||
el.setSelectionRange(start, start + this.findQuery().length);
|
el.setSelectionRange(start, start + this.findQuery().length);
|
||||||
|
|
||||||
const text = this.text();
|
const text = this.text();
|
||||||
const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 20;
|
// Fallback derives from the element's own font size, not a hardcoded 20 —
|
||||||
|
// the interface scale moves the root font-size, so any px literal here
|
||||||
|
// would be a 16px-root assumption that miscentres at every other scale.
|
||||||
|
const style = getComputedStyle(el);
|
||||||
|
const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.625;
|
||||||
let line = 0;
|
let line = 0;
|
||||||
let i = text.indexOf('\n');
|
let i = text.indexOf('\n');
|
||||||
while (i !== -1 && i < start) {
|
while (i !== -1 && i < start) {
|
||||||
@@ -230,12 +236,17 @@ export class EditorWindow {
|
|||||||
this.onScroll();
|
this.onScroll();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Monospace advance width, measured once per window. */
|
/** Monospace advance width, cached per computed FONT rather than per window:
|
||||||
|
* the interface scale changes the root font-size live, and a cache measured
|
||||||
|
* once would keep positioning finds with the old advance width. Keying on
|
||||||
|
* the font string makes it self-invalidating under any font change. */
|
||||||
private charWidth(el: HTMLTextAreaElement): number {
|
private charWidth(el: HTMLTextAreaElement): number {
|
||||||
if (!this.cachedCharWidth) {
|
const font = getComputedStyle(el).font;
|
||||||
|
if (font !== this.cachedCharFont) {
|
||||||
const canvas = this.document.createElement('canvas').getContext('2d')!;
|
const canvas = this.document.createElement('canvas').getContext('2d')!;
|
||||||
canvas.font = getComputedStyle(el).font;
|
canvas.font = font;
|
||||||
this.cachedCharWidth = canvas.measureText('0').width || 7;
|
this.cachedCharWidth = canvas.measureText('0').width || parseFloat(getComputedStyle(el).fontSize) * 0.6;
|
||||||
|
this.cachedCharFont = font;
|
||||||
}
|
}
|
||||||
return this.cachedCharWidth;
|
return this.cachedCharWidth;
|
||||||
}
|
}
|
||||||
@@ -308,22 +319,43 @@ export class EditorWindow {
|
|||||||
// ---- keyboard ----
|
// ---- keyboard ----
|
||||||
|
|
||||||
onKeydown(event: KeyboardEvent): void {
|
onKeydown(event: KeyboardEvent): void {
|
||||||
const mod = event.metaKey || event.ctrlKey;
|
// Chord shortcuts resolve through the shared keymap (ShortcutsService);
|
||||||
if (mod && event.key.toLowerCase() === 's') {
|
// Escape and Tab stay below because their meaning is THIS window's state
|
||||||
|
// (close prompt up? find bar open? caret in the textarea?), not a binding.
|
||||||
|
const match = this.shortcuts.resolve(event, 'editor');
|
||||||
|
if (match) {
|
||||||
|
// scale keys must not fire while typing — but the editor's own chords
|
||||||
|
// (save/find/wrap) are WANTED mid-typing, so the guard is per-shortcut
|
||||||
|
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void this.save();
|
switch (match.id) {
|
||||||
} else if (mod && event.key.toLowerCase() === 'f') {
|
case 'save':
|
||||||
event.preventDefault();
|
void this.save();
|
||||||
this.openFind();
|
break;
|
||||||
} else if (mod && event.key.toLowerCase() === 'w') {
|
case 'find':
|
||||||
// reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W and
|
this.openFind();
|
||||||
// bun routes it back as editorCloseRequest
|
break;
|
||||||
event.preventDefault();
|
case 'close-window':
|
||||||
this.requestClose();
|
// reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W
|
||||||
} else if (event.altKey && event.code === 'KeyZ') {
|
// and bun routes it back as editorCloseRequest
|
||||||
event.preventDefault();
|
this.requestClose();
|
||||||
this.toggleWrap();
|
break;
|
||||||
} else if (event.key === 'Escape' && this.closePrompt()) {
|
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();
|
event.preventDefault();
|
||||||
this.cancelClose();
|
this.cancelClose();
|
||||||
} else if (event.key === 'Escape' && this.findOpen()) {
|
} else if (event.key === 'Escape' && this.findOpen()) {
|
||||||
+6
-6
@@ -84,7 +84,7 @@
|
|||||||
@if (menuOpen()) {
|
@if (menuOpen()) {
|
||||||
<div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div>
|
<div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div>
|
||||||
<div
|
<div
|
||||||
class="has-scrollbar absolute top-full left-2 z-30 mt-1 max-h-80 w-64 overflow-y-auto rounded-lg border border-border bg-card py-1 text-card-foreground shadow-xl"
|
class="absolute top-full left-2 z-30 mt-1 max-h-80 w-64 overflow-y-auto rounded-lg border border-border bg-card py-1 text-card-foreground shadow-xl"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
(click)="pickLocal()"
|
(click)="pickLocal()"
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@for (group of connections.grouped().stacks; track group.stack.id) {
|
@for (group of connections.grouped().stacks; track group.stack.id) {
|
||||||
<div class="px-3 pt-1.5 pb-0.5 text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
|
<div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
|
||||||
{{ group.stack.name }}
|
{{ group.stack.name }}
|
||||||
</div>
|
</div>
|
||||||
@for (connection of group.connections; track connection.id) {
|
@for (connection of group.connections; track connection.id) {
|
||||||
@@ -137,7 +137,7 @@
|
|||||||
|
|
||||||
@if (connections.sshHosts().length) {
|
@if (connections.sshHosts().length) {
|
||||||
<div class="my-1 border-t border-border"></div>
|
<div class="my-1 border-t border-border"></div>
|
||||||
<div class="px-3 pt-1.5 pb-0.5 text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
|
<div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
|
||||||
~/.ssh/config
|
~/.ssh/config
|
||||||
</div>
|
</div>
|
||||||
@for (host of connections.sshHosts(); track host.name) {
|
@for (host of connections.sshHosts(); track host.name) {
|
||||||
@@ -195,7 +195,7 @@
|
|||||||
<div
|
<div
|
||||||
(contextmenu)="headerContextMenu($event)"
|
(contextmenu)="headerContextMenu($event)"
|
||||||
[style.grid-template-columns]="gridTemplate()"
|
[style.grid-template-columns]="gridTemplate()"
|
||||||
class="grid border-b border-border text-[11px] font-medium text-muted-foreground"
|
class="grid border-b border-border text-[0.6875rem] font-medium text-muted-foreground"
|
||||||
>
|
>
|
||||||
<button class="px-2 py-1 text-left hover:text-foreground" (click)="panel().toggleSort('name')">
|
<button class="px-2 py-1 text-left hover:text-foreground" (click)="panel().toggleSort('name')">
|
||||||
{{ i18n.t('columns.name') }} {{ sortIndicator('name') }}
|
{{ i18n.t('columns.name') }} {{ sortIndicator('name') }}
|
||||||
@@ -217,7 +217,7 @@
|
|||||||
<div class="flex items-center gap-1.5 border-b border-border bg-accent/50 px-2 py-1">
|
<div class="flex items-center gap-1.5 border-b border-border bg-accent/50 px-2 py-1">
|
||||||
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
||||||
<span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span>
|
<span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span>
|
||||||
<span class="shrink-0 text-[11px] text-muted-foreground">
|
<span class="shrink-0 text-[0.6875rem] text-muted-foreground">
|
||||||
{{ i18n.t('panel.matches', { count: filterMatches() }) }}
|
{{ i18n.t('panel.matches', { count: filterMatches() }) }}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -238,7 +238,7 @@
|
|||||||
(keydown)="onKeydown($event)"
|
(keydown)="onKeydown($event)"
|
||||||
(mousedown)="activate()"
|
(mousedown)="activate()"
|
||||||
(contextmenu)="listContextMenu($event)"
|
(contextmenu)="listContextMenu($event)"
|
||||||
class="has-scrollbar h-full overflow-y-auto outline-none"
|
class="h-full overflow-y-auto outline-none"
|
||||||
>
|
>
|
||||||
@if (panel().connecting()) {
|
@if (panel().connecting()) {
|
||||||
<div class="panel-fade-in grid h-full place-items-center">
|
<div class="panel-fade-in grid h-full place-items-center">
|
||||||
+20
-16
@@ -9,18 +9,20 @@ import {
|
|||||||
viewChild,
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import { I18nService } from '../i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import type { ColumnKey, FileEntry, SavedConnection, SshConfigHost } from '@shared/types';
|
import type { ColumnKey, FileEntry, SavedConnection, SshConfigHost } from '@shared/types';
|
||||||
import { ConnectionsService } from '../connections.service';
|
import { ConnectionsService } from '@core/connections.service';
|
||||||
import { DialogsService } from '../dialogs.service';
|
import { ContextMenuService } from '@core/context-menu.service';
|
||||||
import { FileActionsService } from '../file-actions.service';
|
import { DialogsService } from '@core/dialogs.service';
|
||||||
import { fileGlyph, fileIconClass, fileKindKey } from '../file-icons';
|
import { FileActionsService } from '@core/file-actions.service';
|
||||||
import { formatMode } from '../format';
|
import { fileGlyph, fileIconClass, fileKindKey } from '@core/file-icons';
|
||||||
import { PanelsService, type Side, type SortKey } from '../panels.service';
|
import { formatMode } from '@core/format';
|
||||||
import { IS_WINDOWS } from '../platform';
|
import { PanelsService, type Side, type SortKey } from '@core/panels.service';
|
||||||
import { RpcService } from '../rpc.service';
|
import { IS_WINDOWS } from '@core/platform';
|
||||||
import { SettingsService } from '../settings.service';
|
import { RpcService } from '@core/rpc.service';
|
||||||
import { TransfersService } from '../transfers.service';
|
import { SettingsService } from '@core/settings.service';
|
||||||
|
import { ShortcutsService } from '@core/shortcuts.service';
|
||||||
|
import { TransfersService } from '@core/transfers.service';
|
||||||
|
|
||||||
/** Optional columns in fixed display order, with their track widths. */
|
/** Optional columns in fixed display order, with their track widths. */
|
||||||
const COLUMN_DEFS: { key: ColumnKey; label: string; width: string }[] = [
|
const COLUMN_DEFS: { key: ColumnKey; label: string; width: string }[] = [
|
||||||
@@ -47,7 +49,9 @@ export class FilePanel {
|
|||||||
readonly transfers = inject(TransfersService);
|
readonly transfers = inject(TransfersService);
|
||||||
readonly connections = inject(ConnectionsService);
|
readonly connections = inject(ConnectionsService);
|
||||||
readonly dialogs = inject(DialogsService);
|
readonly dialogs = inject(DialogsService);
|
||||||
|
private readonly menu = inject(ContextMenuService);
|
||||||
private readonly settings = inject(SettingsService);
|
private readonly settings = inject(SettingsService);
|
||||||
|
private readonly shortcuts = inject(ShortcutsService);
|
||||||
private readonly rpc = inject(RpcService);
|
private readonly rpc = inject(RpcService);
|
||||||
readonly panel = computed(() => this.panels.get(this.side()));
|
readonly panel = computed(() => this.panels.get(this.side()));
|
||||||
readonly isActive = computed(() => this.panels.activeSide() === this.side());
|
readonly isActive = computed(() => this.panels.activeSide() === this.side());
|
||||||
@@ -88,7 +92,7 @@ export class FilePanel {
|
|||||||
|
|
||||||
headerContextMenu(event: MouseEvent): void {
|
headerContextMenu(event: MouseEvent): void {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void this.rpc.request.showColumnsMenu({ visible: this.settings.columns() });
|
this.menu.open(event, this.menu.columnItems(this.settings.columns()));
|
||||||
}
|
}
|
||||||
|
|
||||||
pickDrive(drive: string): void {
|
pickDrive(drive: string): void {
|
||||||
@@ -218,7 +222,7 @@ export class FilePanel {
|
|||||||
const panel = this.panel();
|
const panel = this.panel();
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
panel.selectRange(this.lastClickedIndex, index);
|
panel.selectRange(this.lastClickedIndex, index);
|
||||||
} else if (event.metaKey || event.ctrlKey) {
|
} else if (this.shortcuts.isMod(event)) {
|
||||||
const entry = panel.sorted()[index];
|
const entry = panel.sorted()[index];
|
||||||
if (entry) panel.toggleSelect(entry.name);
|
if (entry) panel.toggleSelect(entry.name);
|
||||||
} else {
|
} else {
|
||||||
@@ -238,14 +242,14 @@ export class FilePanel {
|
|||||||
// standard behavior: keep the selection when right-clicking inside it
|
// standard behavior: keep the selection when right-clicking inside it
|
||||||
if (entry && !panel.selected().has(entry.name)) panel.clearSelection();
|
if (entry && !panel.selected().has(entry.name)) panel.clearSelection();
|
||||||
panel.cursor.set(index);
|
panel.cursor.set(index);
|
||||||
void this.actions.openContextMenu({ side: this.side(), panel, entry });
|
this.actions.openContextMenu(event, { side: this.side(), panel, entry });
|
||||||
}
|
}
|
||||||
|
|
||||||
listContextMenu(event: MouseEvent): void {
|
listContextMenu(event: MouseEvent): void {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (this.blocked()) return;
|
if (this.blocked()) return;
|
||||||
this.activate();
|
this.activate();
|
||||||
void this.actions.openContextMenu({ side: this.side(), panel: this.panel(), entry: null });
|
this.actions.openContextMenu(event, { side: this.side(), panel: this.panel(), entry: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
openAt(index: number): void {
|
openAt(index: number): void {
|
||||||
@@ -264,7 +268,7 @@ export class FilePanel {
|
|||||||
this.panels.setActiveSide(this.panels.otherSide(this.side()));
|
this.panels.setActiveSide(this.panels.otherSide(this.side()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ((event.metaKey || event.ctrlKey) && key.toLowerCase() === 'a') {
|
if (this.shortcuts.isMod(event) && key.toLowerCase() === 'a') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
panel.selectAll();
|
panel.selectAll();
|
||||||
return;
|
return;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { App } from './app';
|
||||||
|
import { ConnectionManager } from './connection-manager/connection-manager';
|
||||||
|
import { EditorWindow } from './editor-window/editor-window';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three window roots of the modern (shadcn-style) skin, keyed by APP_MODE.
|
||||||
|
*
|
||||||
|
* Same shape as SEVEN_ROOTS, which is the point: main.ts picks a tree by skin
|
||||||
|
* and then a root by mode, without knowing anything about either skin. This
|
||||||
|
* skin has no title-bar component — it defers to the native window frame
|
||||||
|
* (hiddenInset on macOS), which is exactly what IS_MAC_CHROME accounts for.
|
||||||
|
*/
|
||||||
|
export const SHAD_ROOTS = {
|
||||||
|
main: App,
|
||||||
|
manager: ConnectionManager,
|
||||||
|
editor: EditorWindow,
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
shadlike skin — the modern, shadcn-style look
|
||||||
|
==========================================================================
|
||||||
|
|
||||||
|
Intentionally near-empty, and that is not an oversight.
|
||||||
|
|
||||||
|
This skin IS the shared foundation: the OKLCH token system, `@theme inline`,
|
||||||
|
the panel-chrome metrics and the accent axis all live in ui/src/styles.css
|
||||||
|
because both skins consume them, and everything else the modern look needs is
|
||||||
|
a Tailwind utility written inline in its templates. There is no third-party
|
||||||
|
framework to bridge and no cascade inversion to undo, so there is simply
|
||||||
|
nothing left for a skin-level stylesheet to say.
|
||||||
|
|
||||||
|
Compare app/skins/7like/7like.css, which is 630 lines: all of it exists to
|
||||||
|
import 7.css above `utilities`, re-point the app's semantic tokens at Win7
|
||||||
|
values, and hand individual properties back where 7.css's dialog metrics are
|
||||||
|
wrong for this app.
|
||||||
|
|
||||||
|
This file exists so the folder is self-contained and symmetrical with the
|
||||||
|
other skin — modern-only rules that should not leak into the win7 cascade
|
||||||
|
have a home here. Rules that BOTH skins need belong in ui/src/styles.css
|
||||||
|
instead; anything added here is dead weight under .win7.
|
||||||
|
|
||||||
|
Scoping note: 7like scopes itself with the `.win7` class that 7.scoped.css
|
||||||
|
requires. This skin is the default (no marker class), so any rule added here
|
||||||
|
must NOT be written so broadly that it also matches under .win7 — prefer
|
||||||
|
scoping to the component, not to bare elements. */
|
||||||
+6
-6
@@ -1,18 +1,18 @@
|
|||||||
import { Component, computed, inject } from '@angular/core';
|
import { Component, computed, inject } from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import { I18nService } from '../i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import type { TransferSnapshot } from '@shared/types';
|
import type { TransferSnapshot } from '@shared/types';
|
||||||
import { formatSpeed } from '../format';
|
import { formatSpeed } from '@core/format';
|
||||||
import { PanelsService } from '../panels.service';
|
import { PanelsService } from '@core/panels.service';
|
||||||
import { SettingsService } from '../settings.service';
|
import { SettingsService } from '@core/settings.service';
|
||||||
import { TransfersService } from '../transfers.service';
|
import { TransfersService } from '@core/transfers.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-status-bar',
|
selector: 'app-status-bar',
|
||||||
imports: [NgIcon],
|
imports: [NgIcon],
|
||||||
template: `
|
template: `
|
||||||
<div
|
<div
|
||||||
class="flex h-6 items-center justify-between gap-4 border-t border-border bg-chrome px-3 text-[11px] text-muted-foreground select-none"
|
class="flex h-6 items-center justify-between gap-4 border-t border-border bg-chrome px-3 text-[0.6875rem] text-muted-foreground select-none"
|
||||||
>
|
>
|
||||||
<!-- transfers summary; click toggles the detailed list -->
|
<!-- transfers summary; click toggles the detailed list -->
|
||||||
<button
|
<button
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import { Component, ElementRef, effect, inject, signal, viewChild } from '@angular/core';
|
import { Component, ElementRef, computed, effect, inject, signal, viewChild } from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import { BRANDING } from '@shared/branding';
|
import { BRANDING } from '@shared/branding';
|
||||||
import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
|
import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
|
||||||
import type { AccentTone } from '@shared/types';
|
import type { AccentTone } from '@shared/types';
|
||||||
import { I18nService } from '../i18n.service';
|
import { ContextMenuService } from '@core/context-menu.service';
|
||||||
import { PanelsService } from '../panels.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import { IS_MAC_CHROME, IS_WIN7_CHROME, MOD_LABEL } from '../platform';
|
import { PanelsService, type Workspace } from '@core/panels.service';
|
||||||
import { RpcService } from '../rpc.service';
|
import { APP_SKIN, IS_MAC_CHROME, MOD_LABEL } from '@core/platform';
|
||||||
import { SettingsService } from '../settings.service';
|
import { RpcService } from '@core/rpc.service';
|
||||||
import { UpdaterService } from '../updater.service';
|
import { SettingsService } from '@core/settings.service';
|
||||||
|
import { UpdaterService } from '@core/updater.service';
|
||||||
|
|
||||||
const SKINS = ['modern', 'win7'] as const;
|
const SKINS = ['modern', 'win7'] as const;
|
||||||
const THEMES = ['system', 'light', 'dark'] as const;
|
const THEMES = ['system', 'light', 'dark'] as const;
|
||||||
@@ -30,7 +31,6 @@ const SPEED_UNITS = [
|
|||||||
{ value: 'MBps', label: 'MB/s' },
|
{ value: 'MBps', label: 'MB/s' },
|
||||||
{ value: 'Mbps', label: 'Mbps' },
|
{ value: 'Mbps', label: 'Mbps' },
|
||||||
] as const;
|
] as const;
|
||||||
const TEXT_SIZES = ['compact', 'default', 'large'] as const;
|
|
||||||
const ICON_COLOR_MODES = ['plain', 'multi'] as const;
|
const ICON_COLOR_MODES = ['plain', 'multi'] as const;
|
||||||
const TRANSFER_MODES = ['single', 'multi'] as const;
|
const TRANSFER_MODES = ['single', 'multi'] as const;
|
||||||
const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||||
@@ -42,7 +42,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
<!-- On macOS this row IS the titlebar (hiddenInset): taller, padded past the
|
<!-- On macOS this row IS the titlebar (hiddenInset): taller, padded past the
|
||||||
traffic lights, and draggable. Elsewhere it's a normal compact row. -->
|
traffic lights, and draggable. Elsewhere it's a normal compact row. -->
|
||||||
<div
|
<div
|
||||||
class="w7-toolbar relative flex items-center gap-0.5 border-b border-border bg-chrome pr-1.5 electrobun-webkit-app-region-drag"
|
class="relative flex items-center gap-0.5 border-b border-border bg-chrome pr-1.5 electrobun-webkit-app-region-drag"
|
||||||
[class.h-10]="isMacChrome"
|
[class.h-10]="isMacChrome"
|
||||||
[class.pl-24]="isMacChrome"
|
[class.pl-24]="isMacChrome"
|
||||||
[class.pl-1.5]="!isMacChrome"
|
[class.pl-1.5]="!isMacChrome"
|
||||||
@@ -59,6 +59,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
[class.bg-accent]="ws.id === panels.activeWorkspaceId()"
|
[class.bg-accent]="ws.id === panels.activeWorkspaceId()"
|
||||||
[class.opacity-50]="draggedId() === ws.id"
|
[class.opacity-50]="draggedId() === ws.id"
|
||||||
[style.box-shadow]="dropIndicatorShadow(i)"
|
[style.box-shadow]="dropIndicatorShadow(i)"
|
||||||
|
(contextmenu)="onTabContextMenu($event, ws, i)"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
(dragstart)="onDragStart($event, ws.id)"
|
(dragstart)="onDragStart($event, ws.id)"
|
||||||
(dragover)="onDragOver($event, i)"
|
(dragover)="onDragOver($event, i)"
|
||||||
@@ -114,7 +115,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
(click)="onUpdateClick()"
|
(click)="onUpdateClick()"
|
||||||
[disabled]="updater.state() === 'downloading'"
|
[disabled]="updater.state() === 'downloading'"
|
||||||
[title]="i18n.t('tabBar.updateTooltip', { version: updater.version() })"
|
[title]="i18n.t('tabBar.updateTooltip', { version: updater.version() })"
|
||||||
class="relative mr-1 grid h-6 place-items-center overflow-hidden rounded-md px-2.5 text-[11px] font-medium bg-primary text-primary-foreground electrobun-webkit-app-region-no-drag hover:opacity-90 disabled:cursor-default"
|
class="relative mr-1 grid h-6 place-items-center overflow-hidden rounded-md px-2.5 text-[0.6875rem] font-medium bg-primary text-primary-foreground electrobun-webkit-app-region-no-drag hover:opacity-90 disabled:cursor-default"
|
||||||
>
|
>
|
||||||
@if (updater.state() === 'downloading') {
|
@if (updater.state() === 'downloading') {
|
||||||
<span
|
<span
|
||||||
@@ -142,7 +143,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
<button
|
<button
|
||||||
(click)="openManager()"
|
(click)="openManager()"
|
||||||
[title]="i18n.t('tabBar.manageConnections')"
|
[title]="i18n.t('tabBar.manageConnections')"
|
||||||
class="grid h-6 px-2 py-1 place-items-center rounded-md text-[11px] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
class="grid h-6 px-2 py-1 place-items-center rounded-md text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
<!-- <ng-icon name="mynaCog" size="0.9rem" /> -->
|
<!-- <ng-icon name="mynaCog" size="0.9rem" /> -->
|
||||||
{{ i18n.t('tabBar.connections') }}
|
{{ i18n.t('tabBar.connections') }}
|
||||||
@@ -168,7 +169,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.language') }}</span>
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.language') }}</span>
|
||||||
<select
|
<select
|
||||||
(change)="settings.setLocale($any($event.target).value)"
|
(change)="settings.setLocale($any($event.target).value)"
|
||||||
class="h-7 rounded-md border border-input bg-background px-1.5 text-[11px] outline-none focus:ring-2 focus:ring-ring"
|
class="h-7 rounded-md border border-input bg-background px-1.5 text-[0.6875rem] outline-none focus:ring-2 focus:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="system" [selected]="settings.locale() === 'system'">
|
<option value="system" [selected]="settings.locale() === 'system'">
|
||||||
{{ i18n.t('options.system') }}
|
{{ i18n.t('options.system') }}
|
||||||
@@ -181,26 +182,32 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Skin: a LAUNCH-TIME choice. It swaps the whole component tree
|
||||||
|
(ui/src/app/skins/) and the native frame, and neither can change
|
||||||
|
on a live window — the tree is bootstrapped from the preload-baked
|
||||||
|
__APP_SKIN and titleBarStyle/transparent are BrowserWindow
|
||||||
|
constructor options. Clicking only saves; the running UI is left
|
||||||
|
alone. Highlight the SAVED value so the click registers, and show
|
||||||
|
the pending hint whenever it differs from the booted skin. -->
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.skin.label') }}</span>
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.skin.label') }}</span>
|
||||||
<div class="flex rounded-md border border-border p-0.5">
|
<div class="flex rounded-md border border-border p-0.5">
|
||||||
@for (option of skins; track option) {
|
@for (s of skins; track s) {
|
||||||
<button
|
<button
|
||||||
(click)="settings.setSkin(option)"
|
(click)="settings.setSkin(s)"
|
||||||
class="rounded px-2 py-0.5 text-[11px]"
|
[title]="i18n.t('options.skin.restartHint')"
|
||||||
[class.bg-accent]="settings.skin() === option"
|
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||||
[class.text-foreground]="settings.skin() === option"
|
[class.bg-accent]="settings.skin() === s"
|
||||||
[class.text-muted-foreground]="settings.skin() !== option"
|
[class.text-foreground]="settings.skin() === s"
|
||||||
|
[class.text-muted-foreground]="settings.skin() !== s"
|
||||||
>
|
>
|
||||||
{{ i18n.t('options.skin.' + option) }}
|
{{ i18n.t('options.skin.' + s) }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- The CSS half of the skin flips live; the window frame does not
|
@if (skinPending()) {
|
||||||
(titleBarStyle is fixed at construction — see chromeOptions). -->
|
<p class="mt-1 text-right text-[0.625rem] text-muted-foreground">
|
||||||
@if (settings.skin() !== bootSkin) {
|
|
||||||
<p class="mt-1 text-right text-[10px] text-muted-foreground">
|
|
||||||
{{ i18n.t('options.skin.restartHint') }}
|
{{ i18n.t('options.skin.restartHint') }}
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
@@ -211,7 +218,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
@for (theme of themes; track theme) {
|
@for (theme of themes; track theme) {
|
||||||
<button
|
<button
|
||||||
(click)="settings.setTheme(theme)"
|
(click)="settings.setTheme(theme)"
|
||||||
class="rounded px-2 py-0.5 text-[11px]"
|
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||||
[class.bg-accent]="settings.theme() === theme"
|
[class.bg-accent]="settings.theme() === theme"
|
||||||
[class.text-foreground]="settings.theme() === theme"
|
[class.text-foreground]="settings.theme() === theme"
|
||||||
[class.text-muted-foreground]="settings.theme() !== theme"
|
[class.text-muted-foreground]="settings.theme() !== theme"
|
||||||
@@ -229,7 +236,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
@for (tone of darkTones; track tone) {
|
@for (tone of darkTones; track tone) {
|
||||||
<button
|
<button
|
||||||
(click)="settings.setDarkTone(tone)"
|
(click)="settings.setDarkTone(tone)"
|
||||||
class="rounded px-2 py-0.5 text-[11px]"
|
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||||
[class.bg-accent]="settings.darkTone() === tone"
|
[class.bg-accent]="settings.darkTone() === tone"
|
||||||
[class.text-foreground]="settings.darkTone() === tone"
|
[class.text-foreground]="settings.darkTone() === tone"
|
||||||
[class.text-muted-foreground]="settings.darkTone() !== tone"
|
[class.text-muted-foreground]="settings.darkTone() !== tone"
|
||||||
@@ -265,7 +272,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
@for (mode of iconColorModes; track mode) {
|
@for (mode of iconColorModes; track mode) {
|
||||||
<button
|
<button
|
||||||
(click)="settings.setIconColors(mode)"
|
(click)="settings.setIconColors(mode)"
|
||||||
class="rounded px-2 py-0.5 text-[11px]"
|
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||||
[class.bg-accent]="settings.iconColors() === mode"
|
[class.bg-accent]="settings.iconColors() === mode"
|
||||||
[class.text-foreground]="settings.iconColors() === mode"
|
[class.text-foreground]="settings.iconColors() === mode"
|
||||||
[class.text-muted-foreground]="settings.iconColors() !== mode"
|
[class.text-muted-foreground]="settings.iconColors() !== mode"
|
||||||
@@ -276,20 +283,35 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Interface scale: a continuous stepper, 50%..400% in 10-point
|
||||||
|
steps. One lever moves the whole UI (root font-size; everything
|
||||||
|
is rem), which is also why this popover itself grows with it. -->
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.interface.label') }}</span>
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.interface.label') }}</span>
|
||||||
<div class="flex rounded-md border border-border p-0.5">
|
<div class="flex items-center rounded-md border border-border p-0.5">
|
||||||
@for (size of textSizes; track size) {
|
<button
|
||||||
<button
|
(click)="settings.setUiScale(settings.uiScale() - 0.1)"
|
||||||
(click)="settings.setTextSize(size)"
|
[disabled]="settings.uiScale() <= 0.5"
|
||||||
class="rounded px-2 py-0.5 text-[11px]"
|
[title]="i18n.t('options.interface.smaller')"
|
||||||
[class.bg-accent]="settings.textSize() === size"
|
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
|
||||||
[class.text-foreground]="settings.textSize() === size"
|
>
|
||||||
[class.text-muted-foreground]="settings.textSize() !== size"
|
−
|
||||||
>
|
</button>
|
||||||
{{ i18n.t('options.interface.' + size) }}
|
<button
|
||||||
</button>
|
(click)="settings.setUiScale(1)"
|
||||||
}
|
[title]="i18n.t('options.interface.reset')"
|
||||||
|
class="w-11 rounded px-1 py-0.5 text-center text-[0.6875rem] tabular-nums hover:bg-accent"
|
||||||
|
>
|
||||||
|
{{ scalePercent() }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="settings.setUiScale(settings.uiScale() + 0.1)"
|
||||||
|
[disabled]="settings.uiScale() >= 4"
|
||||||
|
[title]="i18n.t('options.interface.larger')"
|
||||||
|
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -299,7 +321,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
@for (unit of speedUnits; track unit.value) {
|
@for (unit of speedUnits; track unit.value) {
|
||||||
<button
|
<button
|
||||||
(click)="settings.setSpeedUnit(unit.value)"
|
(click)="settings.setSpeedUnit(unit.value)"
|
||||||
class="rounded px-2 py-0.5 text-[11px]"
|
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||||
[class.bg-accent]="settings.speedUnit() === unit.value"
|
[class.bg-accent]="settings.speedUnit() === unit.value"
|
||||||
[class.text-foreground]="settings.speedUnit() === unit.value"
|
[class.text-foreground]="settings.speedUnit() === unit.value"
|
||||||
[class.text-muted-foreground]="settings.speedUnit() !== unit.value"
|
[class.text-muted-foreground]="settings.speedUnit() !== unit.value"
|
||||||
@@ -316,7 +338,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
@for (mode of transferModes; track mode) {
|
@for (mode of transferModes; track mode) {
|
||||||
<button
|
<button
|
||||||
(click)="settings.setTransferMode(mode)"
|
(click)="settings.setTransferMode(mode)"
|
||||||
class="rounded px-2 py-0.5 text-[11px]"
|
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||||
[class.bg-accent]="settings.transferMode() === mode"
|
[class.bg-accent]="settings.transferMode() === mode"
|
||||||
[class.text-foreground]="settings.transferMode() === mode"
|
[class.text-foreground]="settings.transferMode() === mode"
|
||||||
[class.text-muted-foreground]="settings.transferMode() !== mode"
|
[class.text-muted-foreground]="settings.transferMode() !== mode"
|
||||||
@@ -334,7 +356,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
@for (count of concurrencyChoices; track count) {
|
@for (count of concurrencyChoices; track count) {
|
||||||
<button
|
<button
|
||||||
(click)="settings.setTransferConcurrency(count)"
|
(click)="settings.setTransferConcurrency(count)"
|
||||||
class="rounded px-2 py-0.5 text-[11px] tabular-nums"
|
class="rounded px-2 py-0.5 text-[0.6875rem] tabular-nums"
|
||||||
[class.bg-accent]="settings.transferConcurrency() === count"
|
[class.bg-accent]="settings.transferConcurrency() === count"
|
||||||
[class.text-foreground]="settings.transferConcurrency() === count"
|
[class.text-foreground]="settings.transferConcurrency() === count"
|
||||||
[class.text-muted-foreground]="settings.transferConcurrency() !== count"
|
[class.text-muted-foreground]="settings.transferConcurrency() !== count"
|
||||||
@@ -351,7 +373,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
<div class="flex rounded-md border border-border p-0.5">
|
<div class="flex rounded-md border border-border p-0.5">
|
||||||
<button
|
<button
|
||||||
(click)="settings.setEditor('builtin')"
|
(click)="settings.setEditor('builtin')"
|
||||||
class="rounded px-2 py-0.5 text-[11px]"
|
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||||
[class.bg-accent]="settings.editor() === 'builtin'"
|
[class.bg-accent]="settings.editor() === 'builtin'"
|
||||||
[class.text-foreground]="settings.editor() === 'builtin'"
|
[class.text-foreground]="settings.editor() === 'builtin'"
|
||||||
[class.text-muted-foreground]="settings.editor() !== 'builtin'"
|
[class.text-muted-foreground]="settings.editor() !== 'builtin'"
|
||||||
@@ -360,7 +382,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="settings.setEditor('custom')"
|
(click)="settings.setEditor('custom')"
|
||||||
class="rounded px-2 py-0.5 text-[11px]"
|
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||||
[class.bg-accent]="settings.editor() === 'custom'"
|
[class.bg-accent]="settings.editor() === 'custom'"
|
||||||
[class.text-foreground]="settings.editor() === 'custom'"
|
[class.text-foreground]="settings.editor() === 'custom'"
|
||||||
[class.text-muted-foreground]="settings.editor() !== 'custom'"
|
[class.text-muted-foreground]="settings.editor() !== 'custom'"
|
||||||
@@ -378,7 +400,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
<div class="flex shrink-0 items-center gap-1">
|
<div class="flex shrink-0 items-center gap-1">
|
||||||
<button
|
<button
|
||||||
(click)="pickEditorApp()"
|
(click)="pickEditorApp()"
|
||||||
class="rounded-md border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
|
class="rounded-md border border-border px-2 py-0.5 text-[0.6875rem] hover:bg-accent"
|
||||||
>
|
>
|
||||||
{{ i18n.t('common.choose') }}
|
{{ i18n.t('common.choose') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -400,7 +422,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
<div class="flex rounded-md border border-border p-0.5">
|
<div class="flex rounded-md border border-border p-0.5">
|
||||||
<button
|
<button
|
||||||
(click)="settings.setAutoClearCompleted(true)"
|
(click)="settings.setAutoClearCompleted(true)"
|
||||||
class="rounded px-2 py-0.5 text-[11px]"
|
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||||
[class.bg-accent]="settings.autoClearCompleted()"
|
[class.bg-accent]="settings.autoClearCompleted()"
|
||||||
[class.text-foreground]="settings.autoClearCompleted()"
|
[class.text-foreground]="settings.autoClearCompleted()"
|
||||||
[class.text-muted-foreground]="!settings.autoClearCompleted()"
|
[class.text-muted-foreground]="!settings.autoClearCompleted()"
|
||||||
@@ -409,7 +431,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="settings.setAutoClearCompleted(false)"
|
(click)="settings.setAutoClearCompleted(false)"
|
||||||
class="rounded px-2 py-0.5 text-[11px]"
|
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||||
[class.bg-accent]="!settings.autoClearCompleted()"
|
[class.bg-accent]="!settings.autoClearCompleted()"
|
||||||
[class.text-foreground]="!settings.autoClearCompleted()"
|
[class.text-foreground]="!settings.autoClearCompleted()"
|
||||||
[class.text-muted-foreground]="settings.autoClearCompleted()"
|
[class.text-muted-foreground]="settings.autoClearCompleted()"
|
||||||
@@ -424,11 +446,11 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||||
<span class="text-xs text-muted-foreground">Auto-refresh</span>
|
<span class="text-xs text-muted-foreground">Auto-refresh</span>
|
||||||
<div class="flex rounded-md border border-border p-0.5">
|
<div class="flex rounded-md border border-border p-0.5">
|
||||||
<button (click)="settings.setAutoRefresh(true)" class="rounded px-2 py-0.5 text-[11px]"
|
<button (click)="settings.setAutoRefresh(true)" class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||||
[class.bg-accent]="settings.autoRefresh()"
|
[class.bg-accent]="settings.autoRefresh()"
|
||||||
[class.text-foreground]="settings.autoRefresh()"
|
[class.text-foreground]="settings.autoRefresh()"
|
||||||
[class.text-muted-foreground]="!settings.autoRefresh()">On</button>
|
[class.text-muted-foreground]="!settings.autoRefresh()">On</button>
|
||||||
<button (click)="settings.setAutoRefresh(false)" class="rounded px-2 py-0.5 text-[11px]"
|
<button (click)="settings.setAutoRefresh(false)" class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||||
[class.bg-accent]="!settings.autoRefresh()"
|
[class.bg-accent]="!settings.autoRefresh()"
|
||||||
[class.text-foreground]="!settings.autoRefresh()"
|
[class.text-foreground]="!settings.autoRefresh()"
|
||||||
[class.text-muted-foreground]="settings.autoRefresh()">Off</button>
|
[class.text-muted-foreground]="settings.autoRefresh()">Off</button>
|
||||||
@@ -439,7 +461,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
<!-- about footer -->
|
<!-- about footer -->
|
||||||
<button
|
<button
|
||||||
(click)="showAbout()"
|
(click)="showAbout()"
|
||||||
class="mt-3 w-full border-t border-border pt-2 text-center text-[11px] text-muted-foreground hover:text-foreground"
|
class="mt-3 w-full border-t border-border pt-2 text-center text-[0.6875rem] text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
{{ appName }} · {{ buildLabel }}
|
{{ appName }} · {{ buildLabel }}
|
||||||
</button>
|
</button>
|
||||||
@@ -453,6 +475,7 @@ export class TabBar {
|
|||||||
readonly settings = inject(SettingsService);
|
readonly settings = inject(SettingsService);
|
||||||
readonly i18n = inject(I18nService);
|
readonly i18n = inject(I18nService);
|
||||||
readonly updater = inject(UpdaterService);
|
readonly updater = inject(UpdaterService);
|
||||||
|
private readonly contextMenu = inject(ContextMenuService);
|
||||||
private readonly rpc = inject(RpcService);
|
private readonly rpc = inject(RpcService);
|
||||||
readonly settingsOpen = signal(false);
|
readonly settingsOpen = signal(false);
|
||||||
readonly localeOptions = SUPPORTED;
|
readonly localeOptions = SUPPORTED;
|
||||||
@@ -462,15 +485,19 @@ export class TabBar {
|
|||||||
readonly appName = BRANDING.appName;
|
readonly appName = BRANDING.appName;
|
||||||
readonly buildLabel = BRANDING.buildLabel;
|
readonly buildLabel = BRANDING.buildLabel;
|
||||||
readonly skins = SKINS;
|
readonly skins = SKINS;
|
||||||
/** What the window was BUILT with, not the current setting — the frameless
|
/**
|
||||||
* chrome only changes on reopen, so the hint compares against this. */
|
* The saved skin differs from the one this window actually booted, so the
|
||||||
readonly bootSkin: (typeof SKINS)[number] = IS_WIN7_CHROME ? 'win7' : 'modern';
|
* choice is waiting on a restart. APP_SKIN is the preload-baked value that
|
||||||
|
* picked the running component tree; settings.skin() is merely persisted.
|
||||||
|
*/
|
||||||
|
readonly skinPending = computed(() => this.settings.skin() !== APP_SKIN);
|
||||||
readonly themes = THEMES;
|
readonly themes = THEMES;
|
||||||
readonly darkTones = DARK_TONES;
|
readonly darkTones = DARK_TONES;
|
||||||
readonly accents = ACCENTS;
|
readonly accents = ACCENTS;
|
||||||
readonly iconColorModes = ICON_COLOR_MODES;
|
readonly iconColorModes = ICON_COLOR_MODES;
|
||||||
readonly speedUnits = SPEED_UNITS;
|
readonly speedUnits = SPEED_UNITS;
|
||||||
readonly textSizes = TEXT_SIZES;
|
/** "100%" for the stepper readout; the value itself lives in settings. */
|
||||||
|
readonly scalePercent = computed(() => `${Math.round(this.settings.uiScale() * 100)}%`);
|
||||||
readonly transferModes = TRANSFER_MODES;
|
readonly transferModes = TRANSFER_MODES;
|
||||||
readonly concurrencyChoices = CONCURRENCY_CHOICES;
|
readonly concurrencyChoices = CONCURRENCY_CHOICES;
|
||||||
|
|
||||||
@@ -495,6 +522,24 @@ export class TabBar {
|
|||||||
this.panels.closeWorkspace(id);
|
this.panels.closeWorkspace(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Right-click a tab. The menu targets THIS tab, not the active one — its
|
||||||
|
* actions all carry the id — so right-clicking a background tab does not
|
||||||
|
* yank focus to it first, the way Explorer and every browser behave.
|
||||||
|
*/
|
||||||
|
onTabContextMenu(event: MouseEvent, workspace: Workspace, index: number): void {
|
||||||
|
event.preventDefault();
|
||||||
|
this.contextMenu.open(
|
||||||
|
event,
|
||||||
|
this.contextMenu.tabItems({
|
||||||
|
id: workspace.id,
|
||||||
|
index,
|
||||||
|
tabCount: this.panels.workspaces().length,
|
||||||
|
isRemote: this.panels.workspaceIsRemote(workspace),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */
|
/** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */
|
||||||
editorAppName(): string {
|
editorAppName(): string {
|
||||||
const path = this.settings.editorApp();
|
const path = this.settings.editorApp();
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { NgIcon } from '@ng-icons/core';
|
||||||
|
import { ToastService } from '@core/toast.service';
|
||||||
|
|
||||||
|
/** Toast stack overlay; shared by the main window and editor windows. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-toasts',
|
||||||
|
imports: [NgIcon],
|
||||||
|
template: `
|
||||||
|
<div class="pointer-events-none fixed right-3 bottom-8 z-50 flex w-80 flex-col gap-2">
|
||||||
|
@for (t of toast.toasts(); track t.id) {
|
||||||
|
<div
|
||||||
|
(mouseenter)="toast.pause(t.id)"
|
||||||
|
(mouseleave)="toast.resume(t.id)"
|
||||||
|
class="group pointer-events-auto relative flex items-start gap-2 overflow-hidden rounded-lg border border-border bg-card p-3 text-xs text-card-foreground shadow-lg"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
[name]="toastIcon(t.kind)"
|
||||||
|
size="1rem"
|
||||||
|
[class]="t.kind === 'error' ? 'shrink-0 text-destructive' : 'shrink-0 text-success'"
|
||||||
|
/>
|
||||||
|
<span class="min-w-0 flex-1 wrap-break-word">{{ t.text }}</span>
|
||||||
|
<button
|
||||||
|
(click)="toast.dismiss(t.id)"
|
||||||
|
class="shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaX" size="0.9rem" />
|
||||||
|
</button>
|
||||||
|
<!-- auto-dismiss countdown; freezes with the timer while hovered -->
|
||||||
|
<div
|
||||||
|
class="toast-countdown absolute inset-x-0 bottom-0 h-0.5 origin-left"
|
||||||
|
[class.bg-destructive]="t.kind === 'error'"
|
||||||
|
[class.bg-success]="t.kind === 'success'"
|
||||||
|
[class.bg-primary]="t.kind === 'info'"
|
||||||
|
[style.animation-duration.ms]="t.ttlMs"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class Toasts {
|
||||||
|
readonly toast = inject(ToastService);
|
||||||
|
|
||||||
|
toastIcon(kind: 'error' | 'info' | 'success'): string {
|
||||||
|
if (kind === 'error') return 'mynaXCircle';
|
||||||
|
if (kind === 'success') return 'mynaCheckCircle';
|
||||||
|
return 'mynaCheck';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
@if (transfers.expanded() && transfers.hasJobs()) {
|
||||||
|
<div class="flex items-center justify-between border-t border-border bg-chrome px-3 py-1 text-[0.6875rem] text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{{ i18n.t('transfer.summary', { active: transfers.activeJobs().length, finished: transfers.finishedJobs().length }) }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
(click)="transfers.clearFinished()"
|
||||||
|
[disabled]="!transfers.finishedJobs().length"
|
||||||
|
class="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaTrash" size="0.75rem" /> {{ i18n.t('transfer.clearFinished') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-40 overflow-y-auto border-t border-border bg-chrome">
|
||||||
|
@for (job of transfers.jobs(); track job.id) {
|
||||||
|
<div class="flex items-center gap-2 px-3 py-1.5 text-xs">
|
||||||
|
<ng-icon [name]="stateIcon(job)" size="0.95rem" [class]="stateClass(job)" />
|
||||||
|
<span class="w-40 shrink-0 truncate font-medium" [title]="job.srcPath">{{ job.name }}</span>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="truncate text-[0.6875rem] text-muted-foreground">{{ progressLabel(job) }}</div>
|
||||||
|
@if (isActive(job)) {
|
||||||
|
<div class="mt-0.5 h-1 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full bg-primary transition-[width] duration-100"
|
||||||
|
[style.width.%]="jobPercent(job)"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (isActive(job)) {
|
||||||
|
<button
|
||||||
|
(click)="transfers.cancel(job.id)"
|
||||||
|
[title]="i18n.t('transfer.cancel')"
|
||||||
|
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-destructive"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaX" size="0.9rem" />
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<button
|
||||||
|
(click)="transfers.dismiss(job.id)"
|
||||||
|
[title]="i18n.t('transfer.dismiss')"
|
||||||
|
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaX" size="0.9rem" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { NgIcon } from '@ng-icons/core';
|
||||||
|
import type { TransferSnapshot } from '@shared/types';
|
||||||
|
import { formatEta } from '@core/format';
|
||||||
|
import { I18nService } from '@core/i18n.service';
|
||||||
|
import { SettingsService } from '@core/settings.service';
|
||||||
|
import { TransfersService } from '@core/transfers.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-transfer-bar',
|
||||||
|
imports: [NgIcon],
|
||||||
|
templateUrl: './transfer-bar.html',
|
||||||
|
})
|
||||||
|
export class TransferBar {
|
||||||
|
readonly i18n = inject(I18nService);
|
||||||
|
readonly transfers = inject(TransfersService);
|
||||||
|
readonly settings = inject(SettingsService);
|
||||||
|
readonly formatSize = (bytes: number): string => this.i18n.size(bytes);
|
||||||
|
|
||||||
|
/** "14.2 MB/s · 0:42 left" while a job is moving bytes. */
|
||||||
|
speedLabel(job: TransferSnapshot): string {
|
||||||
|
if (job.state !== 'running') return '';
|
||||||
|
const speed = this.transfers.speeds().get(job.id);
|
||||||
|
if (!speed) return '';
|
||||||
|
const parts = [this.i18n.speed(speed, this.settings.speedUnit())];
|
||||||
|
const remaining = job.totalBytes - job.doneBytes;
|
||||||
|
if (remaining > 0 && speed > 0) {
|
||||||
|
parts.push(this.i18n.t('transfer.left', { eta: formatEta(remaining / speed) }));
|
||||||
|
}
|
||||||
|
return parts.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
stateIcon(job: TransferSnapshot): string {
|
||||||
|
switch (job.state) {
|
||||||
|
case 'completed':
|
||||||
|
return 'mynaCheckCircle';
|
||||||
|
case 'failed':
|
||||||
|
case 'cancelled':
|
||||||
|
return 'mynaXCircle';
|
||||||
|
default:
|
||||||
|
return 'mynaSpinner';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stateClass(job: TransferSnapshot): string {
|
||||||
|
switch (job.state) {
|
||||||
|
case 'completed':
|
||||||
|
return 'shrink-0 text-success';
|
||||||
|
case 'failed':
|
||||||
|
return 'shrink-0 text-destructive';
|
||||||
|
case 'cancelled':
|
||||||
|
return 'shrink-0 text-muted-foreground';
|
||||||
|
default:
|
||||||
|
return 'shrink-0 animate-spin text-primary';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isActive(job: TransferSnapshot): boolean {
|
||||||
|
return job.state === 'queued' || job.state === 'scanning' || job.state === 'running';
|
||||||
|
}
|
||||||
|
|
||||||
|
jobPercent(job: TransferSnapshot): number {
|
||||||
|
if (!job.totalBytes) return job.state === 'completed' ? 100 : 0;
|
||||||
|
return Math.min(100, (job.doneBytes / job.totalBytes) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
progressLabel(job: TransferSnapshot): string {
|
||||||
|
if (job.state === 'queued') return this.i18n.t('transfer.queuedState');
|
||||||
|
if (job.state === 'scanning') return this.i18n.t('transfer.scanning');
|
||||||
|
if (job.state === 'failed')
|
||||||
|
return `${job.currentFile ? `${job.currentFile} — ` : ''}${job.error ?? this.i18n.t('transfer.failedState')}`;
|
||||||
|
if (job.state === 'cancelled') return this.i18n.t('transfer.cancelled');
|
||||||
|
if (job.state === 'completed')
|
||||||
|
return this.i18n.t('transfer.completedFiles', {
|
||||||
|
count: job.doneFiles,
|
||||||
|
size: this.i18n.size(job.doneBytes),
|
||||||
|
});
|
||||||
|
const speed = this.speedLabel(job);
|
||||||
|
const base = `${job.currentFile} · ${this.i18n.size(job.doneBytes)} / ${this.i18n.size(job.totalBytes)}`;
|
||||||
|
return speed ? `${base} · ${speed}` : base;
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-24
@@ -6,31 +6,13 @@
|
|||||||
<base href="/" />
|
<base href="/" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
<!-- Pre-stylesheet paint: the window background must be right BEFORE
|
|
||||||
styles.css parses or new windows flash white/inverted. Only the
|
|
||||||
html background decision lives here — values must stay in sync with
|
|
||||||
the corresponding --background tokens in styles.css. The data-*
|
|
||||||
attributes are baked in by the bun preload (buildPreload in
|
|
||||||
src/bun/windows.ts) so forced themes are correct at first paint. -->
|
|
||||||
<style>
|
<style>
|
||||||
html { background: oklch(1 0 0); }
|
/* The native window is transparent (chromeOptions), so the page itself
|
||||||
html[data-theme='dark'] { background: oklch(0.2178 0.002 107); }
|
must not paint a background — the Aero frame in styles.css is the only
|
||||||
html[data-theme='dark'][data-dark-tone='darker'] { background: oklch(0 0 0); }
|
opaque surface, and its radius/drop shadow need to fall outside it.
|
||||||
@media (prefers-color-scheme: dark) {
|
Windows are created hidden and revealed on dom-ready, so there is no
|
||||||
html:not([data-theme='light']) { background: oklch(0.2178 0.002 107); }
|
unthemed flash to guard against here any more. */
|
||||||
html:not([data-theme='light'])[data-dark-tone='darker'] { background: oklch(0 0 0); }
|
html.win7, html.win7 body { background: transparent; }
|
||||||
}
|
|
||||||
/* win7 skin: same four states, mirroring the .win7 --background values
|
|
||||||
in styles.css. The preload adds this class before any page script, so
|
|
||||||
it is already on <html> here. Omitting this arm makes every win7
|
|
||||||
window flash white before styles.css parses. */
|
|
||||||
html.win7 { background: #ffffff; }
|
|
||||||
html.win7[data-theme='dark'] { background: #1f1f1f; }
|
|
||||||
html.win7[data-theme='dark'][data-dark-tone='darker'] { background: #121212; }
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
html.win7:not([data-theme='light']) { background: #1f1f1f; }
|
|
||||||
html.win7:not([data-theme='light'])[data-dark-tone='darker'] { background: #121212; }
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+16
-7
@@ -1,10 +1,19 @@
|
|||||||
import { bootstrapApplication } from '@angular/platform-browser';
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
import { appConfig } from './app/app.config';
|
import { appConfig } from '@core/app.config';
|
||||||
import { App } from './app/app';
|
import { APP_MODE, APP_SKIN } from '@core/platform';
|
||||||
import { ConnectionManager } from './app/connection-manager/connection-manager';
|
import { SEVEN_ROOTS } from './app/skins/7like';
|
||||||
import { EditorWindow } from './app/editor-window/editor-window';
|
import { SHAD_ROOTS } from './app/skins/shadlike';
|
||||||
import { APP_MODE } from './app/platform';
|
|
||||||
|
|
||||||
// One bundle, three window kinds: the preload global picks the root component.
|
// One bundle, two skins x three window kinds. Both globals are baked into the
|
||||||
const root = APP_MODE === 'editor' ? EditorWindow : APP_MODE === 'manager' ? ConnectionManager : App;
|
// preload (src/bun/windows.ts buildPreload) because both are needed before the
|
||||||
|
// first paint: settings arrive over an async RPC, far too late to choose a
|
||||||
|
// component tree without flashing the wrong UI.
|
||||||
|
//
|
||||||
|
// Every skin's barrel exports the same { main, manager, editor } shape, so this
|
||||||
|
// file never learns anything about either tree's internals. Both are imported
|
||||||
|
// statically and ship in one bundle — the inactive skin costs bundle size but
|
||||||
|
// no runtime work, which is the right trade for a local desktop app and keeps
|
||||||
|
// bootstrap synchronous.
|
||||||
|
const tree = APP_SKIN === 'win7' ? SEVEN_ROOTS : SHAD_ROOTS;
|
||||||
|
const root = APP_MODE === 'editor' ? tree.editor : APP_MODE === 'manager' ? tree.manager : tree.main;
|
||||||
bootstrapApplication(root, appConfig).catch((err) => console.error(err));
|
bootstrapApplication(root, appConfig).catch((err) => console.error(err));
|
||||||
|
|||||||
+28
-338
@@ -9,15 +9,13 @@
|
|||||||
See the "Windows 7 skin" section at the bottom of this file. */
|
See the "Windows 7 skin" section at the bottom of this file. */
|
||||||
@layer theme, base, components, utilities, skin, skinfix;
|
@layer theme, base, components, utilities, skin, skinfix;
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
/* The SCOPED 7.css build: postcss-prefix-selector rewrote every selector to
|
/* The two skin stylesheets. Both ship in one bundle and are scoped by the
|
||||||
`.win7` (root rules) or `.win7 <sel>` (the rest), so the whole framework is
|
`.win7` class on <html>, exactly as when they lived in this file — only one
|
||||||
inert until SettingsService.applySkin puts that class on <html>. All 61
|
skin's rules can match at a time. They sit here because CSS requires every
|
||||||
icons are data URIs, so nothing needs copying into assets — and NOT the
|
@import to precede all other rules; layer assignment is fixed by the @layer
|
||||||
`7.inline.css` build, which flattens away the --w7-* vars the dark palette
|
statement above, not by this order. */
|
||||||
below depends on. The deep path is required: 7.css ships no `exports` map
|
@import "./app/skins/shadlike/shadlike.css";
|
||||||
and Tailwind's resolver only reads `style`/`mainFields`, so a bare
|
@import "./app/skins/7like/7like.css";
|
||||||
`@import "7.css"` finds no entry. */
|
|
||||||
@import "7.css/dist/7.scoped.css" layer(skin);
|
|
||||||
|
|
||||||
/* shadcn-style theme tokens. Light stays the zinc family; the dark neutrals are a
|
/* shadcn-style theme tokens. Light stays the zinc family; the dark neutrals are a
|
||||||
warm gray family (hue ~107) anchored on #1a1a19 — see the dark blocks below.
|
warm gray family (hue ~107) anchored on #1a1a19 — see the dark blocks below.
|
||||||
@@ -183,45 +181,33 @@
|
|||||||
--color-icon-media: var(--icon-media);
|
--color-icon-media: var(--icon-media);
|
||||||
--color-icon-archive: var(--icon-archive);
|
--color-icon-archive: var(--icon-archive);
|
||||||
--color-icon-code: var(--icon-code);
|
--color-icon-code: var(--icon-code);
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
/* rem subtrahends (4px/2px at the 16px root), NOT px: --radius is rem, and a
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
mixed-unit calc would stop the derived radii from following the interface
|
||||||
|
scale — corners would sharpen as the UI grew. */
|
||||||
|
--radius-sm: calc(var(--radius) - 0.25rem);
|
||||||
|
--radius-md: calc(var(--radius) - 0.125rem);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Interface-size setting (data-text set by SettingsService). Scales the whole
|
/* Panel-chrome metrics: file list, path bar, action buttons, source picker.
|
||||||
panel chrome: file list, path bar, action buttons, and the source picker
|
ALL REM, deliberately — the interface scale (settings.uiScale) works by
|
||||||
with its dropdown. The tab bar (including the options button) stays fixed.
|
setting the root font-size, so every rem value in the app scales from that
|
||||||
Default-tier values match the old hardcoded classes exactly. */
|
one lever. A px value here would freeze while everything around it grew,
|
||||||
|
which is exactly what happened to the win7 boxes before stage 1c. There is
|
||||||
|
only one tier now: the old data-text compact/large tiers are gone, replaced
|
||||||
|
by the continuous scale. Values are the old default tier verbatim
|
||||||
|
(13px -> 0.8125rem etc. at the 16px root), so scale 1.0 is pixel-identical
|
||||||
|
to the pre-scale app. */
|
||||||
:root {
|
:root {
|
||||||
--list-font: 13px;
|
--list-font: 0.8125rem;
|
||||||
--list-meta-font: 12px;
|
--list-meta-font: 0.75rem;
|
||||||
--path-font: 12px;
|
--path-font: 0.75rem;
|
||||||
--row-h: 28px;
|
--row-h: 1.75rem;
|
||||||
--path-ctl-h: 28px;
|
--path-ctl-h: 1.75rem;
|
||||||
--action-h: 24px;
|
--action-h: 1.5rem;
|
||||||
--action-font: 11px;
|
--action-font: 0.6875rem;
|
||||||
--action-icon: 0.9rem;
|
--action-icon: 0.9rem;
|
||||||
}
|
}
|
||||||
:root[data-text='compact'] {
|
|
||||||
--list-font: 12px;
|
|
||||||
--list-meta-font: 11px;
|
|
||||||
--path-font: 11px;
|
|
||||||
--row-h: 24px;
|
|
||||||
--path-ctl-h: 24px;
|
|
||||||
--action-h: 22px;
|
|
||||||
--action-font: 10px;
|
|
||||||
--action-icon: 0.85rem;
|
|
||||||
}
|
|
||||||
:root[data-text='large'] {
|
|
||||||
--list-font: 14px;
|
|
||||||
--list-meta-font: 13px;
|
|
||||||
--path-font: 13px;
|
|
||||||
--row-h: 32px;
|
|
||||||
--path-ctl-h: 32px;
|
|
||||||
--action-h: 28px;
|
|
||||||
--action-font: 12px;
|
|
||||||
--action-icon: 1.05rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Soft entrance for the panel busy states (connecting placeholder, load overlay). */
|
/* Soft entrance for the panel busy states (connecting placeholder, load overlay). */
|
||||||
@keyframes panel-fade-in {
|
@keyframes panel-fade-in {
|
||||||
@@ -296,299 +282,3 @@ body {
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================================================
|
|
||||||
Windows 7 skin — SettingsService.applySkin puts `.win7` on <html>
|
|
||||||
==========================================================================
|
|
||||||
|
|
||||||
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 -> ui/src/fonts/. 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, but in px so it tracks the interface-size axis below.
|
|
||||||
Restating the `font` SHORTHAND (not just the family) is deliberate:
|
|
||||||
7.css applies `font: var(--w7-font)` on 12 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: 12px;
|
|
||||||
--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 */
|
|
||||||
|
|
||||||
/* 5px, NOT 3px: `rounded-md` is 74 of the 112 radius utilities in the
|
|
||||||
templates and compiles to calc(var(--radius) - 2px), 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 and is unaffected. */
|
|
||||||
--radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Interface size: Win7 shipped this exact axis ("Smaller / Medium / Larger"),
|
|
||||||
so scaling is more authentic than disabling it. One indirection covers all
|
|
||||||
three tiers because var substitution is lazy. The --row-h / --list-font
|
|
||||||
block further up scales the file list independently and is untouched. */
|
|
||||||
:root.win7[data-text='compact'] { --w7-fs: 11px; }
|
|
||||||
:root.win7[data-text='large'] { --w7-fs: 13px; }
|
|
||||||
|
|
||||||
/* 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: #000000d9;
|
|
||||||
--w7-w-bg: #1f3a52;
|
|
||||||
--w7-tab-bg: #2b2b2b;
|
|
||||||
--w7-title-c: #8ab4e8;
|
|
||||||
--w7-link-c: #6fb3ff;
|
|
||||||
--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: #000000d9;
|
|
||||||
--w7-w-bg: #1f3a52;
|
|
||||||
--w7-tab-bg: #2b2b2b;
|
|
||||||
--w7-title-c: #8ab4e8;
|
|
||||||
--w7-link-c: #6fb3ff;
|
|
||||||
--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: #12202c;
|
|
||||||
--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: #12202c;
|
|
||||||
--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. */
|
|
||||||
:root.win7[data-accent] {
|
|
||||||
--w7-w-bg: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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. */
|
|
||||||
.win7 .w7-toolbar button,
|
|
||||||
.win7 .w7-flat {
|
|
||||||
background: revert-layer;
|
|
||||||
border-color: transparent;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
.win7 .w7-toolbar button::before,
|
|
||||||
.win7 .w7-toolbar button::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 {
|
|
||||||
/* Opt back IN to real Win7 push-button metrics, for the dialog and options
|
|
||||||
buttons where they are right. Inert under the modern skin. */
|
|
||||||
.win7 .w7-btn {
|
|
||||||
min-width: 75px;
|
|
||||||
min-height: 23px;
|
|
||||||
padding: 0 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+7
-1
@@ -4,8 +4,14 @@
|
|||||||
"compileOnSave": false,
|
"compileOnSave": false,
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
/* @shared -> the bun/UI shared code (src/shared). @core -> the skin-agnostic
|
||||||
|
UI layer (services, platform, formatters). @core is an ALIAS rather than a
|
||||||
|
relative path on purpose: skin components live at varying depths under
|
||||||
|
app/skins/<skin>/, and an alias keeps their imports identical wherever a
|
||||||
|
component is moved to. */
|
||||||
"paths": {
|
"paths": {
|
||||||
"@shared/*": ["../src/shared/*"]
|
"@shared/*": ["../src/shared/*"],
|
||||||
|
"@core/*": ["./src/app/core/*"]
|
||||||
},
|
},
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
"noPropertyAccessFromIndexSignature": true,
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user