Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a0c708140 | ||
|
|
4605a481f1 | ||
|
|
5ed029a2b8 | ||
|
|
c4364c1051 | ||
|
|
89af52e1c0 | ||
|
|
9c54a441a9 | ||
|
|
f101257f20 | ||
|
|
9f2a076e08 | ||
|
|
c8ccc6b918 | ||
|
|
5bbfc2f475 | ||
|
|
eafe426c8e | ||
|
|
bb97e5418f | ||
|
|
b35c30d5ab | ||
|
|
39e019af9f | ||
|
|
17bb1a14fb | ||
|
|
ab0bb4ef6f | ||
|
|
06a14fb6a1 | ||
|
|
7921b2986b | ||
|
|
aaa12c3e23 | ||
|
|
01739e4cf4 | ||
|
|
7b15e0982e | ||
|
|
0ae2a62229 | ||
|
|
1f773261a8 | ||
|
|
fe75904872 | ||
|
|
2294453c93 | ||
|
|
2db367318d | ||
|
|
66076cf1d7 | ||
|
|
4102020f5c | ||
|
|
11c3d419c7 | ||
|
|
fe028a9b01 | ||
|
|
72643051f9 | ||
|
|
6bb4c3cd7d | ||
|
|
30a432cd37 | ||
|
|
f7ebed5478 | ||
|
|
07825c1bd1 |
@@ -8,3 +8,6 @@ ui/dist/
|
||||
ui/.angular/
|
||||
src/shared/version.gen.ts
|
||||
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)
|
||||
(processes, windows, RPC, every module/service), [build & release](docs/build-and-release.md)
|
||||
(Build-n versioning, per-target release pipeline, CI), [platform notes](docs/platform-notes.md)
|
||||
(macOS/Windows matrix + smoke tests), and [gotchas](docs/gotchas.md) (known landmines).
|
||||
(macOS/Windows/Linux matrix, runtime deps + smoke tests), and
|
||||
[gotchas](docs/gotchas.md) (known landmines).
|
||||
|
||||
## Features
|
||||
|
||||
@@ -75,6 +76,11 @@ ui/src/app/ Angular components + signal services
|
||||
|
||||
- [Bun](https://bun.sh) ≥ 1.3 (`bun --version`).
|
||||
- macOS: **Xcode Command Line Tools** (for the app build).
|
||||
- Linux: **AppIndicator** — the app links it for the tray and will not start
|
||||
without it (`ERR_DLOPEN_FAILED`). `libayatana-appindicator` (Arch),
|
||||
`libayatana-appindicator3-1` (Debian/Ubuntu),
|
||||
`libayatana-appindicator-gtk3` (Fedora), plus system `webkit2gtk`.
|
||||
Details and diagnostics in [platform notes](docs/platform-notes.md).
|
||||
- No Node install required — a preload shim (`scripts/node-version-shim.ts`)
|
||||
satisfies the Angular CLI's Node version check while running under Bun.
|
||||
|
||||
@@ -107,11 +113,14 @@ cross-compile between OSes:
|
||||
bun run release -- --target mac-arm64 # on the Mac (native)
|
||||
bun run release -- --target mac-x64 # on the same arm64 Mac, via Rosetta 2
|
||||
bun run release -- --target win-x64 # on a Windows 11 x64 machine
|
||||
bun run release -- --target linux-x64 # on a Linux x64 machine
|
||||
```
|
||||
|
||||
Artifacts land in `release/<target>/miniSCP-Build<n>-<target>.*` — a `.dmg`
|
||||
per mac arch; on Windows a `-Setup.zip` installer **and** a `-portable.zip`
|
||||
(unzip anywhere and run). The mac-x64 build fetches electrobun's x64 CLI once
|
||||
(unzip anywhere and run); on Linux a portable `.tar.gz` (unpack and run
|
||||
`miniSCP.sh`, which sets the NVIDIA workaround then execs `bin/launcher`).
|
||||
The mac-x64 build fetches electrobun's x64 CLI once
|
||||
into `~/.cache/miniscp/` and runs it under Rosetta so the whole pipeline
|
||||
believes it is on an Intel Mac. CI: `.gitea/workflows/release.yml` runs the
|
||||
same script on a macos + windows runner matrix once act_runners are
|
||||
@@ -210,6 +219,12 @@ shell-integration wording, drive listing, and the connections-file ACL. The
|
||||
application menu ([src/bun/menu.ts](src/bun/menu.ts)) is macOS-only for now;
|
||||
the in-app UI covers every menu action.
|
||||
|
||||
Right-click menus are **HTML, on every platform**
|
||||
([ui/src/app/context-menu.ts](ui/src/app/context-menu.ts)) rather than native —
|
||||
electrobun's native context menu is a documented no-op on Linux, and one menu
|
||||
implementation beats two that drift. Only reveal / open-with-default / copy-path
|
||||
cross to the backend, through the `shellItemAction` RPC.
|
||||
|
||||
## Windows (beta)
|
||||
|
||||
Windows 11+ x64 is supported as a **beta** target (win-arm64 deferred). All
|
||||
@@ -231,6 +246,29 @@ drive root ('..' disappears); local↔remote transfers; external editor launch
|
||||
renders colored; Permissions absent on local panels; Ctrl+labels in tooltips;
|
||||
portable zip runs from any folder.
|
||||
|
||||
## Linux (beta)
|
||||
|
||||
Linux x64 is supported as a **beta** target and builds a portable tarball.
|
||||
One hard requirement: the AppIndicator library listed under
|
||||
[Prerequisites](#prerequisites) — electrobun links it for the tray, so without
|
||||
it the app aborts at startup with `ERR_DLOPEN_FAILED` rather than merely losing
|
||||
its tray icon.
|
||||
|
||||
- **No native menus at all** upstream (both the app menubar and context menus
|
||||
are no-ops). The menubar was already macOS-only and the context menus are now
|
||||
HTML, so nothing is missing in practice.
|
||||
- **Tray visibility** depends on the desktop: KDE, XFCE, MATE and Cinnamon show
|
||||
it; GNOME 3.26+ needs the AppIndicator extension. The app works either way.
|
||||
- **Unsigned and unpackaged**: no AppImage, `.deb`, desktop entry or icon
|
||||
installation — unpack the tarball and run `miniSCP.sh`.
|
||||
- **NVIDIA GPUs** need `WEBKIT_DISABLE_DMABUF_RENDERER=1` or the window renders
|
||||
black. Every launch path (dev scripts and the packaged `miniSCP.sh` wrapper)
|
||||
sets it automatically when an NVIDIA driver is detected — see
|
||||
[gotchas](docs/gotchas.md#linux) if you add a new way to launch the app.
|
||||
|
||||
Full dependency table, diagnostics and the first-boot smoke test:
|
||||
[platform notes](docs/platform-notes.md).
|
||||
|
||||
## Known issues
|
||||
|
||||
- **Rare native crash under load.** A long-running session (multiple remote
|
||||
@@ -246,5 +284,5 @@ portable zip runs from any folder.
|
||||
|
||||
Drag-and-drop, file preview/edit, transfer resume, symlink following
|
||||
(symlinks are listed but skipped by transfers), OS keychain, tab persistence
|
||||
across restarts, Windows/Linux packaging (the code is portable; only macOS
|
||||
packaging is wired up).
|
||||
across restarts, Linux desktop integration (AppImage/.deb/.desktop entry —
|
||||
the release is a portable tarball).
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"name": "ui",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"7.css": "^0.21.1",
|
||||
"@angular/common": "^22.0.0",
|
||||
"@angular/compiler": "^22.0.0",
|
||||
"@angular/core": "^22.0.0",
|
||||
@@ -45,6 +46,8 @@
|
||||
"@parcel/watcher",
|
||||
],
|
||||
"packages": {
|
||||
"7.css": ["7.css@0.21.1", "", {}, "sha512-Q+/aFUpGemoR/Zltj4a7yvHonyxfb+9cgiwBL+j/Lm+H28EOoLzWoJki7MdGPV+3mLCvKSCVqr3M2x2uNYyeWw=="],
|
||||
|
||||
"@algolia/abtesting": ["@algolia/abtesting@1.18.0", "", { "dependencies": { "@algolia/client-common": "5.52.0", "@algolia/requester-browser-xhr": "5.52.0", "@algolia/requester-fetch": "5.52.0", "@algolia/requester-node-http": "5.52.0" } }, "sha512-8siuLG+FIns1AjZ/g2SDVwHz9S+ObacDQISEJvS8XsNei1zl3FXqfqQrBpmrG7ACWCyesXHbicMJtvRbg00FEw=="],
|
||||
|
||||
"@algolia/client-abtesting": ["@algolia/client-abtesting@5.52.0", "", { "dependencies": { "@algolia/client-common": "5.52.0", "@algolia/requester-browser-xhr": "5.52.0", "@algolia/requester-fetch": "5.52.0", "@algolia/requester-node-http": "5.52.0" } }, "sha512-wtwPgyPmO7b7sQPVgoK29c1VpfS08DnnJCmxX/oU1pV2DlMRJCzQcLN7JSloYpodyKHwM8+9wOzlAM0co3TDmA=="],
|
||||
|
||||
+272
-22
@@ -10,7 +10,8 @@ Dense reference for development and debugging. Companion files:
|
||||
its native launcher. Owns every privileged capability: filesystem, SSH,
|
||||
window management, native menus/dialogs/tray, the connection/settings stores.
|
||||
- **Webviews** (`ui/` Angular): one per window, system WebKit on macOS /
|
||||
WebView2 on Windows. Zero privileged access — everything goes through RPC.
|
||||
WebView2 on Windows / WebKitGTK on Linux. Zero privileged access — everything
|
||||
goes through RPC.
|
||||
- **RPC transport**: one WebSocket server in the bun process, multiplexed by
|
||||
`webviewId` (each webview auto-connects using natively injected globals).
|
||||
Requests are typed by the `AppRPC` schema in `src/shared/rpc.ts`.
|
||||
@@ -42,14 +43,18 @@ no-op functions, reassigned in `src/bun/index.ts` after everything exists:
|
||||
|
||||
## Window kinds
|
||||
|
||||
`src/bun/windows.ts` — `buildPreload(mode, ctx?)` injects `__APP_PLATFORM`,
|
||||
`__APP_MODE` (`main|editor|manager`), `__APP_CTX` before any page script.
|
||||
`ui/src/main.ts` picks the root component from `APP_MODE`; all three roots use
|
||||
selector `app-root` (index.html hosts only that tag). `openSecondaryWindow` is
|
||||
the ONLY place that branches platform for secondary chrome: macOS
|
||||
`hiddenInset` + `trafficLightOffset {x:12,y:13}` (the UI renders an
|
||||
`h-10 pl-24` drag-region header via `IS_MAC_CHROME`); other platforms keep the
|
||||
native titlebar and a compact `h-9` non-drag header.
|
||||
`src/bun/windows.ts` — `buildPreload(mode, settings, ctx?)` injects
|
||||
`__APP_PLATFORM`, `__APP_MODE` (`main|editor|manager`), `__APP_SKIN`
|
||||
(`modern|win7`), `__APP_CTX` before any page script. `ui/src/main.ts` selects on
|
||||
BOTH axes: the skin picks a component tree, `APP_MODE` picks a root within it
|
||||
(see "UI layout" below). All roots use selector `app-root` (index.html hosts
|
||||
only that tag). Both globals are preload-baked because both are needed before
|
||||
first paint — settings arrive over an async RPC, far too late to choose a tree
|
||||
without flashing the wrong UI. `chromeOptions(skin)` is the ONLY place the
|
||||
native frame is decided: win7 is frameless + transparent everywhere, modern
|
||||
keeps the native titlebar (macOS `hiddenInset` + `trafficLightOffset
|
||||
{x:12,y:13}`, where the shadlike tree renders an `h-10 pl-24` drag-region header
|
||||
via `IS_MAC_CHROME`).
|
||||
|
||||
`WindowRegistry<Rec>` (same file): keyed window map with dedupe/activate,
|
||||
cascade placement off an anchor frame, focus tracking. electrobun's emitter
|
||||
@@ -57,7 +62,7 @@ has **no `off()`** — `track()` closures capture only `(this, key)` so closed
|
||||
records can be garbage collected. A future "window follows window" grouping
|
||||
belongs here (`anchor.on('move')` + `setPosition`).
|
||||
|
||||
### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/editor-window/`)
|
||||
### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/skins/*/editor-window/`)
|
||||
|
||||
One window per file, key `sessionId+path` (sessionId, not fsKey: saves route
|
||||
through a concrete session, and `local` is one shared session anyway).
|
||||
@@ -148,10 +153,6 @@ would float over editor windows).
|
||||
- `store/connections.ts` — plaintext-passwords JSON (deliberate v1 choice),
|
||||
atomic tmp+rename, 0600 + win32 `icacls` ACL. `store/settings.ts` — defaults
|
||||
spread-merge on load, so NEW SETTINGS KEYS need no backend change.
|
||||
- `context-menu.ts` — native row/empty-space menus + the column picker.
|
||||
Local shell actions (reveal/open/copy-path) run bun-side via Utils; others
|
||||
forward through `menuAction` and the UI applies them to the row it
|
||||
remembered (`FileActionsService.menuTarget`). Labels are per-platform.
|
||||
- `menu.ts` — macOS-only app menu, rebuilt on every connections change (the
|
||||
Connect submenu mirrors the store). ⌘W is intercepted by the menu, so bun
|
||||
routes `close-tab` to the focused editor window first. Labels go through
|
||||
@@ -161,7 +162,8 @@ would float over editor windows).
|
||||
locale (from `settings.locale`, `system` via `Intl…resolvedOptions().locale`);
|
||||
`t(key, params)` shares the same `src/shared/i18n` dictionaries as the UI.
|
||||
Wired in index.ts on boot and on `settingsChanged` (relocalizes + rebuilds
|
||||
the app menu). `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;
|
||||
Windows colored icon. No `setImage` calls ever (they lose the template
|
||||
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
|
||||
notification suppression across all windows).
|
||||
|
||||
## UI services (`ui/src/app/`)
|
||||
## UI layout: one core, two skin trees
|
||||
|
||||
```
|
||||
ui/src/app/
|
||||
core/ app.config.ts, icons.ts, platform.ts, format.ts, file-icons.ts,
|
||||
and all 12 *.service.ts — everything skin-agnostic
|
||||
skins/
|
||||
shadlike/ the modern (shadcn-style) tree + shadlike.css
|
||||
7like/ the Windows 7 tree + 7like.css + fonts/ + title-bar/
|
||||
```
|
||||
|
||||
Each skin owns a **complete** component tree, including its own three window
|
||||
roots, and exports a barrel (`SHAD_ROOTS` / `SEVEN_ROOTS`) with the same
|
||||
`{ main, manager, editor }` shape. `main.ts` therefore knows nothing about
|
||||
either tree's internals: `APP_SKIN` picks the barrel, `APP_MODE` picks the root.
|
||||
Both trees are imported statically into one bundle — the inactive skin costs
|
||||
bundle size but no runtime work, which keeps bootstrap synchronous.
|
||||
|
||||
Duplicate selectors across trees (`app-file-panel` exists in both) are safe:
|
||||
standalone components resolve through their importer's `imports:` array, never
|
||||
a global registry, and the two trees are never mounted together.
|
||||
|
||||
Only `7like` has a `title-bar/`. The modern skin defers to the native frame,
|
||||
which is exactly what `IS_MAC_CHROME` accounts for.
|
||||
|
||||
**Why this split was cheap**: the service layer was already skin-agnostic —
|
||||
`git diff main...feat/7css-skin` touched none of it. Components reach it through
|
||||
the **`@core/*`** tsconfig alias (alongside `@shared/*` → `src/shared`), which
|
||||
is deliberate: skin components sit at varying depths and an alias keeps their
|
||||
imports identical wherever a file moves. Do not replace it with relative paths.
|
||||
|
||||
**Where a new rule goes**: shared behaviour → `core/`; markup and appearance for
|
||||
one look → that skin's folder; tokens both skins consume → `ui/src/styles.css`.
|
||||
|
||||
## UI services (`ui/src/app/core/`)
|
||||
|
||||
- `rpc.service.ts` — wraps `Electroview.defineRPC`. **Push messages fan out
|
||||
through plain callback registries, never signals**: a signal consumed from
|
||||
@@ -220,12 +256,35 @@ would float over editor windows).
|
||||
boot and on the `settingsChanged` broadcast (so editor/manager windows follow).
|
||||
- `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always
|
||||
against an explicit panel; `menuTarget` remembers the right-clicked row.
|
||||
- `context-menu.service.ts` + `context-menu.ts` — the panel and tab right-click
|
||||
menus, in HTML. Electrobun's native `showContextMenu` is a no-op on Linux, so it was
|
||||
retired on ALL platforms rather than kept as a second code path (see
|
||||
platform-notes.md). The service builds the item list (row / empty-space menu,
|
||||
and the header column picker) and holds the open request as a signal; the
|
||||
component renders it at the pointer with edge-flip clamping, arrow/Enter/Escape
|
||||
keys, and close-on-blur/resize/scroll. Picked items are emitted verbatim as
|
||||
`ctx-*` / `col:*` / `tab:*` strings through `RpcService.emitMenuAction`, so they
|
||||
land in the SAME `App.handleMenuAction` dispatcher the macOS app menubar uses —
|
||||
one handler, two sources. The three shell actions the webview cannot perform
|
||||
(reveal, open-with-default, copy path) go back to bun via the `shellItemAction`
|
||||
RPC, which is all that remains of the old native menu.
|
||||
`tabItems()` builds the workspace-tab menu (`tab:new`, and
|
||||
`tab:close|close-others|close-right|disconnect:<id>`), served by
|
||||
`PanelsService.closeOtherWorkspaces` / `closeWorkspacesToRight` /
|
||||
`disconnectWorkspace`. **The target id travels inside the action string**: the
|
||||
right-clicked tab is not necessarily the active one, so nothing on that path
|
||||
may fall back to `activeWorkspaceId`. Illegal entries are `disabled`, not
|
||||
omitted — Win7 greys out, and a menu whose items move between right-clicks is
|
||||
worse than one with a dead row. 7.css hangs that state on
|
||||
`li[role=menuitem][aria-disabled]` (opacity + `pointer-events: none`), which
|
||||
the keyboard path never consults — so `pick()` and the arrow-key walker check
|
||||
the flag themselves, in both skins.
|
||||
- `file-icons.ts` — extension → category map driving glyphs, colors
|
||||
(fixed `--icon-*` tokens, never accent-tinted) and the Kind column.
|
||||
- `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`,
|
||||
`EDITOR_CONTEXT`, `IS_MAC_CHROME`, `IS_WINDOWS`, `MOD_LABEL`/`ALT_LABEL`)
|
||||
+ `blockNativeContextMenu` (kills WebKit's Reload/Inspect menu outside
|
||||
text fields).
|
||||
`EDITOR_CONTEXT`, `IS_MAC`/`IS_MAC_CHROME`, `IS_WINDOWS`, `IS_LINUX`,
|
||||
`MOD_LABEL`/`ALT_LABEL`) + `blockNativeContextMenu` (kills WebKit's
|
||||
Reload/Inspect menu outside text fields — the app draws its own menu instead).
|
||||
- `updater.service.ts` — auto-updater state for the tab-bar pill. Signals
|
||||
`state` (`idle→available→downloading→ready`|`error`), `version`, `progress`.
|
||||
Subscribes the `updateStatus` push (backend `Updater.checkForUpdate` on
|
||||
@@ -257,6 +316,197 @@ toolbars deliberately stay on `--background`. Accent tones: one
|
||||
`@theme inline` maps tokens to Tailwind utilities (`bg-primary`,
|
||||
`text-icon-folder`, `bg-cursor-tint`). Selected rows are `bg-primary/15`
|
||||
(follow the accent); the cursor row uses `--cursor-tint` so graphite stays
|
||||
pixel-identical to the old neutral hover. Text-size levels set `--row-h`,
|
||||
`--list-font` etc. per `data-text` attribute, consumed via `h-(--row-h)` /
|
||||
`text-(length:--list-font)` arbitrary utilities.
|
||||
pixel-identical to the old neutral hover. **Interface scale** (`settings.uiScale`,
|
||||
0.5–4 in 0.1 steps): one root font-size lever — `applyUiScale` sets
|
||||
`html { font-size: 16px * scale }`, and since EVERY size in the app is rem
|
||||
(panel tokens like `--row-h`, all Tailwind utilities, ng-icon sizes, `--w7-fs`
|
||||
and the 7.css bridge in 7like.css), both skins scale whole, chrome included.
|
||||
Driven by the tab-bar stepper, Ctrl/Cmd +/−/0, and preload-baked for a
|
||||
flash-free first paint. It replaced the 3-tier `data-text` axis; the store
|
||||
migrates old `textSize` values. TS that measures rem-sized CSS converts through
|
||||
`@core/metrics` `remPx()` — never an assumed 16.
|
||||
|
||||
### Skins (`settings.skin`: `modern | win7`)
|
||||
|
||||
A fifth theming axis, and the only one that is a **class** (`.win7` on
|
||||
`<html>`) rather than a `data-*` attribute — `7.css/dist/7.scoped.css` is
|
||||
precompiled against a literal `.win7`, so the name is not ours to pick.
|
||||
Default `modern` = class absent, matching the delete-when-default rule.
|
||||
`buildPreload` is the **only** writer of that class, ever — there is
|
||||
deliberately no `SettingsService.applySkin`.
|
||||
|
||||
**Unlike the other four axes, this one is launch-time only.** It selects a
|
||||
whole component tree (see "UI layout") as well as CSS, and neither half can
|
||||
change on a live window: the tree is bootstrapped once from the preload-baked
|
||||
`__APP_SKIN`, and `titleBarStyle`/`transparent` are `BrowserWindow` constructor
|
||||
options with no setters. Clicking a skin therefore only persists it; the
|
||||
options row shows the saved value as pending (`skinPending()` compares
|
||||
`settings.skin()` against `APP_SKIN`) and the running UI is untouched until the
|
||||
next app start.
|
||||
|
||||
The bun side pins this: `initSessionSkin` freezes the skin at startup, before
|
||||
any window exists, and both `buildPreload` and `chromeOptions` read that pinned
|
||||
value rather than the live settings snapshot. Otherwise an editor or manager
|
||||
opened after a pending change would boot the *new* tree and frame while the
|
||||
main window still ran the old one.
|
||||
|
||||
**Stylesheet split.** `ui/src/styles.css` holds only what BOTH skins consume —
|
||||
the `@layer` statement, the Tailwind import, OKLCH tokens, `@theme inline`,
|
||||
panel-chrome tokens, animations, the accent axis, and the `html, body` base. Each
|
||||
skin's own rules live in its folder (`skins/7like/7like.css`,
|
||||
`skins/shadlike/shadlike.css`) and are `@import`ed from there. `shadlike.css` is
|
||||
near-empty by nature: that skin IS the shared token system plus inline Tailwind.
|
||||
Two constraints: the `@layer` statement must stay the FIRST statement in
|
||||
`styles.css` (it is one of only two rule types allowed above `@import`), and
|
||||
`@font-face` `url()`s resolve relative to the file that declares them — which is
|
||||
why 7like's Noto Sans lives at `skins/7like/fonts/`, inside the skin.
|
||||
|
||||
**Cascade** (`ui/src/styles.css` first lines + `skins/7like/7like.css`): the
|
||||
explicit `@layer theme, base, components, utilities, skin, skinfix;` statement
|
||||
precedes every import. 7.css is imported with `layer(skin)`, i.e. **above
|
||||
`utilities`**,
|
||||
so a bare `button` rule in 7.css outranks `rounded-md` on that same button —
|
||||
that inversion is what reskins ~1900 inline utility classes with no template
|
||||
edits. Consequences: (1) any utility later added to a `button`/`input`/
|
||||
`select`/`textarea`/`table` silently loses under the skin; (2) neutralizers
|
||||
that want the Tailwind value back must sit **inside `@layer skin`**, not
|
||||
`skinfix` — `revert-layer` rolls back one layer, so from `skin` it lands on
|
||||
`utilities`, but from `skinfix` it lands back on 7.css and does nothing.
|
||||
`skinfix` is for rules that state an explicit value (`.w7-btn`).
|
||||
|
||||
**Token bridge** (`skins/7like/7like.css`): `:root.win7` re-points the existing
|
||||
semantic tokens at Win7 values, so utilities Tailwind still controls come out
|
||||
right too. It adds NO new tokens on purpose — a new colour would need another
|
||||
`@theme inline` line. The Win7 surface ramp **inverts** the modern one:
|
||||
`--background` (content panes) is Explorer white and `--chrome` is the darker
|
||||
`#f0f0f0` window surface. `--radius: 5px` so `rounded-md`
|
||||
(`calc(--radius - 2px)`, 74 uses) lands exactly on 7.css's 3px `--w7-el-bdr`.
|
||||
`--primary`/`--ring` are deliberately left alone so the accent axis stays live;
|
||||
`:root.win7[data-accent]` feeds it to `--w7-w-bg`, which is authentic (Win7
|
||||
shipped "Window Color"). Dark Win7 is hand-authored (Microsoft never shipped
|
||||
one) as ~20 `--w7-*` overrides, duplicated forced + system like every other
|
||||
dark block. `--w7-font` is restated as a rem `font` shorthand so the interface
|
||||
scale reaches it and 7.css's `pt` shorthand stops resetting weight/line-height;
|
||||
the rest of 7.css's px metrics are re-expressed in rem by the scale bridge in
|
||||
7like.css's `skinfix`.
|
||||
|
||||
**Markup hooks**: `role="progressbar"` (+aria) on the transfer bars,
|
||||
`has-scrollbar` on scroll containers (7.css only styles `::-webkit-scrollbar`
|
||||
under that class), `w7-toolbar` on every command row — the three window header
|
||||
rows plus each file panel's session strip and path bar (their buttons go flat
|
||||
with a hover glow — Explorer's command bar was flat, unlike dialog push
|
||||
buttons; it only targets `button`, so the path `<input>` keeps its 7.css
|
||||
textbox treatment), `w7-flat`
|
||||
as the per-button escape hatch for the same treatment outside a toolbar (the
|
||||
status-bar transfers summary is a text toggle, not a push button), `w7-btn`
|
||||
on the 10 dialog action buttons. The flat treatment is split across two
|
||||
layers on purpose: `background: revert-layer` must sit in `skin` to reach
|
||||
`utilities`, while `border-color`/`box-shadow` sit in `skinfix` because 7.css
|
||||
restates `border-color` at (0,3,1) and no sane `.w7-flat` selector outranks
|
||||
that.
|
||||
|
||||
**Tabs**: the workspace strip is 7.css's real tab component —
|
||||
`menu[role=tablist]` + one `button[role=tab][aria-selected]` per workspace, so
|
||||
the framework draws the joined inactive tabs and the raised active one. The
|
||||
selector is a DESCENDANT (`menu[role=tablist] button`), so the `+` button, the
|
||||
update pill and the right-hand tools deliberately sit outside the `<menu>`.
|
||||
Tabs are exempted from the `w7-toolbar` flattening via `:not([role='tab'])` —
|
||||
they want the full Aero slab their neighbours are stripped of. There is no
|
||||
per-tab ✕ (Win7 tab controls had none, and a nested button is invalid HTML):
|
||||
closing is `Ctrl/Cmd+W` or middle-click. `role="tabpanel"` on the panel area
|
||||
was considered and rejected — its `padding: 14px`, `margin-bottom: 9px` and
|
||||
border fight the split-pane layout for a join that cannot read anyway with the
|
||||
panel toolbar row in between.
|
||||
|
||||
**Frame transparency**: the rounded frame only reads as rounded because
|
||||
nothing paints an opaque full-bleed ground behind it. That is not free —
|
||||
`html, body` paint `var(--background)` **unlayered**, which beats every
|
||||
`@layer`, so `.win7` resets them to transparent in an equally unlayered rule
|
||||
next to the `font-family` one. A maximized window drops the radius (7.css
|
||||
declares it three times: `.window`, `::before`, `::after` — all three must be
|
||||
zeroed); the frame reads the state off its own child with
|
||||
`:has(> .title-bar.is-maximized)`, which `title-bar.ts` publishes from its
|
||||
existing `maximized` signal rather than subscribing to `windowState` twice.
|
||||
|
||||
**Selection band**: the file list's selected row is a FLAT Win7-Explorer blue
|
||||
(`--w7-sel-bg`, `#cbe8f6`) with **no border** — not 7.css's `--w7-el-grad-h`,
|
||||
which is its Aero element-hover ramp and stays reserved for buttons and tabs
|
||||
that still want the gloss, and not its `#aaddfa` row outline, which stacked a
|
||||
second edge inside the fill and, with the cursor row's dotted rect, drew three
|
||||
concentric outlines on one row. Every row still reserves `border: 1px solid
|
||||
transparent` for geometry; the fill paints under it (`background-clip`
|
||||
defaults to `border-box`). The unfocused pane gets `--w7-sel-bg-idle`,
|
||||
Explorer's grey inactive selection — the band never disappears with focus,
|
||||
only weakens (the commander convention above). Both are dark-overridden in
|
||||
BOTH dark blocks. The context menu's item highlight uses the same
|
||||
`--w7-sel-bg`, so selection reads identically wherever it appears.
|
||||
|
||||
**Context menu**: pure 7.css via its real menu contract —
|
||||
`ul[role=menu] > li[role=menuitem] > button` for actions,
|
||||
`input[type=checkbox] + label` for the checkable column toggles, dividers as
|
||||
`.has-divider` on the PRECEDING item (an `::after` bevel, not a node). The
|
||||
contract is exact: an earlier iteration rendered `div > button`, matched
|
||||
nothing, and wrongly concluded "7.css has no menu component" — it has a full
|
||||
one (`dist/gui/menu.css`), including the gutter stripe, disabled state and
|
||||
submenu arrows. Keyboard moves REAL focus between items (7.css styles
|
||||
`:focus-visible` like `:hover`); Enter/Space activate buttons natively. Only
|
||||
menu.css's hardcoded light literals are restated for dark, in `skinfix` — plus
|
||||
three overrides keyed on the app's own `w7-ctx` marker: `position: fixed`
|
||||
(7.css's `position: relative` on `ul[role=menu]` had been silently winning),
|
||||
the flat `--w7-sel-bg` highlight in place of its gradient wash, and
|
||||
`content: none` on the item buttons' pseudo-elements, which kills both the
|
||||
1s Aero afterglow and the infinite `pulse-anim` on a focused item (see
|
||||
gotchas.md). `scripts/menu-probe.ts` regression-tests placement AND highlight
|
||||
flatness, each with a control that proves the check can fail.
|
||||
|
||||
**Font**: Segoe UI is absent off Windows and not redistributable. Noto Sans —
|
||||
the substitute 7.css itself names — is vendored (OFL-1.1, from
|
||||
`@fontsource/noto-sans`) into `ui/src/fonts/`; under `src/` and not `public/`
|
||||
so esbuild emits it hashed into `media/` and rewrites the `url()`.
|
||||
|
||||
**Window chrome**: `chromeOptions(skin)` in `src/bun/windows.ts` is the single
|
||||
place either skin's frame is decided, spread by both the main window and
|
||||
`openSecondaryWindow`. `win7` uses `titleBarStyle: "hidden"` on **every**
|
||||
platform — that clears `Titled` from the Cocoa style mask, which is also the
|
||||
only way to remove the macOS traffic lights. **Linux honours it too**: the GTK
|
||||
wrapper calls `gtk_window_set_decorated(false)` and logs "GTK: Created window
|
||||
without decorations (custom titlebar)" (verified on GNOME/Wayland), so the
|
||||
frameless Aero chrome is not a mac/win-only feature. The webview then draws the whole
|
||||
frame via `ui/src/app/skins/7like/title-bar/title-bar.ts` (7.css's `.title-bar` >
|
||||
`.title-bar-text` + `.title-bar-controls`, buttons keyed by `aria-label`),
|
||||
mounted by all three of that skin's window roots. Because `titleBarStyle` is
|
||||
fixed at construction, **a skin change only reaches the frame on the next app
|
||||
start** — and nothing about the skin flips live, deliberately. `IS_WIN7_CHROME` in
|
||||
`platform.ts` reads the preload-baked `__APP_SKIN` (settings resolve too late
|
||||
for layout) and forces `IS_MAC_CHROME` false, since there are no traffic
|
||||
lights to reserve `pl-24` for.
|
||||
|
||||
**Window resize (STOPGAP)**: undecorating the window also removes the WM's
|
||||
resize borders, and electrobun 1.18.1 has no native resize gesture — so
|
||||
`skins/7like/window-resize/` synthesizes one: a bottom-right grip drags the
|
||||
frame through `windowGetFrame`/`windowSetFrame` → `BrowserWindow.setFrame`.
|
||||
Mounted by all three win7 roots, hidden while maximized by a CSS `:has()` rule
|
||||
(no second `windowState` subscription), and the grip carries
|
||||
`electrobun-webkit-app-region-no-drag` or the drag region would move the window
|
||||
instead. **One corner, not eight handles**: the handles are invisible and paint
|
||||
above the frame, so a north strip or top corner overlays the caption buttons and
|
||||
steals their `:hover`, which reads as the Aero glow flickering on those buttons.
|
||||
The bottom-right corner is the only one that overlaps nothing (it sits in the
|
||||
6px gutter `.window-body`'s margin leaves). The frame math is a dependency-free module so
|
||||
`scripts/resize-math-probe.ts` can assert all eight edges without a window.
|
||||
**This is meant to be deleted**: the component header carries the removal
|
||||
checklist and `scripts/check-electrobun-resize.ts` (wired into `postinstall`)
|
||||
prints it when upstream ships the primitive. See platform-notes.md.
|
||||
|
||||
**Window controls**: new `windowMinimize` / `windowToggleMaximize` /
|
||||
`windowClose` / `windowIsMaximized` requests, built by
|
||||
`windowControlHandlers(getWindow, onClose?)` in `rpc-handlers.ts` and served by
|
||||
all three window kinds (each passing a late-bound getter for its own window —
|
||||
the window takes the rpc in its constructor, same reason `events` is
|
||||
late-bound). `onClose` is overridden per window kind so the X reuses the
|
||||
existing close path: editors run their dirty prompt, the manager runs
|
||||
`managerClose`, main falls through to the cancellable `before-quit` guard.
|
||||
electrobun emits **no maximize event**, so `trackMaximizeState` derives it from
|
||||
`resize` + the synchronous `isMaximized()` and pushes `windowState` — which
|
||||
also covers Aero Snap and Win+Up. Double-click-to-maximize is likewise absent
|
||||
from electrobun's drag regions and is implemented in the title-bar component.
|
||||
|
||||
+18
-19
@@ -22,20 +22,13 @@ semver-shaped (`0.1.<n>`) for electrobun's config/updater; `BRANDING.buildLabel`
|
||||
- `scripts/node-version-shim.ts` (preloaded via `bunfig.toml`, root AND ui/)
|
||||
fakes a Node version the Angular CLI accepts under Bun. Both preloads are
|
||||
load-bearing.
|
||||
- `scripts/fix-electrobun-cli.ts` re-signs electrobun's prebuilt CLI binary,
|
||||
whose upstream ad-hoc signature is invalid — macOS SIGKILLs it and the app
|
||||
silently never launches, with exit code 0 (see [gotchas.md](gotchas.md)). It
|
||||
runs from `postinstall` **and** at the start of `dev`/`start`/`build`/
|
||||
`release`, because the CLI is downloaded lazily on first use rather than at
|
||||
install time; when the binary is missing it provokes that download first, so
|
||||
a fresh checkout self-heals. Equally load-bearing — removing it means
|
||||
`bun run start` does nothing at all.
|
||||
|
||||
## Release builds
|
||||
|
||||
`bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64]` (default: host).
|
||||
Steps: gen-version → fix-electrobun-cli → gen-icons → Angular build →
|
||||
`electrobun build --env=stable` → copy/rename artifacts into
|
||||
`bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64|linux-x64]`
|
||||
(default: host).
|
||||
Steps: gen-version → gen-icons → Angular build → `electrobun build
|
||||
--env=stable` → copy/rename artifacts into
|
||||
`release/<target>/miniSCP-Build<n>-<target>.*`.
|
||||
|
||||
Facts the script encodes:
|
||||
@@ -45,7 +38,8 @@ Facts the script encodes:
|
||||
- **No OS cross-compile.** The CLI builds for its host (its `targets` config
|
||||
field is unread in 1.18.1); packaging needs host tools (codesign/hdiutil on
|
||||
macOS, rcedit/PowerShell on Windows). Hence one script run per artifact, on
|
||||
the matching machine; `win-x64` on a non-Windows host errors out.
|
||||
the matching machine; `win-x64` on a non-Windows host and `linux-x64` on a
|
||||
non-Linux host both error out.
|
||||
- **mac-x64 from the arm64 Mac (Rosetta)**: the script downloads electrobun's
|
||||
x64 CLI binary once (`electrobun-cli-darwin-x64.tar.gz` from the GitHub
|
||||
release matching the installed version, cached at
|
||||
@@ -54,10 +48,6 @@ Facts the script encodes:
|
||||
`dist-macos-x64` core into the electrobun package dir on first use, and
|
||||
packages a genuine x64 app. Note: Bun's `fetch`→`Bun.write` streaming hung
|
||||
on the GitHub redirect chain; the script uses `curl -fsSL` deliberately.
|
||||
- **Both CLI binaries need re-signing.** The x64 CLI fetched for the Rosetta
|
||||
path carries the same broken upstream signature as the arm64 one, so
|
||||
`x64Cli()` re-signs it after download *and* on cache reuse; the native path
|
||||
is covered by the `fix-electrobun-cli.ts` step up front.
|
||||
- **Core binary downloads**: the installed npm package ships mac-arm64
|
||||
binaries only; other `dist-<os>-<arch>` cores are fetched from
|
||||
`github.com/blackboardsh/electrobun/releases` at build time — network to
|
||||
@@ -68,7 +58,16 @@ Facts the script encodes:
|
||||
(self-extracting installer → `%LOCALAPPDATA%`) plus the script's own
|
||||
**portable zip** made from the assembled app folder under
|
||||
`build/stable-win-x64/` (layout to confirm on the first real Windows build —
|
||||
the step warns instead of failing).
|
||||
the step warns instead of failing); Linux → only
|
||||
`stable-linux-x64-miniSCP.tar.zst` + `update.json`, i.e. the updater feed and
|
||||
nothing installable, so the script's **`.tar.gz` of the assembled app folder**
|
||||
under `build/stable-linux-x64/` IS the release artifact (same best-effort
|
||||
warn). No AppImage/.deb/.desktop — unpack and run the `miniSCP.sh` wrapper the
|
||||
script writes beside `bin/launcher` (it applies the NVIDIA/DMABUF workaround,
|
||||
which the app cannot set for itself — see gotchas.md).
|
||||
- **Linux builds need the AppIndicator dev libraries present to RUN** (not to
|
||||
build) — see platform-notes.md; a packaged tarball started on a machine
|
||||
without them dies at `dlopen`.
|
||||
- The script filters artifacts by app name, so stale artifacts from older
|
||||
brandings don't get collected.
|
||||
|
||||
@@ -119,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` /
|
||||
`applyUpdate` (handlers in [rpc-handlers.ts](../src/bun/rpc-handlers.ts) call the electrobun `Updater`);
|
||||
push message `updateStatus`.
|
||||
- **UI**: `UpdaterService` ([ui/src/app/updater.service.ts](../ui/src/app/updater.service.ts)) holds signals
|
||||
(`state`/`version`/`progress`); the tab-bar "update pill" ([tab-bar.ts](../ui/src/app/tab-bar/tab-bar.ts))
|
||||
- **UI**: `UpdaterService` ([ui/src/app/core/updater.service.ts](../ui/src/app/core/updater.service.ts)) holds signals
|
||||
(`state`/`version`/`progress`); the tab-bar "update pill" ([tab-bar.ts](../ui/src/app/skins/7like/tab-bar/tab-bar.ts))
|
||||
is one accent (`bg-primary`) control that announces → fills as a progress bar while downloading →
|
||||
"Restart to apply". `state` is `available → downloading → ready` (ready derived from the
|
||||
`downloadUpdate` response). App-menu "Check for Updates…" ([menu.ts](../src/bun/menu.ts)) routes a
|
||||
|
||||
+302
-19
@@ -28,24 +28,177 @@ touching the adjacent code.
|
||||
- Tailwind v4 canonical class names: `wrap-break-word` not `break-words`,
|
||||
`accent-primary` not `accent-[var(--primary)]`, `z-60` not `z-[60]`.
|
||||
Every color used in a template must resolve through `@theme inline`.
|
||||
- **The win7 skin layer outranks Tailwind utilities.** 7.css is imported
|
||||
`layer(skin)`, ordered above `utilities`, so any utility you add to a
|
||||
`button`/`input`/`select`/`textarea`/`table` is silently dead under that
|
||||
skin. If it matters, neutralize in the `@layer skin` block in
|
||||
`app/skins/7like/7like.css` — and note `revert-layer` only reaches
|
||||
`utilities` from **inside `skin`**; used from `skinfix` it falls back onto
|
||||
7.css and is a no-op. This bit once already.
|
||||
- `@import … layer(name)` works through `@tailwindcss/postcss` (it wraps the
|
||||
imported AST), but the **deep path is mandatory**: 7.css ships no `exports`
|
||||
map and Tailwind's resolver reads only `style`/`mainFields`, so a bare
|
||||
`@import "7.css"` resolves to nothing. Use `7.css/dist/7.scoped.css`.
|
||||
- Stylesheet `url()` must resolve at BUILD time: assets referenced from
|
||||
`styles.css` live under `ui/src/` (esbuild emits them hashed into `media/`
|
||||
and rewrites the url), NOT `ui/public/` — a public path fails the build with
|
||||
"Could not resolve".
|
||||
- **Two `class` attributes on one element: the second is silently dropped.**
|
||||
No warning, no build error — the layout classes simply never apply. This
|
||||
collapsed the tab strip and two window headers at once (`fe028a9`), because
|
||||
a scripted edit appended `class="…"` to elements that already had one.
|
||||
When removing a conditional binding, check what the branch *contained*: the
|
||||
bindings there were genuinely dead, but the layout classes inside them were
|
||||
not, and nothing replaced them.
|
||||
- **A component `template:` is a TS template literal, so no backticks inside
|
||||
it** — not even in an HTML comment. A backtick in a comment terminates the
|
||||
literal and the errors point at the prose, not at the quoting.
|
||||
|
||||
## The interface scale (root font-size lever)
|
||||
|
||||
- **electrobun's native page zoom is a silent no-op on Linux — do not reach for
|
||||
it.** `webviewSetPageZoom` in the shipped `libNativeWrapper.so` (v1.18.1, and
|
||||
the `_cef.so`, and the copy in our own build output) is a compiled
|
||||
empty-body stub: the disassembly has no `call` instruction, and
|
||||
`webkit_web_view_set_zoom_level` is not even in the library's imports.
|
||||
`webviewGetPageZoom` returns a hardcoded `1.0` from `.rodata`, so the failure
|
||||
is unobservable through the API — set 2.0, read back "1.0 = success". The
|
||||
JSDoc's "(WebKit only)" means macOS WKWebView. This is why the scale is CSS.
|
||||
- **Everything the UI sizes with must be rem.** The scale works by setting the
|
||||
root font-size, so a px value freezes while everything around it grows. The
|
||||
three px reservoirs were: the panel-chrome tokens (`--row-h` etc.), 93
|
||||
`text-[11px]` arbitraries, and 7.css itself — 370 px values, zero rem, with
|
||||
`--w7-font` reaching only 15 rules' typography. 7like.css's `skinfix` carries
|
||||
a scale bridge re-expressing 7.css's vars and hardcoded box metrics in rem.
|
||||
1px hairlines and focus rects stay px deliberately (crisp at any scale; the
|
||||
`tr` border exists to stop selection shifting rows).
|
||||
- **Restating a 7.css rule in `skinfix` also outranks 7.css's VARIANTS of that
|
||||
rule — by layer, regardless of specificity.** The title-bar-controls
|
||||
restatement silently shrank the close button from 48px to 29px (7.css widens
|
||||
it via `button[aria-label=Close]`), and the tablist padding flattened the
|
||||
active tab's 4px raise. Caught only by the harness pixel diff. When bridging
|
||||
a rule, grep 7.scoped.css for every variant that overrides the same
|
||||
properties and restate those too.
|
||||
- **px constants in TS that mirror rem-sized CSS are latent scale bugs.** The
|
||||
shadlike context menu's `MENU_W = 224` was a hardcoded mirror of `w-56`
|
||||
(14rem): box grew, math didn't, flip fired at the wrong edge. Convert through
|
||||
`@core/metrics` `remPx()` at use time — it reads the LIVE root font-size,
|
||||
and per call, since the scale changes mid-session. The editor's canvas-based
|
||||
`charWidth` is cached keyed on the computed font string for the same reason.
|
||||
|
||||
## Hover states and the frame edge
|
||||
|
||||
- **A child's box-shadow is NOT clipped by its parent's border or radius.** The
|
||||
close button's hover glow (7.css: `0 0 7px 3px #e68e75` on a `::before`)
|
||||
painted across the window's top border and onto the desktop beyond it — the
|
||||
caption buttons sit flush against the top edge (`border-top: 0`), and the
|
||||
frame's rounded transparent corners clip nothing. Fixed with
|
||||
`clip-path: inset(0 0 -1rem 0)` on `.w7-frame > .title-bar`: glow stops at
|
||||
the frame like the real DWM, bottom bleed (authentic) stays open. Note
|
||||
clip-path also clips hit-testing — safe here because the drag region is the
|
||||
title-bar's own box, entirely inside the clip.
|
||||
- **Hover states CAN be tested headlessly**: `scripts/hover-probe.ts` copies
|
||||
the built stylesheet with `:hover` rewritten to `.simhover`, renders the
|
||||
frame markup with the state forced, and pixel-scans artifact regions,
|
||||
exiting non-zero on hover-introduced red outside the frame. Firefox
|
||||
`--screenshot` has no pointer and `import -window` captures the real
|
||||
pointer's state, so the class rewrite is the only programmatic hover on this
|
||||
machine. Re-run it after touching title-bar or hover styling.
|
||||
|
||||
## The two skin trees (`ui/src/app/skins/`)
|
||||
|
||||
- **`.win7` is not a name we may change.** `7.scoped.css` is precompiled with
|
||||
every selector prefixed by that literal class, so the skin id in
|
||||
`AppSettings` stays `"win7"` even though its folder is `7like/`. The folder
|
||||
names are cosmetic; the class and the persisted setting value are not.
|
||||
- **Reach `core/` through the `@core/*` alias, never a relative path.** Skin
|
||||
components sit at varying depths and get moved; the alias is what let 67
|
||||
imports survive two relocations untouched. A `../../core/...` path is a
|
||||
future merge conflict.
|
||||
- **`@font-face` `url()` resolves relative to the file that declares it.** When
|
||||
the win7 rules moved three directories deeper, `./fonts/...` would silently
|
||||
have 404'd — build still green, app just renders a fallback font. The fonts
|
||||
therefore live inside the skin that uses them.
|
||||
- **The `@layer` statement must stay the first statement in `ui/src/styles.css`.**
|
||||
It is one of only two rule types allowed above `@import`, and it is what sorts
|
||||
`skin` above `utilities`. Every `@import` (Tailwind and both skin sheets) must
|
||||
also precede all other rules.
|
||||
- **Duplicate selectors across skins are safe.** `app-file-panel` exists in both
|
||||
trees; standalone components resolve through their importer's `imports:`
|
||||
array, never a global registry, and the trees are never mounted together.
|
||||
- **The build DOES type-check components nothing imports.** ngtsc compiles the
|
||||
whole program, so a skin that is currently unreachable still fails
|
||||
`bun run build:ui` on a type or template error. Verified deliberately by
|
||||
injecting an error into an unreferenced file — worth knowing before assuming
|
||||
a green build proves nothing about the inactive skin.
|
||||
|
||||
## The win7 skin (7.css)
|
||||
|
||||
- **Unlayered declarations beat every `@layer`, regardless of specificity.**
|
||||
`html, body { background: … }` in styles.css is unlayered, so no `skinfix`
|
||||
rule can override it — the win7 transparent-corner reset has to be unlayered
|
||||
too. The frame's rounded corners only read as rounded because nothing paints
|
||||
an opaque full-bleed ground behind them; anything that does fills the corners
|
||||
and the window becomes a rectangle with a curve drawn on it.
|
||||
- **7.css restates `border-color` at (0,3,1)** via
|
||||
`.win7 button:not(:disabled):not(:hover)` and its `:hover` twin. A two-class
|
||||
selector like `.win7 .w7-flat` (0,2,0) loses to it, so paint suppression for
|
||||
flat buttons lives in `skinfix` where layer order settles it. The
|
||||
`.w7-toolbar` arm only ever won by tying on specificity and landing later in
|
||||
the same layer — an accident `w7-flat` did not share, which is why it
|
||||
rendered a bordered slab while its neighbours went flat.
|
||||
- **`overflow-x: auto` also clips vertically.** CSS refuses to pair `visible`
|
||||
with a non-visible axis, so the computed `overflow-y` becomes `auto` too.
|
||||
7.css raises the active tab with `margin-top: -2px`; inside the horizontally
|
||||
scrolling tab strip that put its top border outside the scroll box and
|
||||
deleted it silently. The strip reserves `padding-top: 2px` to compensate —
|
||||
mirroring the `padding-left: 3px` 7.css already reserves for the same tab's
|
||||
`-3px` left margin.
|
||||
- **7.css hardcodes light-mode literals outside its own var system** —
|
||||
`color: #222` on tablist buttons, black/white bevels in the ListView and
|
||||
menu. Each one has to be routed through a `--w7-*` token declared in
|
||||
`:root.win7` and overridden in **both** dark blocks (the `[data-theme]`
|
||||
attribute one *and* the `prefers-color-scheme` one; system dark sets no
|
||||
attribute).
|
||||
- **The skin layer can also override `position`, and that breaks LAYOUT, not
|
||||
just paint.** `ul[role=menu]` carries `position: relative` in 7.css (for the
|
||||
gutter stripe's `::before` and submenu anchoring), which outranked Tailwind's
|
||||
`.fixed` on the app's own context menu. `[style.left/top.px]` then became
|
||||
*flow* offsets from the menu's static position — the last child of the
|
||||
`h-screen` frame — so every right-click menu rendered below the viewport,
|
||||
unreachable behind `overflow: hidden`. The backdrop is a plain `<div>`, which
|
||||
7.css does not match, so it stayed genuinely fixed and ate the next click:
|
||||
the symptom was "right-click does nothing, then the app ignores me once".
|
||||
Fixed with a `position: fixed` restatement in `skinfix`, scoped to a `w7-ctx`
|
||||
marker class so 7.css's own menus keep theirs. Regressed in `0ae2a62`, when
|
||||
the menu root became a real `ul[role=menu]` — i.e. adopting the framework's
|
||||
markup is what exposed it. **A resting pixel diff cannot catch this class of
|
||||
bug**: nothing about the idle window changes. `scripts/menu-probe.ts` renders
|
||||
the menu at declared coordinates and scans whether it landed there, with a
|
||||
marker-less control menu as its own discriminator.
|
||||
- **7.css's button effects reach anything that IS a button — including menu
|
||||
items — and one of them fades out over a FULL SECOND.** `button::before` is
|
||||
an Aero glow overlay with `opacity: 0`, raised to 1 on hover, and
|
||||
`button:not(:hover):before` carries `transition: opacity 1s linear`.
|
||||
menu.css suppresses it with `content: none` on the **`:hover` arm only**, so
|
||||
while an item is hovered nothing paints, but the moment the pointer leaves,
|
||||
the pseudo returns with opacity animating 1 → 0: a blue ghost trailing the
|
||||
cursor down the menu. Killing `content` in *every* state is the fix —
|
||||
suppressing the transition alone leaves a glow that simply snaps. Two more
|
||||
from the same family: `button:focus` runs `pulse-anim` *infinitely*
|
||||
(`1s ease infinite alternate`), which matters because the context menu moves
|
||||
REAL focus between items for keyboard nav, and the hover paint is 7.css's
|
||||
translucent gradient wash — the same one the ListView selection dropped.
|
||||
All three are overridden together, scoped to `w7-ctx`.
|
||||
- **Its selectors are exact, and a near-miss looks exactly like "7.css doesn't
|
||||
support this".** `menu[role=tablist] button` is a DESCENDANT selector, so
|
||||
every button inside the strip becomes a tab — the `+` and the right-hand
|
||||
tools must live outside it. `.window.glass > .title-bar` is direct-child, so
|
||||
an inner wrapper breaks the glass coupling. A wrong guess here already
|
||||
produced one hand-written lookalike that was deleted.
|
||||
|
||||
## Electrobun
|
||||
|
||||
- **The prebuilt CLI binary ships with a broken ad-hoc signature**, so macOS
|
||||
SIGKILLs it at exec and `bun run start` prints nothing and exits **0** —
|
||||
`bin/electrobun.cjs` spawns the real binary and forwards
|
||||
`process.exit(code || 0)`, and a signal death has `code === null`. The binary
|
||||
is a Bun single-file executable whose payload is appended *after* the linker
|
||||
signed it, so `codesign -v` reports "code or signature have been modified";
|
||||
the arm64 and x64 CLI tarballs are both affected upstream (confirmed by
|
||||
hashing a fresh download against the shipped copy — it is not local
|
||||
corruption, and re-installing does not help). `scripts/fix-electrobun-cli.ts`
|
||||
re-seals it with `codesign --force --sign -`. It runs from `postinstall`
|
||||
**and** ahead of every electrobun invocation, because the wrapper downloads
|
||||
the binary **lazily on first use**, not at install time — postinstall alone
|
||||
would no-op on a fresh checkout. It also fixes `.cache/electrobun`, which the
|
||||
wrapper restores `bin/` from. Never read a silent `exit 0` from the
|
||||
electrobun CLI as "nothing happened": run `codesign -v` on it first.
|
||||
- **Window `close` is not cancellable** (response type `{}`; only the
|
||||
app-level `before-quit` supports `event.response = {allow:false}`). All
|
||||
unsaved-state guards must either intercept app-controlled close paths
|
||||
@@ -63,7 +216,109 @@ touching the adjacent code.
|
||||
- Tray: never call `setImage` after construction on macOS — the template
|
||||
treatment is lost and the icon renders black on dark menu bars.
|
||||
- `ApplicationMenu` is macOS-only in our usage; accelerators on Windows are
|
||||
single-character only. ContextMenu exists on macOS+Windows, not Linux.
|
||||
single-character only.
|
||||
- **`ContextMenu.showContextMenu` is a silent no-op on Linux** (electrobun's own
|
||||
`dist/api/bun/proc/linux.md` says so; the app also prints "Application menus are
|
||||
not supported on Linux" at boot). Because the UI suppresses the webview's own
|
||||
right-click menu, that left EVERY panel context menu dead on Linux — no error,
|
||||
nothing happens. The menu is plain HTML now (`ui/src/app/skins/*/context-menu.ts`) on
|
||||
every platform; don't reintroduce the native one for "nativeness" on mac/win
|
||||
without keeping a Linux path.
|
||||
|
||||
## Linux
|
||||
|
||||
- **`ERR_DLOPEN_FAILED` on `bun run start`** means a missing system library, not
|
||||
broken app code. `libNativeWrapper.so` links `libayatana-appindicator3.so.1`
|
||||
*hard* (for the tray), so the app cannot start at all without it — even though
|
||||
the tray is a minor feature. Diagnose with
|
||||
`ldd build/dev-linux-x64/*/bin/libNativeWrapper.so | grep 'not found'`; the
|
||||
`libasar.so => not found` line in that output is a red herring (it ships beside
|
||||
the wrapper and resolves at load). Install list per distro is in
|
||||
[platform-notes.md](platform-notes.md).
|
||||
- **Black window on NVIDIA + `Failed to create GBM buffer of size WxH: Invalid
|
||||
argument`** (usually with `X11 Error: GLXBadWindow (code 170)`). The app boots,
|
||||
logs "miniSCP started", reports no error — and paints nothing. WebKitGTK's
|
||||
DMABUF renderer can't allocate buffers on the NVIDIA proprietary driver;
|
||||
`WEBKIT_DISABLE_DMABUF_RENDERER=1` is the upstream workaround.
|
||||
- **The app cannot apply this to itself.** WebKit reads the variable when it
|
||||
forks its web process, and **Bun's `process.env` writes do not propagate to
|
||||
child processes** (verified: a spawned child sees it unset). Setting it in
|
||||
`src/bun/index.ts` — even in a module imported before `electrobun/bun` —
|
||||
does nothing. It must be in the environment before the app process starts.
|
||||
- Hence every launch path injects it: `scripts/linux-gpu.ts` (`launchEnv()`)
|
||||
for `bun run dev` / `bun run start`, and the `miniSCP.sh` wrapper that
|
||||
`release.ts` writes beside `bin/launcher` in the tarball. **A new launch
|
||||
path must call `launchEnv()` or it will render black on NVIDIA.**
|
||||
- Gated on `/proc/driver/nvidia` so Intel/AMD keep hardware compositing, and
|
||||
an explicit `WEBKIT_DISABLE_DMABUF_RENDERER` from the user always wins.
|
||||
- A "does it boot?" log check does NOT catch this — grep for the GBM line, or
|
||||
look at the window.
|
||||
- **`bun run start` leaves the old instance running, and
|
||||
`import -window miniSCP` grabs the OLDEST match.** Instances pile up across
|
||||
rebuilds, so every screenshot silently comes from a stale build and the fix
|
||||
you just made looks like it did nothing. Cost three wrong readings in one
|
||||
session, including a "the CSS is in the bundle but does not apply" dead end.
|
||||
Kill first (`pkill -f dev-linux-x64`), confirm with
|
||||
`pgrep -cf 'dev-linux-x64/miniSCP-dev/bin/launcher'`, then launch and capture.
|
||||
Note `pkill -f 'electrobun dev'` also matches the shell running it and kills
|
||||
your own command (exit 144) — match on the build path instead. That is only
|
||||
half the trap: `pkill -f dev-linux-x64` does the same thing, because the
|
||||
pattern appears in the killing command's OWN argv. Break the literal with a
|
||||
character class — `pkill -f 'dev-linux-x[6]4'` — the way `ps | grep [x]yz`
|
||||
has always worked.
|
||||
- **A frameless window cannot be resized, and `preventDefault` will not save
|
||||
you from the drag region.** Undecorating the GTK window (what
|
||||
`titleBarStyle: "hidden"` does) takes the WM's resize borders with it, and
|
||||
electrobun exposes no resize gesture — hence the synthesized grip in
|
||||
`ui/src/app/skins/7like/window-resize/` (a STOPGAP; that file carries the
|
||||
removal checklist, and `scripts/check-electrobun-resize.ts` fires from
|
||||
`postinstall` when upstream makes it deletable).
|
||||
**An invisible resize handle over a control steals that control's hover.** A
|
||||
full eight-handle set shipped briefly and was reverted: the north strip ran
|
||||
the whole width across the caption buttons and the north-east square sat on
|
||||
the close button's corner, so crossing one dropped the button's `:hover`
|
||||
mid-glow and looked like a rendering artifact in 7.css — nothing in the CSS
|
||||
was wrong. Only the bottom-right corner overlaps nothing (it lands in the 6px
|
||||
gutter `.window-body { margin: 0 6px 6px }` leaves). Any new handle must be
|
||||
checked against what sits under it; south/east/west are innocent, north and
|
||||
both top corners are not.
|
||||
Three more traps live in that code, all verified against
|
||||
`dist/api/bun/preload/dragRegions.ts`:
|
||||
- It starts a window **move** from a `mousedown` listener on `document`, in
|
||||
the **bubble** phase. It never reads `defaultPrevented`, so
|
||||
`event.preventDefault()` does **nothing** to stop it.
|
||||
- A `pointerdown` handler does not stop it either: the compatibility
|
||||
`mousedown` still fires afterwards, and stopping propagation on a *pointer*
|
||||
event has no effect on a *mouse* event. This is the trap that looks fixed
|
||||
in every test until the window jumps.
|
||||
- `isAppRegionDrag` checks `.electrobun-webkit-app-region-no-drag` **first**
|
||||
and short-circuits, so that class — on the handle itself — is the only
|
||||
reliable suppressor. Without it, every resize drag also drags the window.
|
||||
Also: deltas must come from `screenX/screenY`. North/west resizes move the
|
||||
window's origin, which moves the webview's client origin under a stationary
|
||||
pointer, so `clientX` feeds that movement back into the next delta and the
|
||||
window runs away. And the minimum-size clamp must re-anchor the opposite
|
||||
edge — clamping the width alone lets `x` keep tracking the pointer, walking
|
||||
the window off-screen while it looks frozen (`scripts/resize-math-probe.ts`
|
||||
asserts exactly that, and was verified to catch it).
|
||||
- **Prefer the headless probes over launching the app.** `bun run start` opens a
|
||||
real window that takes focus, which interrupts whatever the user is doing;
|
||||
there is no Xvfb/xvfb-run on this machine and Wayland offers no
|
||||
launch-without-focus. `scripts/shot.ts` (+ `--dark`), `hover-probe.ts` and
|
||||
`menu-probe.ts` all render through headless Firefox and open nothing. Launch
|
||||
the real app only for what Gecko genuinely cannot answer — WebKitGTK
|
||||
behaviour, the native frame, transparency, live RPC — ask first, and batch
|
||||
every live check into a single launch.
|
||||
- **X11 capture cannot answer "is this pixel transparent?"** for the frameless
|
||||
window. `import -window` grabs the window's own pixels with no compositing,
|
||||
so a transparent corner and an opaque black one both come out black;
|
||||
`png32:` just fills alpha with 1 and proves nothing. XWayland's root window
|
||||
is not the composited desktop, so `import -window root` fails too. Frame
|
||||
transparency and the maximized corners need a human looking at the screen —
|
||||
say so rather than reporting a screenshot as confirmation.
|
||||
- Benign startup noise on Linux — non-fatal, the app runs normally:
|
||||
`libayatana-appindicator is deprecated. Please use libayatana-appindicator-glib`
|
||||
(upstream electrobun's choice, not ours).
|
||||
- **Window-open flash prevention is three-layer** — remove any one and the
|
||||
white/inverted blink returns: (1) windows are created `hidden: true` and
|
||||
revealed by `showWhenReady` on the webview's first `dom-ready` (800 ms
|
||||
@@ -71,6 +326,33 @@ touching the adjacent code.
|
||||
attributes before first paint (`buildPreload`), (3) `ui/src/index.html`
|
||||
carries an inline `<style>` with the html background per theme — its oklch
|
||||
values MUST stay in sync with the `--background` tokens in styles.css.
|
||||
The win7 skin adds a **fourth value to keep in sync**: `buildPreload` also
|
||||
bakes the `.win7` class, and index.html carries a matching `html.win7` arm
|
||||
for all four theme states. Miss it and every win7 window flashes white.
|
||||
- **The skin is a LAUNCH-TIME choice. Never apply one to a running window.**
|
||||
This shipped broken once: `SettingsService.applySkin` toggled the `.win7`
|
||||
class the moment the setting changed, which was harmless when there was one
|
||||
component tree and became a broken window as soon as there were two — 7.css
|
||||
painted onto shadlike markup that has no `.window.glass`, no `.title-bar`, no
|
||||
`menu[role=tablist]` and no `<table>` ListView. Each skin's CSS is written
|
||||
against its OWN tree's markup; the class must only ever be written by
|
||||
`buildPreload`, for the tree that window actually booted.
|
||||
- **A reload does not rescue it.** The preload is a persistent
|
||||
`WebKitUserScript` injected at DOCUMENT_START, so its string is frozen at
|
||||
webview construction — `webview.loadURL()` re-runs it with the OLD
|
||||
`__APP_SKIN`. (`updatePreloadScriptToWebView` exists in the FFI but is
|
||||
reachable only from electrobun's internal `<electrobun-webview>` handler.)
|
||||
- **`titleBarStyle`/`transparent` are construction-time only** — no setters
|
||||
exist, so a live window can never be re-framed. win7 → modern is the
|
||||
dangerous direction: the modern tree draws no title bar, so it would land
|
||||
in a frameless transparent window with no way to move, minimize or close.
|
||||
- Don't "fix" this by recreating the main window either: `exitOnLastWindowClosed`
|
||||
is true, the main `rpc` is captured by six push closures, the tray captures
|
||||
`win`, listeners cannot be removed (no `off()`) — and it would drop every
|
||||
session and tab.
|
||||
- Bun pins it: `initSessionSkin` freezes the skin before any window exists,
|
||||
and `buildPreload`/`chromeOptions` read that, never the live settings. Skip
|
||||
the pin and a window opened after a pending change boots the other skin.
|
||||
- Dev vs prod URL: `getMainViewUrl()` resolves the ng-serve dev server only
|
||||
when `DEV_SERVER_URL` is set AND the updater channel is `dev` AND the
|
||||
server answers a HEAD request; secondary windows must reuse the resolved
|
||||
@@ -115,9 +397,10 @@ touching the adjacent code.
|
||||
single-quoted entries** (`'…c'est…'`). Use double quotes for any value
|
||||
containing an apostrophe. Plurals are `{ one, other }` maps (not ICU strings);
|
||||
zh/ja supply only `other`.
|
||||
- **Native menus can't use the Angular service** — `menu.ts`/`context-menu.ts`
|
||||
go through backend `i18n.ts`, and the app menu must be rebuilt when the locale
|
||||
changes (wired in index.ts `settingsChanged`), or it keeps the old language.
|
||||
- **Native menus can't use the Angular service** — `menu.ts` goes through backend
|
||||
`i18n.ts`, and the app menu must be rebuilt when the locale changes (wired in
|
||||
index.ts `settingsChanged`), or it keeps the old language. The context menu no
|
||||
longer has this problem: it is Angular now and reads `I18nService` directly.
|
||||
- `eu`/`zh`/`ja` are AI drafts pending native review (see the i18n README).
|
||||
- **Silent auto-refresh must snapshot sessionId+cwd and discard a mismatched
|
||||
result.** A normal `refresh()` is masked by `busy` (input blocked), but the
|
||||
|
||||
+119
-11
@@ -1,35 +1,66 @@
|
||||
# Platform notes — macOS / Windows behavior matrix
|
||||
# Platform notes — macOS / Windows / Linux behavior matrix
|
||||
|
||||
macOS is the primary target; Windows 11+ x64 is a **beta** target (win-arm64
|
||||
deferred; Linux untargeted — electrobun lacks menus/context menus there).
|
||||
Every platform seam carries a `PORTABILITY:` comment at the branch site.
|
||||
macOS is the primary target; Windows 11+ x64 and Linux x64 are **beta** targets
|
||||
(win-arm64 and linux-arm64 deferred). Every platform seam carries a
|
||||
`PORTABILITY:` comment at the branch site.
|
||||
|
||||
## Seam inventory (where platform branches live)
|
||||
|
||||
| Concern | File | mac | win | other |
|
||||
| Concern | File | mac | win | linux |
|
||||
|---|---|---|---|---|
|
||||
| Window chrome | `src/bun/windows.ts`, `index.ts` | hiddenInset + traffic-light offset, UI drag header | native titlebar, compact header | same as win |
|
||||
| Window chrome (modern skin) | `src/bun/windows.ts`, `index.ts` | hiddenInset + traffic-light offset, UI drag header | native titlebar, compact header | same as win |
|
||||
| Window chrome (win7 skin) | `src/bun/windows.ts chromeOptions` | `hidden` (clears Titled → no traffic lights) | frameless | `gtk_window_set_decorated(false)` |
|
||||
| Window **resize** | `ui/.../7like/window-resize/` | native frame resizes; win7 frameless uses the synthesized handles | as mac | as mac |
|
||||
| External editor launch | `src/bun/edits.ts launchEditor` | `open [-a app]` | `cmd /c start "" [app] file` | app directly / `xdg-open` |
|
||||
| Editor app picker | `rpc-handlers.ts pickEditorApp` | /Applications, `.app` | %ProgramFiles%, `.exe` | home, any |
|
||||
| Owner/group names | `fs/local.ts resolveId` | `id -nu` + `dscacheutil` | undefined → "—" | `id -nu` + `getent` |
|
||||
| Drive roots | `fs/local.ts listLocalDrives` | empty | `fsutil fsinfo drives` | empty |
|
||||
| chmod/Permissions | context-menu.ts, file-panel, file-actions | full | hidden for LOCAL panels (only flips read-only); SFTP unaffected | full |
|
||||
| Shell wording | `context-menu.ts` | Reveal in Finder / Move to Trash | Show in Explorer / Move to Recycle Bin | Show in File Manager |
|
||||
| chmod/Permissions | context-menu.service.ts, file-panel, file-actions | full | hidden for LOCAL panels (only flips read-only); SFTP unaffected | full |
|
||||
| Shell wording | `ui context-menu.service.ts` | Reveal in Finder / Move to Trash | Show in Explorer / Move to Recycle Bin | Show in File Manager |
|
||||
| Shortcut labels | `ui platform.ts MOD_LABEL/ALT_LABEL` | ⌘ / ⌥ | Ctrl+ / Alt+ | Ctrl+ / Alt+ |
|
||||
| Tray | `src/bun/tray.ts` | 22 pt template PNG | 16 px colored PNG | colored |
|
||||
| App menu | `src/bun/menu.ts` | full menu incl. About | none (early return) — About lives in the options popover | none |
|
||||
| Tray | `src/bun/tray.ts` | 22 pt template PNG | 16 px colored PNG | colored (needs AppIndicator, see below) |
|
||||
| App menu | `src/bun/menu.ts` | full menu incl. About | none (early return) — About lives in the options popover | none (unsupported upstream) |
|
||||
| connections.json protection | `store/connections.ts` | chmod 0600 | `icacls` current-user ACL (0600 is meaningless) | 0600 |
|
||||
| App data dir | `store/paths.ts` | ~/Library/Application Support | %APPDATA% | $XDG_CONFIG_HOME/.config |
|
||||
| ssh-agent | `fs/sftp.ts` | $SSH_AUTH_SOCK | `\\.\pipe\openssh-ssh-agent` fallback | $SSH_AUTH_SOCK |
|
||||
| Paths in UI | `ui format.ts joinPath/isRootPath` | "/" | separator detected from the dir string; drive-root regex | "/" |
|
||||
|
||||
The **context menu is HTML on every platform** (`ui/src/app/skins/*/context-menu.ts` +
|
||||
`context-menu.service.ts`), not a native electrobun menu — see the Linux section
|
||||
below for why. Only the three shell actions it can't do itself (reveal, open with
|
||||
the default app, copy path) cross to bun, via the `shellItemAction` RPC.
|
||||
|
||||
Remote (SFTP) paths are ALWAYS POSIX regardless of host or server OS
|
||||
(`path.posix` throughout `fs/sftp.ts`).
|
||||
|
||||
### Frameless windows lose the WM's resize borders (win7 skin, every platform)
|
||||
|
||||
`titleBarStyle: "hidden"` is what lets 7.css draw the Aero caption — on Linux the
|
||||
GTK wrapper honours it with `gtk_window_set_decorated(false)`. Undecorating also
|
||||
removes the window manager's resize borders and corner grips, and electrobun
|
||||
1.18.1 offers **no** native resize gesture to put back: the shipped
|
||||
`libNativeWrapper` exports `startWindowMove`/`stopWindowMove` with no resize
|
||||
counterpart, does not link `gtk_window_begin_resize_drag`, and `BrowserWindow`
|
||||
has neither `setResizable` nor min-size constructor options. The window is still
|
||||
*programmatically* resizable (`setFrame`/`getFrame` work, and
|
||||
`gtk_window_set_resizable(false)` is never called), so the app **synthesizes the
|
||||
input affordance**: a bottom-right grip in the webview drags the native frame
|
||||
over RPC (`ui/src/app/skins/7like/window-resize/`). Only that one corner — an
|
||||
invisible handle anywhere else overlays a control and steals its hover, which is
|
||||
what a full edge set did to the caption buttons.
|
||||
|
||||
That is a **stopgap** — each drag tick is an RPC hop plus an FFI call, where a
|
||||
real WM grab is compositor-side. `scripts/check-electrobun-resize.ts` watches the
|
||||
installed electrobun for the missing primitives and prints the removal checklist
|
||||
when they appear; it runs from `postinstall`, so an electrobun bump surfaces it
|
||||
without anyone remembering to look. The modern skin keeps the native frame and
|
||||
needs none of this.
|
||||
|
||||
## Memory footprint (expected, not a bug)
|
||||
|
||||
Investigated 2026-07 — packaged build. Roughly **~200 MB macOS / ~300 MB
|
||||
Windows**, 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).
|
||||
Not reducible to tens of MB without dropping the web UI.
|
||||
|
||||
@@ -49,6 +80,83 @@ Not reducible to tens of MB without dropping the web UI.
|
||||
each), and each open tab keeps both panels' full `entries` arrays; terminal
|
||||
transfers are retained until cleared. None affect normal use.
|
||||
|
||||
## Linux runtime dependencies (REQUIRED — the app will not start without them)
|
||||
|
||||
electrobun's `libNativeWrapper.so` links AppIndicator *hard* for the tray, so a
|
||||
missing library is a hard boot failure (`ERR_DLOPEN_FAILED` out of `bun:ffi`
|
||||
`dlopen`), not a degraded tray. The transitive deps (`libdbusmenu-glib`,
|
||||
`libdbusmenu-gtk3`, `libayatana-indicator3`, `libayatana-ido3`) come in as
|
||||
package dependencies:
|
||||
|
||||
| Distro | Install |
|
||||
|---|---|
|
||||
| Arch / CachyOS | `sudo pacman -S libayatana-appindicator` |
|
||||
| Debian / Ubuntu | `sudo apt install libayatana-appindicator3-1` |
|
||||
| Fedora | `sudo dnf install libayatana-appindicator-gtk3` |
|
||||
|
||||
GTK/WebKit itself comes from the distro too — electrobun uses the system webview
|
||||
(`bundleCEF: false`), so `webkit2gtk` must be present.
|
||||
|
||||
Diagnosing a boot failure:
|
||||
|
||||
```sh
|
||||
ldd build/dev-linux-x64/*/bin/libNativeWrapper.so | grep 'not found'
|
||||
```
|
||||
|
||||
`libasar.so => not found` in that output is **expected and harmless** — it ships
|
||||
in the same `bin/` folder and resolves at load time; only an unresolved
|
||||
`libayatana-appindicator3.so.1` is fatal.
|
||||
|
||||
**Tray visibility** is a separate matter from linking: GNOME 3.26+ hides the
|
||||
legacy tray, so the icon needs the
|
||||
[AppIndicator extension](https://extensions.gnome.org/extension/615/appindicator-support/).
|
||||
KDE Plasma, XFCE, MATE and Cinnamon show it out of the box. The app runs fine
|
||||
either way — the tray is a convenience, and every action it offers exists in-app.
|
||||
|
||||
## Linux known issues (upstream electrobun)
|
||||
|
||||
- **No native menus of any kind.** Application menus and `showContextMenu` are
|
||||
both no-ops (electrobun's `dist/api/bun/proc/linux.md`; the app logs
|
||||
"Application menus are not supported on Linux" at boot). The app menubar was
|
||||
already macOS-only, and the panel context menus are now HTML on every platform,
|
||||
so nothing is missing at the UI level — but do not add a feature that depends
|
||||
on a native menu.
|
||||
- **NVIDIA renders black without the DMABUF workaround.** WebKitGTK can't
|
||||
allocate GBM buffers on the proprietary driver, so the window paints nothing
|
||||
while the log looks perfectly healthy. Every launch path sets
|
||||
`WEBKIT_DISABLE_DMABUF_RENDERER=1` when `/proc/driver/nvidia` exists —
|
||||
`scripts/linux-gpu.ts` for dev, the `miniSCP.sh` wrapper in the tarball for
|
||||
releases. The app can't do it itself (Bun's `process.env` writes don't reach
|
||||
child processes); see [gotchas.md](gotchas.md#linux).
|
||||
- **Benign startup noise**: a deprecation warning pointing at
|
||||
`libayatana-appindicator-glib`. Not fatal.
|
||||
- **Unsigned, unpackaged**: the release artifact is a portable tarball — no
|
||||
AppImage, `.deb`, `.desktop` entry or icon installation. Unpack and run
|
||||
`miniSCP.sh` (which execs `bin/launcher`).
|
||||
|
||||
## First-Linux-boot smoke test
|
||||
|
||||
1. Install the AppIndicator package for your distro (table above), then
|
||||
`bun run start`. The window opens **and actually renders the UI** (a black
|
||||
window on NVIDIA means the DMABUF workaround didn't apply — check for
|
||||
`Failed to create GBM buffer` in the log), and the tray icon appears (or
|
||||
doesn't, on bare GNOME — that's the extension caveat, not a failure).
|
||||
2. Right-click a file row: menu appears at the pointer, Send to Other Panel is
|
||||
first, Permissions is present, Escape / click-outside / arrow keys + Enter all
|
||||
work, and a right-click near the bottom-right flips the menu instead of
|
||||
overflowing.
|
||||
3. Right-click empty panel space (New Folder / New File / Refresh / Show in File
|
||||
Manager) and a column header (checkmarks toggle columns live).
|
||||
4. Copy Path puts the path on the clipboard; Show in File Manager opens the
|
||||
distro's file manager; Open (local file) launches the default app via
|
||||
`xdg-open`.
|
||||
5. local↔remote transfers both directions; external editor round-trip
|
||||
(temp download → save → auto-upload) with the inotify watcher.
|
||||
6. Move to Trash lands the item in the desktop's trash; connections.json is 0600.
|
||||
7. `bun scripts/release.ts --target linux-x64` produces
|
||||
`release/linux-x64/miniSCP-Build<N>-linux-x64.tar.gz`; unpack it elsewhere and
|
||||
run `miniSCP.sh`.
|
||||
|
||||
## Windows known issues (upstream electrobun, documented not fixed)
|
||||
|
||||
- **HiDPI blur** — WebView2 renders at 1x on scaled displays
|
||||
|
||||
+4
-4
@@ -7,12 +7,12 @@
|
||||
"ui"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "bun scripts/gen-version.ts && bun scripts/fix-electrobun-cli.ts && bun scripts/dev.ts",
|
||||
"dev": "bun scripts/gen-version.ts && bun scripts/dev.ts",
|
||||
"dev:ui": "bun run --cwd ui start",
|
||||
"build:ui": "bun run --cwd ui build",
|
||||
"build": "bun scripts/gen-version.ts && bun scripts/fix-electrobun-cli.ts && bun run build:ui && bunx electrobun build",
|
||||
"start": "bun scripts/gen-version.ts && bun scripts/fix-electrobun-cli.ts && bun run build:ui && bunx electrobun dev",
|
||||
"postinstall": "bun scripts/gen-version.ts && bun scripts/fix-electrobun-cli.ts",
|
||||
"build": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun build",
|
||||
"start": "bun scripts/gen-version.ts && bun run build:ui && bun scripts/start.ts",
|
||||
"postinstall": "bun scripts/gen-version.ts && bun scripts/check-electrobun-resize.ts",
|
||||
"release": "bun scripts/release.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Watches for electrobun growing a real window-resize gesture, so the
|
||||
// synthesized one can be deleted the moment it becomes dead weight.
|
||||
//
|
||||
// bun scripts/check-electrobun-resize.ts (also runs from `postinstall`)
|
||||
//
|
||||
// WHY THIS EXISTS
|
||||
//
|
||||
// The win7 skin is frameless, which on GTK means undecorated, which means the
|
||||
// window manager supplies no resize borders. electrobun 1.18.1 exposes no
|
||||
// native resize gesture at all, so the app synthesizes one: handles in the
|
||||
// webview drag the frame via RPC. That is a genuine stopgap — jerkier than a
|
||||
// compositor-side grab and always will be — and the thing most likely to
|
||||
// happen to a stopgap is that it outlives its reason and nobody notices.
|
||||
//
|
||||
// So the trigger is automated rather than a comment somebody has to re-read: an
|
||||
// electrobun bump runs `postinstall`, which runs this, which prints the removal
|
||||
// checklist the moment upstream ships the primitive we are waiting for.
|
||||
//
|
||||
// It NEVER fails the install (always exit 0) and is silent when there is
|
||||
// nothing to say — a check that cries wolf gets muted, and a muted check is
|
||||
// worse than none.
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/** The version the synthesized resize was written against and verified on. If
|
||||
* the installed version differs, this script says so once — that is the cue to
|
||||
* re-check upstream's changelog and then bump this constant. */
|
||||
const VALIDATED_VERSION = "1.18.1";
|
||||
|
||||
/**
|
||||
* Symbols that would mean "upstream can resize now". Verified ABSENT in 1.18.1:
|
||||
* the shipped library exports `startWindowMove`/`stopWindowMove` with no resize
|
||||
* counterpart, and imports `gtk_window_begin_move_drag` but not the resize
|
||||
* twin. Any of these appearing is the signal.
|
||||
*
|
||||
* Searched as raw ASCII in the binary rather than via `nm`/`strings`: only the
|
||||
* host platform's library is installed (`dist-linux-x64/…so`, `.dylib`, `.dll`
|
||||
* elsewhere), and `nm` does not exist on Windows. Symbol names live in the
|
||||
* binary as plain text either way.
|
||||
*/
|
||||
const NATIVE_NEEDLES = [
|
||||
"gtk_window_begin_resize_drag", // the GTK primitive, if they ever link it
|
||||
"gtk_window_set_resizable",
|
||||
"startWindowResize", // the obvious sibling of startWindowMove
|
||||
"setWindowResizable",
|
||||
"beginWindowResize",
|
||||
];
|
||||
|
||||
/** Source-level signals in the shipped TS API. */
|
||||
const SOURCE_NEEDLES: { file: string; needle: string; means: string }[] = [
|
||||
{
|
||||
file: "dist/api/bun/core/BrowserWindow.ts",
|
||||
needle: "setResizable",
|
||||
means: "BrowserWindow gained a resizability setter",
|
||||
},
|
||||
{
|
||||
file: "dist/api/bun/core/BrowserWindow.ts",
|
||||
needle: "minWidth",
|
||||
means: "the window constructor gained min-size options (our JS clamp may be redundant)",
|
||||
},
|
||||
{
|
||||
file: "dist/api/bun/preload/dragRegions.ts",
|
||||
needle: "resize",
|
||||
means: "the drag-region preload now knows about resizing",
|
||||
},
|
||||
];
|
||||
|
||||
const CHECKLIST = `
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ electrobun may now support window resizing natively. │
|
||||
│ The synthesized resize is a STOPGAP and should be deleted. │
|
||||
│ Full checklist: │
|
||||
│ ui/src/app/skins/7like/window-resize/window-resize.ts │
|
||||
└────────────────────────────────────────────────────────────────────────┘`;
|
||||
|
||||
function main(): void {
|
||||
const pkgDir = path.join(import.meta.dir, "..", "node_modules", "electrobun");
|
||||
if (!existsSync(pkgDir)) return; // not installed yet (fresh clone mid-install)
|
||||
|
||||
const hits: string[] = [];
|
||||
|
||||
// --- the shipped native library, whichever platform's is installed ---
|
||||
for (const entry of readdirSync(pkgDir)) {
|
||||
if (!entry.startsWith("dist-")) continue;
|
||||
const dir = path.join(pkgDir, entry);
|
||||
for (const file of readdirSync(dir)) {
|
||||
if (!/^libNativeWrapper.*\.(so|dylib|dll)$/.test(file)) continue;
|
||||
// latin1: we are searching for ASCII symbol names inside a binary, and
|
||||
// utf-8 decoding would mangle bytes >0x7f (and can drop invalid
|
||||
// sequences), which could hide a needle sitting next to one.
|
||||
const text = readFileSync(path.join(dir, file), "latin1");
|
||||
for (const needle of NATIVE_NEEDLES) {
|
||||
if (text.includes(needle)) hits.push(`${entry}/${file} contains ${needle}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- the shipped TypeScript API ---
|
||||
for (const { file, needle, means } of SOURCE_NEEDLES) {
|
||||
const full = path.join(pkgDir, file);
|
||||
if (!existsSync(full)) continue;
|
||||
if (readFileSync(full, "utf8").includes(needle)) hits.push(`${file} mentions ${needle} — ${means}`);
|
||||
}
|
||||
|
||||
const version = installedVersion(pkgDir);
|
||||
|
||||
if (hits.length) {
|
||||
console.log(CHECKLIST);
|
||||
console.log(` electrobun ${version ?? "?"} — what changed:`);
|
||||
for (const hit of hits) console.log(` · ${hit}`);
|
||||
console.log("");
|
||||
return;
|
||||
}
|
||||
|
||||
// No resize support. Stay quiet unless the version moved out from under the
|
||||
// hack, in which case one line is the reminder to look.
|
||||
if (version && version !== VALIDATED_VERSION) {
|
||||
console.log(
|
||||
`[electrobun] ${VALIDATED_VERSION} → ${version}: no window-resize API found, so the ` +
|
||||
`synthesized resize stays. Re-check, then bump VALIDATED_VERSION in ` +
|
||||
`scripts/check-electrobun-resize.ts to silence this.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function installedVersion(pkgDir: string): string | null {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path.join(pkgDir, "package.json"), "utf8")).version ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// A broken check must never break an install: swallow everything, exit 0.
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
// Deliberately quiet — this is advisory. Surface it only when run by hand.
|
||||
if (process.env["DEBUG"]) console.error("[electrobun] resize check failed:", error);
|
||||
}
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
// Dev orchestrator: starts ng serve (UI hot reload), waits for it, then runs
|
||||
// `electrobun dev --watch` (relaunches the app when bun-side code changes).
|
||||
import { launchEnv } from "./linux-gpu";
|
||||
|
||||
const DEV_SERVER_URL = "http://localhost:4200";
|
||||
|
||||
const ui = Bun.spawn(["bun", "run", "--cwd", "ui", "start"], {
|
||||
@@ -27,7 +29,7 @@ console.log("Angular dev server is up, launching Electrobun ...");
|
||||
const app = Bun.spawn(["bunx", "electrobun", "dev", "--watch"], {
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
env: { ...process.env, DEV_SERVER_URL },
|
||||
env: launchEnv({ DEV_SERVER_URL }),
|
||||
});
|
||||
|
||||
function shutdown() {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
// Re-signs electrobun's prebuilt CLI binary, which ships with a BROKEN ad-hoc
|
||||
// signature: it is a Bun single-file executable whose payload is appended after
|
||||
// the linker signed it, so `codesign -v` reports "code or signature have been
|
||||
// modified" and the macOS kernel SIGKILLs it at exec. The failure is invisible:
|
||||
// electrobun's bin/electrobun.cjs wrapper spawns the real binary and forwards
|
||||
// `process.exit(code || 0)` — a signal death has code === null, so `bun run
|
||||
// start` printed nothing and exited 0 while never launching the app.
|
||||
// `codesign --force --sign -` re-seals it ad-hoc and it runs normally.
|
||||
//
|
||||
// The binary is downloaded LAZILY by that wrapper on first use, not by install,
|
||||
// so this runs both from postinstall and ahead of every electrobun invocation.
|
||||
// Idempotent, and a no-op off macOS.
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const ROOT = path.join(import.meta.dir, "..");
|
||||
|
||||
/** true if `bin` has (or now has) a signature the kernel will accept. */
|
||||
export function ensureSigned(bin: string): boolean {
|
||||
if (process.platform !== "darwin" || !existsSync(bin)) return true;
|
||||
const valid = () => Bun.spawnSync(["codesign", "-v", bin], { stderr: "ignore" }).success;
|
||||
if (valid()) return true;
|
||||
const signed = Bun.spawnSync(["codesign", "--force", "--sign", "-", bin], { stderr: "pipe" });
|
||||
if (!signed.success || !valid()) {
|
||||
console.warn(`[electrobun-cli] could not re-sign ${path.relative(ROOT, bin)} — the app will not launch`);
|
||||
return false;
|
||||
}
|
||||
console.log(`[electrobun-cli] re-signed ${path.relative(ROOT, bin)} (upstream ad-hoc signature is invalid)`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const pkgDir = path.join(ROOT, "node_modules", "electrobun");
|
||||
const bin = path.join(pkgDir, "bin", "electrobun");
|
||||
|
||||
// The wrapper downloads the CLI on first run and only then execs it, so one
|
||||
// throwaway invocation leaves a downloaded (still unsigned) binary behind
|
||||
// even though that exec is killed.
|
||||
if (process.platform === "darwin" && existsSync(pkgDir) && !existsSync(bin)) {
|
||||
Bun.spawnSync(["bunx", "electrobun", "--version"], { cwd: ROOT, stdout: "ignore", stderr: "ignore" });
|
||||
}
|
||||
|
||||
// .cache is what the wrapper restores bin/ from, so both copies need fixing.
|
||||
const ok = [bin, path.join(pkgDir, ".cache", "electrobun")].map(ensureSigned).every(Boolean);
|
||||
if (!ok) process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,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");
|
||||
+48
-20
@@ -1,22 +1,22 @@
|
||||
// Build one release artifact for one target, on the matching host:
|
||||
// bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64]
|
||||
// bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64|linux-x64]
|
||||
// Default target = the host. electrobun cannot cross-compile OSes (its
|
||||
// packaging needs host tools: codesign/hdiutil on mac, rcedit/PowerShell on
|
||||
// Windows), so win-x64 must run on a Windows machine. mac-x64 IS buildable on
|
||||
// an arm64 Mac: we fetch electrobun's x64 CLI binary and run it under
|
||||
// Rosetta 2 — it sees an x64 host, pulls the x64 core, and packages mac-x64.
|
||||
// Artifacts land in release/<target>/miniSCP-Build<N>-<target>.<ext>.
|
||||
import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
||||
import { cpSync, existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { ensureSigned } from "./fix-electrobun-cli";
|
||||
|
||||
const ROOT = path.join(import.meta.dir, "..");
|
||||
const TARGETS = ["mac-arm64", "mac-x64", "win-x64"] as const;
|
||||
const TARGETS = ["mac-arm64", "mac-x64", "win-x64", "linux-x64"] as const;
|
||||
type Target = (typeof TARGETS)[number];
|
||||
|
||||
function hostTarget(): Target {
|
||||
if (process.platform === "win32") return "win-x64";
|
||||
if (process.platform === "linux") return "linux-x64";
|
||||
return process.arch === "arm64" ? "mac-arm64" : "mac-x64";
|
||||
}
|
||||
|
||||
@@ -44,6 +44,9 @@ if (!TARGETS.includes(requested)) fail(`unknown target "${requested}" (${TARGETS
|
||||
if (requested === "win-x64" && process.platform !== "win32") {
|
||||
fail("win-x64 must be built on a Windows machine/runner — electrobun cannot cross-compile.");
|
||||
}
|
||||
if (requested === "linux-x64" && process.platform !== "linux") {
|
||||
fail("linux-x64 must be built on a Linux machine/runner — electrobun cannot cross-compile.");
|
||||
}
|
||||
if (requested.startsWith("mac") && process.platform !== "darwin") {
|
||||
fail(`${requested} must be built on macOS.`);
|
||||
}
|
||||
@@ -52,7 +55,6 @@ const viaRosetta = requested === "mac-x64" && process.arch === "arm64";
|
||||
// ---- prepare: version, icons, UI bundle ----
|
||||
|
||||
run(["bun", "scripts/gen-version.ts"]);
|
||||
run(["bun", "scripts/fix-electrobun-cli.ts"]);
|
||||
run(["bun", "scripts/gen-icons.ts"]);
|
||||
run(["bun", "run", "--cwd", "ui", "build"]);
|
||||
|
||||
@@ -66,12 +68,7 @@ async function x64Cli(): Promise<string> {
|
||||
const pkg = await Bun.file(path.join(ROOT, "node_modules", "electrobun", "package.json")).json();
|
||||
const dir = path.join(os.homedir(), ".cache", "miniscp", `electrobun-cli-x64-${pkg.version}`);
|
||||
const bin = path.join(dir, "electrobun");
|
||||
// Same broken upstream ad-hoc signature as the arm64 CLI (see
|
||||
// scripts/fix-electrobun-cli.ts) — unsigned it is SIGKILLed on exec.
|
||||
if (existsSync(bin)) {
|
||||
if (!ensureSigned(bin)) fail(`x64 CLI at ${bin} has an unusable code signature`);
|
||||
return bin;
|
||||
}
|
||||
if (existsSync(bin)) return bin;
|
||||
console.log(`[release] downloading electrobun x64 CLI ${pkg.version} (one-time)…`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const url = `https://github.com/blackboardsh/electrobun/releases/download/v${pkg.version}/electrobun-cli-darwin-x64.tar.gz`;
|
||||
@@ -80,7 +77,6 @@ async function x64Cli(): Promise<string> {
|
||||
run(["tar", "-xzf", tarball, "-C", dir]);
|
||||
if (!existsSync(bin)) fail(`x64 CLI tarball did not contain "electrobun" (${dir})`);
|
||||
run(["chmod", "+x", bin]);
|
||||
if (!ensureSigned(bin)) fail(`x64 CLI at ${bin} has an unusable code signature`);
|
||||
return bin;
|
||||
}
|
||||
|
||||
@@ -97,7 +93,12 @@ if (viaRosetta) {
|
||||
|
||||
// ---- collect artifacts ----
|
||||
|
||||
const [osName, archName] = requested === "win-x64" ? ["win", "x64"] : ["macos", requested.slice(4)];
|
||||
const [osName, archName] =
|
||||
requested === "win-x64"
|
||||
? ["win", "x64"]
|
||||
: requested === "linux-x64"
|
||||
? ["linux", "x64"]
|
||||
: ["macos", requested.slice(4)];
|
||||
const prefix = `stable-${osName}-${archName}-`;
|
||||
const artifactsDir = path.join(ROOT, "artifacts");
|
||||
const outDir = path.join(ROOT, "release", requested);
|
||||
@@ -117,15 +118,42 @@ for (const file of produced) {
|
||||
out.push(name);
|
||||
}
|
||||
|
||||
// Windows: also produce a portable zip from the assembled app folder (the
|
||||
// Setup.exe is an installer; portable = unzip anywhere and run). Best-effort:
|
||||
// the folder layout is confirmed by the first real Windows build.
|
||||
if (requested === "win-x64") {
|
||||
// Windows / Linux: package the assembled app folder directly. On Windows that
|
||||
// complements the Setup.exe installer (portable = unzip anywhere and run); on
|
||||
// Linux it is the ONLY artifact, since electrobun emits just the .tar.zst
|
||||
// updater blob there — no installer, AppImage or .deb. Best-effort: the folder
|
||||
// layout is confirmed by the first real build on each OS.
|
||||
if (requested === "win-x64" || requested === "linux-x64") {
|
||||
const appDir = path.join(ROOT, "build", `stable-${osName}-${archName}`, BRANDING.appName);
|
||||
if (existsSync(appDir)) {
|
||||
const portable = path.join(outDir, `${BRANDING.appName}-${buildName}-win-x64-portable.zip`);
|
||||
run(["powershell", "-Command", `Compress-Archive -Force -Path '${appDir}\\*' -DestinationPath '${portable}'`]);
|
||||
out.push(path.basename(portable));
|
||||
if (requested === "win-x64") {
|
||||
const portable = path.join(outDir, `${BRANDING.appName}-${buildName}-win-x64-portable.zip`);
|
||||
run(["powershell", "-Command", `Compress-Archive -Force -Path '${appDir}\\*' -DestinationPath '${portable}'`]);
|
||||
out.push(path.basename(portable));
|
||||
} else {
|
||||
// Ship a wrapper beside the launcher: the packaged app never goes
|
||||
// through scripts/start.ts, and the NVIDIA/DMABUF workaround has to be
|
||||
// in the environment BEFORE the process starts (see scripts/linux-gpu.ts).
|
||||
const wrapper = path.join(appDir, `${BRANDING.appName}.sh`);
|
||||
writeFileSync(
|
||||
wrapper,
|
||||
[
|
||||
"#!/bin/sh",
|
||||
"# WebKitGTK's DMABUF renderer cannot allocate GBM buffers on the NVIDIA",
|
||||
"# proprietary driver — the window renders black. Disable it there only.",
|
||||
'if [ -z "$WEBKIT_DISABLE_DMABUF_RENDERER" ] && [ -e /proc/driver/nvidia ]; then',
|
||||
" export WEBKIT_DISABLE_DMABUF_RENDERER=1",
|
||||
"fi",
|
||||
'exec "$(dirname "$(readlink -f "$0")")/bin/launcher" "$@"',
|
||||
"",
|
||||
].join("\n"),
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const tarball = path.join(outDir, `${BRANDING.appName}-${buildName}-linux-x64.tar.gz`);
|
||||
// -C the parent so the archive unpacks into one named folder
|
||||
run(["tar", "-czf", tarball, "-C", path.dirname(appDir), path.basename(appDir)]);
|
||||
out.push(path.basename(tarball));
|
||||
}
|
||||
} else {
|
||||
console.warn(`[release] WARN: portable skipped — app folder not found at ${appDir}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// Programmatic test for the synthesized window resize's frame arithmetic.
|
||||
//
|
||||
// bun scripts/resize-math-probe.ts
|
||||
//
|
||||
// Why this exists: the win7 skin resizes itself by dragging handles and moving
|
||||
// the native frame (see ui/src/app/skins/7like/window-resize/window-resize.ts —
|
||||
// a STOPGAP with a removal checklist). Pointer drags cannot be synthesized
|
||||
// headlessly on this machine, so the interaction itself needs a human; but the
|
||||
// part most likely to be subtly wrong is pure arithmetic — eight edges, four of
|
||||
// which move the window's ORIGIN as well as its size, plus a minimum-size clamp
|
||||
// that must not let the anchored edge drift. That is all in `resize-math.ts`,
|
||||
// which is deliberately dependency-free so this script can drive it directly.
|
||||
//
|
||||
// The failure this guards against is not "the window does not resize" (obvious
|
||||
// on sight) but "dragging the west edge inward past the minimum walks the whole
|
||||
// window across the screen" — correct-looking until you hit the limit.
|
||||
//
|
||||
// This covers MORE than the UI currently mounts, deliberately: only the
|
||||
// bottom-right grip is wired up (an invisible handle over a caption button
|
||||
// steals its hover — see the component), but the math stays general so that
|
||||
// restoring a side later is one line against already-proven arithmetic.
|
||||
import {
|
||||
MIN_HEIGHT,
|
||||
MIN_WIDTH,
|
||||
RESIZE_EDGES,
|
||||
type Frame,
|
||||
type ResizeEdge,
|
||||
resizeFrame,
|
||||
} from "../ui/src/app/skins/7like/window-resize/resize-math";
|
||||
|
||||
const START: Frame = { x: 100, y: 200, width: 1000, height: 800 };
|
||||
|
||||
interface Case {
|
||||
name: string;
|
||||
edge: ResizeEdge;
|
||||
dx: number;
|
||||
dy: number;
|
||||
want: Frame;
|
||||
}
|
||||
|
||||
// Every expectation is written out in full rather than computed, so a bug in
|
||||
// resizeFrame cannot be mirrored into the expectation by sharing its algebra.
|
||||
const CASES: Case[] = [
|
||||
// --- edges: each moves only what it owns ---
|
||||
{ name: "east grows width only", edge: "e", dx: 50, dy: 999, want: { x: 100, y: 200, width: 1050, height: 800 } },
|
||||
{ name: "south grows height only", edge: "s", dx: 999, dy: 40, want: { x: 100, y: 200, width: 1000, height: 840 } },
|
||||
{ name: "west moves x AND width", edge: "w", dx: -30, dy: 0, want: { x: 70, y: 200, width: 1030, height: 800 } },
|
||||
{ name: "north moves y AND height", edge: "n", dx: 0, dy: -25, want: { x: 100, y: 175, width: 1000, height: 825 } },
|
||||
// --- corners: two axes at once ---
|
||||
{ name: "south-east", edge: "se", dx: 10, dy: 20, want: { x: 100, y: 200, width: 1010, height: 820 } },
|
||||
{ name: "north-west", edge: "nw", dx: -10, dy: -20, want: { x: 90, y: 180, width: 1010, height: 820 } },
|
||||
{ name: "north-east", edge: "ne", dx: 10, dy: -20, want: { x: 100, y: 180, width: 1010, height: 820 } },
|
||||
{ name: "south-west", edge: "sw", dx: -10, dy: 20, want: { x: 90, y: 200, width: 1010, height: 820 } },
|
||||
// --- the anchored edge must not drift once clamped ---
|
||||
// west dragged far east: width parks at MIN, and x parks where the EAST edge
|
||||
// (100 + 1000 = 1100) minus MIN puts it. If x kept tracking dx, the window
|
||||
// would slide away while appearing frozen.
|
||||
{
|
||||
name: "west clamps, east edge holds",
|
||||
edge: "w",
|
||||
dx: 5000,
|
||||
dy: 0,
|
||||
want: { x: 1100 - MIN_WIDTH, y: 200, width: MIN_WIDTH, height: 800 },
|
||||
},
|
||||
{
|
||||
name: "north clamps, south edge holds",
|
||||
edge: "n",
|
||||
dx: 0,
|
||||
dy: 5000,
|
||||
want: { x: 100, y: 1000 - MIN_HEIGHT, width: 1000, height: MIN_HEIGHT },
|
||||
},
|
||||
// east/south clamp the size but never touch the origin
|
||||
{ name: "east clamps, origin holds", edge: "e", dx: -5000, dy: 0, want: { x: 100, y: 200, width: MIN_WIDTH, height: 800 } },
|
||||
{ name: "south clamps, origin holds", edge: "s", dx: 0, dy: -5000, want: { x: 100, y: 200, width: 1000, height: MIN_HEIGHT } },
|
||||
// a corner clamping on BOTH axes at once
|
||||
{
|
||||
name: "north-west clamps both",
|
||||
edge: "nw",
|
||||
dx: 5000,
|
||||
dy: 5000,
|
||||
want: { x: 1100 - MIN_WIDTH, y: 1000 - MIN_HEIGHT, width: MIN_WIDTH, height: MIN_HEIGHT },
|
||||
},
|
||||
// a zero drag must be a no-op, or a click on a handle would nudge the window
|
||||
{ name: "zero drag is identity", edge: "se", dx: 0, dy: 0, want: START },
|
||||
];
|
||||
|
||||
const fmt = (f: Frame) => `${f.x},${f.y} ${f.width}x${f.height}`;
|
||||
|
||||
console.log("[resize-math] frame arithmetic:\n");
|
||||
console.log(" case edge got verdict");
|
||||
let failed = 0;
|
||||
for (const c of CASES) {
|
||||
const got = resizeFrame(c.edge, START, c.dx, c.dy);
|
||||
const ok = fmt(got) === fmt(c.want);
|
||||
if (!ok) failed++;
|
||||
console.log(
|
||||
` ${c.name.padEnd(32)} ${c.edge.padEnd(5)} ${fmt(got).padEnd(22)} ${ok ? "ok" : `FAIL: want ${fmt(c.want)}`}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Structural check: every edge must be exercised above, so adding a ninth
|
||||
// handle without a case cannot slip through.
|
||||
const covered = new Set(CASES.map((c) => c.edge));
|
||||
const missing = RESIZE_EDGES.filter((e) => !covered.has(e));
|
||||
if (missing.length) {
|
||||
console.log(`\n[resize-math] no case covers: ${missing.join(", ")}`);
|
||||
failed++;
|
||||
}
|
||||
|
||||
// The clamp must never produce a frame smaller than the minimum, whatever the
|
||||
// drag — a cheap sweep over every edge and a range of deltas.
|
||||
for (const edge of RESIZE_EDGES) {
|
||||
for (const d of [-9999, -1000, -1, 0, 1, 1000, 9999]) {
|
||||
const got = resizeFrame(edge, START, d, d);
|
||||
if (got.width < MIN_WIDTH || got.height < MIN_HEIGHT) {
|
||||
console.log(`\n[resize-math] ${edge} at delta ${d} produced ${fmt(got)} — under the minimum`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
console.log(`\n[resize-math] ${failed} failure(s)`);
|
||||
process.exit(2);
|
||||
}
|
||||
console.log("\n[resize-math] clean");
|
||||
+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;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
+30
-13
@@ -2,7 +2,6 @@ import Electrobun, { ApplicationMenu, BrowserWindow, Updater, Utils } from "elec
|
||||
import { BRANDING } from "../shared/branding";
|
||||
import type { EditorOpenContext } from "../shared/rpc";
|
||||
import { effectiveConcurrency, type AppSettings } from "../shared/types";
|
||||
import { PanelContextMenu } from "./context-menu";
|
||||
import { EditManager } from "./edits";
|
||||
import { EditorWindows } from "./editor-windows";
|
||||
import { SessionRegistry } from "./fs/registry";
|
||||
@@ -16,7 +15,13 @@ import { migrateLegacyDataDir } from "./store/paths";
|
||||
import { SettingsStore } from "./store/settings";
|
||||
import { AppTray } from "./tray";
|
||||
import { TransferEngine } from "./transfers/engine";
|
||||
import { buildPreload, showWhenReady } from "./windows";
|
||||
import {
|
||||
buildPreload,
|
||||
chromeOptions,
|
||||
initSessionSkin,
|
||||
showWhenReady,
|
||||
trackMaximizeState,
|
||||
} from "./windows";
|
||||
|
||||
const PROD_URL = "views://app/index.html";
|
||||
|
||||
@@ -46,7 +51,6 @@ const engine = new TransferEngine(registry);
|
||||
const store = new ConnectionStore();
|
||||
const settings = new SettingsStore();
|
||||
const edits = new EditManager(registry, engine, settings);
|
||||
const contextMenu = new PanelContextMenu();
|
||||
const watcher = new LocalDirWatcher();
|
||||
const events = {
|
||||
sessionClosed: (_info: { sessionId: string; reason: string }) => {},
|
||||
@@ -56,7 +60,19 @@ const events = {
|
||||
openConnectionManager: () => {},
|
||||
showAbout: () => {},
|
||||
};
|
||||
const rpc = createAppRPC({ registry, engine, store, settings, edits, contextMenu, watcher, events });
|
||||
// Late-bound for the same reason events is: `win` below takes this rpc in its
|
||||
// constructor, so it cannot exist yet. Only the win7 title bar reads it.
|
||||
let mainWin: BrowserWindow | undefined;
|
||||
const rpc = createAppRPC({
|
||||
registry,
|
||||
engine,
|
||||
store,
|
||||
settings,
|
||||
edits,
|
||||
watcher,
|
||||
mainWindow: () => mainWin,
|
||||
events,
|
||||
});
|
||||
// late-bound like events: a watched local dir changed -> tell the main webview
|
||||
watcher.onChange = (dir) => rpc.send.pathRefresh({ sessionId: "local", dir });
|
||||
const url = await getMainViewUrl();
|
||||
@@ -66,8 +82,11 @@ const url = await getMainViewUrl();
|
||||
// fresh via the saveSettings flow below.
|
||||
let currentSettings = await settings.load();
|
||||
setBackendLocale(currentSettings.locale); // native menus/errors follow the UI language
|
||||
|
||||
const isMac = process.platform === "darwin";
|
||||
// Freeze the skin for this process BEFORE any window is constructed. It picks
|
||||
// a component tree and a native frame, neither of which can change on a live
|
||||
// window, so the persisted value may only take effect at the next start — see
|
||||
// initSessionSkin.
|
||||
initSessionSkin(currentSettings.skin);
|
||||
|
||||
const win = new BrowserWindow({
|
||||
title: BRANDING.appName,
|
||||
@@ -78,12 +97,9 @@ const win = new BrowserWindow({
|
||||
x: 120,
|
||||
y: 120,
|
||||
},
|
||||
// macOS: fuse the tab bar with the titlebar — traffic lights overlay the
|
||||
// UI's h-10 tab row (offset centers them vertically). Other platforms keep
|
||||
// the native titlebar and the tab bar stays a separate row.
|
||||
...(isMac
|
||||
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
||||
: {}),
|
||||
// Frame decision lives in chromeOptions — see windows.ts. Constructor-only,
|
||||
// and read from the pinned session skin, not from currentSettings.
|
||||
...chromeOptions(),
|
||||
// lets the UI pick the fused layout + theme before first paint
|
||||
preload: buildPreload("main", currentSettings),
|
||||
rpc,
|
||||
@@ -91,6 +107,8 @@ const win = new BrowserWindow({
|
||||
hidden: true,
|
||||
});
|
||||
showWhenReady(win);
|
||||
mainWin = win;
|
||||
trackMaximizeState(win, (maximized) => rpc.send.windowState({ maximized }));
|
||||
|
||||
const editors = new EditorWindows({
|
||||
registry,
|
||||
@@ -149,7 +167,6 @@ win.on("close", () => {
|
||||
});
|
||||
|
||||
new AppTray(win);
|
||||
contextMenu.onAction = (action) => rpc.send.menuAction({ action });
|
||||
|
||||
// notify on finished transfers while the window is in the background
|
||||
let windowFocused = true;
|
||||
|
||||
@@ -70,6 +70,8 @@ export class ManagerWindow {
|
||||
this.close();
|
||||
},
|
||||
onClose: () => this.close(),
|
||||
// late-bound: the window takes this rpc in its constructor
|
||||
window: () => this.win ?? undefined,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+102
-13
@@ -1,7 +1,6 @@
|
||||
import { BrowserView, Updater, Utils } from "electrobun/bun";
|
||||
import { BrowserView, type BrowserWindow, Updater, Utils } from "electrobun/bun";
|
||||
import type { AppRPC, EditorOpenContext } from "../shared/rpc";
|
||||
import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types";
|
||||
import type { PanelContextMenu } from "./context-menu";
|
||||
import { readFileText, writeFileText, type EditManager } from "./edits";
|
||||
import { listLocalDrives } from "./fs/local";
|
||||
import type { SessionRegistry } from "./fs/registry";
|
||||
@@ -24,8 +23,13 @@ export interface AppDeps {
|
||||
store: ConnectionStore;
|
||||
settings: SettingsStore;
|
||||
edits: EditManager;
|
||||
contextMenu: PanelContextMenu;
|
||||
watcher: LocalDirWatcher;
|
||||
/**
|
||||
* The main BrowserWindow, for the win7 skin's custom title-bar controls.
|
||||
* Lazy for the same reason events is late-bound: the window takes this rpc
|
||||
* instance in its constructor, so it does not exist yet at createAppRPC time.
|
||||
*/
|
||||
mainWindow: () => BrowserWindow | undefined;
|
||||
/** Assigned after the rpc object exists (it needs rpc.send). */
|
||||
events: {
|
||||
sessionClosed: (info: { sessionId: string; reason: string }) => void;
|
||||
@@ -45,6 +49,78 @@ async function wrap<T extends object>(fn: () => Promise<T> | T): Promise<Result<
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Window-control requests for the win7 skin's custom title bar. Shared by all
|
||||
* three window kinds; each passes a getter for ITS OWN window.
|
||||
*
|
||||
* The getter is lazy because the main window's rpc instance is constructed
|
||||
* before the BrowserWindow exists (the window takes the rpc in its
|
||||
* constructor) — same late-binding reason as AppDeps.events. Under the modern
|
||||
* skin nothing calls these; they are harmless no-ops if the window is gone.
|
||||
*/
|
||||
export function windowControlHandlers(
|
||||
getWindow: () => BrowserWindow | undefined,
|
||||
/**
|
||||
* What the title bar's X does. Defaults to a raw `win.close()`. Editor
|
||||
* windows MUST pass their confirmed-close hook instead — the native close
|
||||
* is not cancellable, so a raw close there would skip the dirty prompt
|
||||
* (see gotchas.md "Window `close` is not cancellable").
|
||||
*/
|
||||
onClose: (win: BrowserWindow | undefined) => void = (win) => win?.close(),
|
||||
) {
|
||||
return {
|
||||
windowMinimize: () =>
|
||||
wrap(() => {
|
||||
getWindow()?.minimize();
|
||||
return {};
|
||||
}),
|
||||
|
||||
windowToggleMaximize: () =>
|
||||
wrap(() => {
|
||||
const win = getWindow();
|
||||
if (!win) return { maximized: false };
|
||||
// isMaximized is a synchronous FFI read, so this stays one round trip
|
||||
const next = !win.isMaximized();
|
||||
if (next) win.maximize();
|
||||
else win.unmaximize();
|
||||
return { maximized: next };
|
||||
}),
|
||||
|
||||
windowClose: () =>
|
||||
wrap(() => {
|
||||
onClose(getWindow());
|
||||
return {};
|
||||
}),
|
||||
|
||||
windowIsMaximized: () => wrap(() => ({ maximized: getWindow()?.isMaximized() ?? false })),
|
||||
|
||||
// ---- STOPGAP: synthesized resize ----
|
||||
// A frameless GTK window has no resize borders, and electrobun exposes no
|
||||
// native resize gesture (it has startWindowMove and nothing else — the
|
||||
// shipped libNativeWrapper does not even link gtk_window_begin_resize_drag).
|
||||
// So the webview drags its own handles and moves the frame itself. Both are
|
||||
// synchronous FFI reads/writes, like isMaximized above, so each drag tick
|
||||
// stays one round trip. DELETE WITH THE REST OF THE HACK — the checklist is
|
||||
// in ui/src/app/skins/7like/window-resize/window-resize.ts.
|
||||
windowGetFrame: () =>
|
||||
wrap(() => getWindow()?.getFrame() ?? { x: 0, y: 0, width: 0, height: 0 }),
|
||||
|
||||
// Params are annotated from the schema rather than inferred: this factory
|
||||
// returns a bare object literal, so it gets none of the contextual typing
|
||||
// the inline handler sets inside defineRPC<AppRPC> enjoy.
|
||||
windowSetFrame: ({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
}: AppRPC["bun"]["requests"]["windowSetFrame"]["params"]) =>
|
||||
wrap(() => {
|
||||
getWindow()?.setFrame(x, y, width, height);
|
||||
return {};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Connection-store requests, shared by the main and manager window rpcs. */
|
||||
function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void) {
|
||||
return {
|
||||
@@ -122,8 +198,8 @@ export function createAppRPC({
|
||||
store,
|
||||
settings,
|
||||
edits,
|
||||
contextMenu,
|
||||
watcher,
|
||||
mainWindow,
|
||||
events,
|
||||
}: AppDeps) {
|
||||
return BrowserView.defineRPC<AppRPC>({
|
||||
@@ -221,6 +297,9 @@ export function createAppRPC({
|
||||
return {};
|
||||
}),
|
||||
|
||||
// ---- custom window chrome (win7 skin) ----
|
||||
...windowControlHandlers(mainWindow),
|
||||
|
||||
// ---- connection store ----
|
||||
...connectionStoreHandlers(store, () => events.connectionsChanged()),
|
||||
|
||||
@@ -282,10 +361,12 @@ export function createAppRPC({
|
||||
return {};
|
||||
}),
|
||||
|
||||
// ---- context menu + local shell integration ----
|
||||
showRowMenu: (params) =>
|
||||
// ---- local shell integration (menu itself is HTML, see ui/) ----
|
||||
shellItemAction: ({ action, path }) =>
|
||||
wrap(async () => {
|
||||
contextMenu.show(params);
|
||||
if (action === "reveal") Utils.showItemInFolder(path);
|
||||
else if (action === "open") Utils.openPath(path);
|
||||
else Utils.clipboardWriteText(path);
|
||||
return {};
|
||||
}),
|
||||
|
||||
@@ -295,12 +376,6 @@ export function createAppRPC({
|
||||
return {};
|
||||
}),
|
||||
|
||||
showColumnsMenu: ({ visible }) =>
|
||||
wrap(async () => {
|
||||
contextMenu.showColumns(visible);
|
||||
return {};
|
||||
}),
|
||||
|
||||
// ---- editor windows ----
|
||||
openEditor: (ctx) =>
|
||||
wrap(async () => {
|
||||
@@ -354,6 +429,8 @@ export interface EditorRpcHooks {
|
||||
onSaved: () => void;
|
||||
/** Webview resolved its dirty prompt: close without the native fallback. */
|
||||
onCloseConfirmed: () => void;
|
||||
/** This window, for the win7 title-bar controls (late-bound like the rest). */
|
||||
window: () => BrowserWindow | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -389,6 +466,12 @@ export function createEditorRPC(
|
||||
hooks.onCloseConfirmed();
|
||||
return {};
|
||||
}),
|
||||
|
||||
// win7 skin: this window is frameless, so the webview draws the
|
||||
// chrome. The X routes through onCloseConfirmed (suppressPrompt +
|
||||
// close), exactly like editorClose — the editor UI runs its dirty
|
||||
// prompt BEFORE calling this, same as the ⌘W path.
|
||||
...windowControlHandlers(hooks.window, () => hooks.onCloseConfirmed()),
|
||||
},
|
||||
messages: {
|
||||
editorState: (state) => hooks.onDraft(state),
|
||||
@@ -404,6 +487,8 @@ export interface ManagerRpcHooks {
|
||||
onConnect: (id: string) => void;
|
||||
onOpenStack: (id: string) => void;
|
||||
onClose: () => void;
|
||||
/** This window, for the win7 title-bar controls (late-bound like the rest). */
|
||||
window: () => BrowserWindow | undefined;
|
||||
}
|
||||
|
||||
/** RPC for the single connection-manager window (per-window instance). */
|
||||
@@ -436,6 +521,10 @@ export function createManagerRPC(
|
||||
hooks.onClose();
|
||||
return {};
|
||||
}),
|
||||
|
||||
// win7 skin: frameless window, webview-drawn chrome. The X reuses
|
||||
// the managerClose path so the modal bookkeeping still runs.
|
||||
...windowControlHandlers(hooks.window, () => hooks.onClose()),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { DEFAULT_SETTINGS, type AppSettings } from "../../shared/types";
|
||||
import { clampUiScale, DEFAULT_SETTINGS, type AppSettings } from "../../shared/types";
|
||||
import { appDataDir } from "./paths";
|
||||
|
||||
/** The retired 3-tier axis, mapped onto the scale that replaced it. */
|
||||
const LEGACY_TEXT_SIZE: Record<string, number> = { compact: 0.9, default: 1, large: 1.2 };
|
||||
|
||||
/** App settings persisted bun-side (WKWebView localStorage on views:// is unreliable). */
|
||||
export class SettingsStore {
|
||||
private readonly file = path.join(appDataDir(), "settings.json");
|
||||
@@ -10,7 +13,18 @@ export class SettingsStore {
|
||||
async load(): Promise<AppSettings> {
|
||||
try {
|
||||
const data = JSON.parse(await fs.readFile(this.file, "utf8"));
|
||||
return { ...DEFAULT_SETTINGS, ...data };
|
||||
const settings: AppSettings = { ...DEFAULT_SETTINGS, ...data };
|
||||
// migrate the retired textSize tier into the scale that replaced it —
|
||||
// but never over an explicit uiScale (a file carrying both has already
|
||||
// been migrated once and then adjusted)
|
||||
if (data.uiScale === undefined && typeof data.textSize === "string") {
|
||||
settings.uiScale = LEGACY_TEXT_SIZE[data.textSize] ?? 1;
|
||||
}
|
||||
// clamp ALWAYS, not just on migration: buildPreload writes this value
|
||||
// into every window's root font-size, so a hand-edited settings.json
|
||||
// (uiScale: 100, "2", NaN) must not be able to render the UI unusable
|
||||
settings.uiScale = clampUiScale(Number(settings.uiScale));
|
||||
return settings;
|
||||
} catch {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
|
||||
+100
-13
@@ -1,7 +1,27 @@
|
||||
import { BrowserWindow } from "electrobun/bun";
|
||||
import type { AppSettings } from "../shared/types";
|
||||
import { DEFAULT_SETTINGS, type AppSettings } from "../shared/types";
|
||||
|
||||
const isMac = process.platform === "darwin";
|
||||
/**
|
||||
* The skin THIS PROCESS booted with, frozen at startup by `initSessionSkin`.
|
||||
*
|
||||
* Every window must be built from this, never from the live settings snapshot.
|
||||
* The skin picks a whole component tree (bootstrapped from the preload-baked
|
||||
* __APP_SKIN) and the native frame (constructor-only), so it cannot change
|
||||
* without a restart — but the persisted setting CAN change mid-session. Reading
|
||||
* the live value would boot a newly opened editor or manager into the pending
|
||||
* skin while the main window still runs the old one: two windows, two trees,
|
||||
* two frames.
|
||||
*
|
||||
* Pinning it in one place makes that impossible rather than making each call
|
||||
* site remember. Everything else in the preload keeps using the live snapshot,
|
||||
* because theme/tone/accent/text-size genuinely do apply live.
|
||||
*/
|
||||
let sessionSkin: AppSettings["skin"] = DEFAULT_SETTINGS.skin;
|
||||
|
||||
/** Call once, at startup, before any window is constructed. */
|
||||
export function initSessionSkin(skin: AppSettings["skin"]): void {
|
||||
sessionSkin = skin;
|
||||
}
|
||||
|
||||
/**
|
||||
* The preload globals every window gets. Single composition point: the UI
|
||||
@@ -19,22 +39,70 @@ export function buildPreload(
|
||||
settings: AppSettings,
|
||||
ctx?: unknown,
|
||||
): string {
|
||||
// sessionSkin, NOT settings.skin — a pending skin change must not leak into
|
||||
// a window opened before the restart. See initSessionSkin.
|
||||
let script =
|
||||
`window.__APP_PLATFORM=${JSON.stringify(process.platform)};` +
|
||||
`window.__APP_MODE=${JSON.stringify(mode)};`;
|
||||
`window.__APP_MODE=${JSON.stringify(mode)};` +
|
||||
`window.__APP_SKIN=${JSON.stringify(sessionSkin)};`;
|
||||
if (ctx !== undefined) script += `window.__APP_CTX=${JSON.stringify(ctx)};`;
|
||||
|
||||
// A class, not a data-* attribute: 7.scoped.css is precompiled to require a
|
||||
// literal `.win7` ancestor, so the name is not ours to choose. This is the
|
||||
// ONLY place it is ever written — the UI must never toggle it live, because
|
||||
// each skin's CSS is written against its own tree's markup.
|
||||
if (sessionSkin === "win7") {
|
||||
script += `document.documentElement.classList.add("win7");`;
|
||||
}
|
||||
|
||||
const dataset: Record<string, string> = {};
|
||||
if (settings.theme !== "system") dataset["theme"] = settings.theme;
|
||||
if (settings.darkTone !== "dark") dataset["darkTone"] = settings.darkTone;
|
||||
if (settings.accent !== "graphite") dataset["accent"] = settings.accent;
|
||||
if (settings.textSize !== "default") dataset["text"] = settings.textSize;
|
||||
// interface scale: mirrors SettingsService.applyUiScale so the FIRST paint
|
||||
// is already scaled — without this every window opens at 100% and snaps.
|
||||
// Written as an inline style, not a data attribute: the scale is continuous.
|
||||
if (settings.uiScale !== 1) {
|
||||
script += `document.documentElement.style.fontSize=${JSON.stringify(`${16 * settings.uiScale}px`)};`;
|
||||
}
|
||||
for (const [key, value] of Object.entries(dataset)) {
|
||||
script += `document.documentElement.dataset[${JSON.stringify(key)}]=${JSON.stringify(value)};`;
|
||||
}
|
||||
return script;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native chrome options for a window. The ONE place the frame decision is made —
|
||||
* both the main window (src/bun/index.ts) and openSecondaryWindow spread this.
|
||||
*
|
||||
* Both values are BrowserWindow CONSTRUCTOR options — there is no API to
|
||||
* re-decorate a live window — which is half of why the skin is a launch-time
|
||||
* choice. Takes no argument on purpose: it reads the pinned sessionSkin, so no
|
||||
* call site can accidentally frame a window from a pending setting.
|
||||
*/
|
||||
export function chromeOptions() {
|
||||
const skin = sessionSkin;
|
||||
// win7 draws its own Aero title bar (drag region + our minimize/maximize/
|
||||
// close), so the window is frameless on EVERY platform. `titleBarStyle:
|
||||
// "hidden"` clears `Titled` from the Cocoa style mask, which is also what
|
||||
// removes the macOS traffic lights — there is no separate
|
||||
// hide-traffic-lights API. Linux honours it too: the GTK wrapper calls
|
||||
// gtk_window_set_decorated(false).
|
||||
//
|
||||
// `transparent` is what lets the Aero frame be a real frame: .window's 6px
|
||||
// radius is drawn by the webview OUTSIDE the window's own rectangle, so on
|
||||
// an opaque surface the corners are simply clipped square.
|
||||
// PORTABILITY: on Linux this needs a compositor granting an RGBA visual.
|
||||
if (skin === "win7") return { titleBarStyle: "hidden" as const, transparent: true };
|
||||
|
||||
// modern: the native frame stays. On macOS it fuses with the app's own
|
||||
// header row (hiddenInset), and the traffic lights overlay it — which is
|
||||
// what IS_MAC_CHROME reserves pl-24 for in the shadlike tree.
|
||||
return process.platform === "darwin"
|
||||
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
||||
: {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows are created hidden and revealed on the webview's first dom-ready —
|
||||
* showing them earlier paints the native (white) surface before the themed
|
||||
@@ -55,6 +123,31 @@ export function showWhenReady(win: BrowserWindow): void {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Push maximize-state changes to a window's webview so the win7 title bar can
|
||||
* swap its maximize/restore glyph.
|
||||
*
|
||||
* electrobun emits NO maximize/unmaximize event (only close/resize/move/focus/
|
||||
* blur/keyDown/keyUp), so the state is derived from `resize` — which is also
|
||||
* what makes OS-initiated changes work: double-click zoom, Win+Up, Aero Snap
|
||||
* all resize the window. `isMaximized()` is a synchronous FFI read, so polling
|
||||
* it per resize is cheap; the last-value guard keeps the wire quiet during a
|
||||
* drag-resize. The emitter has no off(), so this closure must not capture
|
||||
* anything that needs to be garbage collected (see WindowRegistry.track).
|
||||
*/
|
||||
export function trackMaximizeState(
|
||||
win: BrowserWindow,
|
||||
push: (maximized: boolean) => void,
|
||||
): void {
|
||||
let last = win.isMaximized();
|
||||
win.on("resize", () => {
|
||||
const now = win.isMaximized();
|
||||
if (now === last) return;
|
||||
last = now;
|
||||
push(now);
|
||||
});
|
||||
}
|
||||
|
||||
export interface SecondaryWindowOptions {
|
||||
title: string;
|
||||
/** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */
|
||||
@@ -70,21 +163,15 @@ export interface SecondaryWindowOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* App-chromed secondary window.
|
||||
*
|
||||
* PORTABILITY: the only spot that branches on platform for secondary-window
|
||||
* chrome. macOS fuses the titlebar (traffic lights overlay the UI's h-10
|
||||
* header row, same treatment as the main window); Windows/Linux keep the
|
||||
* native titlebar and the UI renders a compact non-drag header instead.
|
||||
* App-chromed secondary window. The frame decision is delegated to
|
||||
* chromeOptions (the single frame decision).
|
||||
*/
|
||||
export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow {
|
||||
const win = new BrowserWindow({
|
||||
title: opts.title,
|
||||
url: opts.url,
|
||||
frame: opts.frame,
|
||||
...(isMac
|
||||
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
||||
: {}),
|
||||
...chromeOptions(),
|
||||
preload: buildPreload(opts.mode, opts.settings, opts.ctx),
|
||||
rpc: opts.rpc,
|
||||
hidden: true,
|
||||
|
||||
+12
-1
@@ -33,6 +33,12 @@ export const de = {
|
||||
title: "Optionen",
|
||||
language: "Sprache",
|
||||
system: "System",
|
||||
skin: {
|
||||
label: "Optik",
|
||||
modern: "Modern",
|
||||
win7: "Windows 7",
|
||||
restartHint: "Wird beim nächsten Start von miniSCP übernommen",
|
||||
},
|
||||
theme: { label: "Design", system: "System", light: "Hell", dark: "Dunkel" },
|
||||
tone: { label: "Ton", dark: "Dunkel", darker: "Dunkler" },
|
||||
accent: { label: "Akzent" },
|
||||
@@ -47,7 +53,7 @@ export const de = {
|
||||
teal: "Petrol",
|
||||
},
|
||||
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" },
|
||||
transfers: { label: "Übertragungen", single: "Einzeln", multi: "Mehrere" },
|
||||
atOnce: "Gleichzeitig",
|
||||
@@ -288,6 +294,11 @@ export const de = {
|
||||
copyPath: "Pfad kopieren",
|
||||
delete: "Löschen",
|
||||
columns: "Spalten",
|
||||
tabNew: "Neuer Tab",
|
||||
tabClose: "Tab schließen",
|
||||
tabCloseOthers: "Andere Tabs schließen",
|
||||
tabCloseRight: "Tabs rechts schließen",
|
||||
tabDisconnect: "Trennen",
|
||||
trashTooltip: "Finders Wiederherstellen ist für von hier gelöschte Elemente nicht verfügbar",
|
||||
},
|
||||
errors: {
|
||||
|
||||
+12
-1
@@ -37,6 +37,12 @@ export const en = {
|
||||
title: "Options",
|
||||
language: "Language",
|
||||
system: "System",
|
||||
skin: {
|
||||
label: "Skin",
|
||||
modern: "Modern",
|
||||
win7: "Windows 7",
|
||||
restartHint: "Applies next time you start miniSCP",
|
||||
},
|
||||
theme: { label: "Theme", system: "System", light: "Light", dark: "Dark" },
|
||||
tone: { label: "Tone", dark: "Dark", darker: "Darker" },
|
||||
accent: { label: "Accent" },
|
||||
@@ -51,7 +57,7 @@ export const en = {
|
||||
teal: "Teal",
|
||||
},
|
||||
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" },
|
||||
transfers: { label: "Transfers", single: "Single", multi: "Multi" },
|
||||
atOnce: "At once",
|
||||
@@ -292,6 +298,11 @@ export const en = {
|
||||
copyPath: "Copy Path",
|
||||
delete: "Delete",
|
||||
columns: "Columns",
|
||||
tabNew: "New Tab",
|
||||
tabClose: "Close Tab",
|
||||
tabCloseOthers: "Close Other Tabs",
|
||||
tabCloseRight: "Close Tabs to the Right",
|
||||
tabDisconnect: "Disconnect",
|
||||
trashTooltip: "Finder's Restore is not available for items trashed from here",
|
||||
},
|
||||
errors: {
|
||||
|
||||
+12
-1
@@ -33,6 +33,12 @@ export const es = {
|
||||
title: "Opciones",
|
||||
language: "Idioma",
|
||||
system: "Sistema",
|
||||
skin: {
|
||||
label: "Aspecto",
|
||||
modern: "Moderno",
|
||||
win7: "Windows 7",
|
||||
restartHint: "Se aplica la próxima vez que abras miniSCP",
|
||||
},
|
||||
theme: { label: "Tema", system: "Sistema", light: "Claro", dark: "Oscuro" },
|
||||
tone: { label: "Tono", dark: "Oscuro", darker: "Más oscuro" },
|
||||
accent: { label: "Acento" },
|
||||
@@ -47,7 +53,7 @@ export const es = {
|
||||
teal: "Turquesa",
|
||||
},
|
||||
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" },
|
||||
transfers: { label: "Transferencias", single: "Una", multi: "Varias" },
|
||||
atOnce: "A la vez",
|
||||
@@ -288,6 +294,11 @@ export const es = {
|
||||
copyPath: "Copiar ruta",
|
||||
delete: "Eliminar",
|
||||
columns: "Columnas",
|
||||
tabNew: "Nueva pestaña",
|
||||
tabClose: "Cerrar pestaña",
|
||||
tabCloseOthers: "Cerrar las demás pestañas",
|
||||
tabCloseRight: "Cerrar pestañas a la derecha",
|
||||
tabDisconnect: "Desconectar",
|
||||
trashTooltip: "La opción Restaurar de Finder no está disponible para elementos enviados a la papelera desde aquí",
|
||||
},
|
||||
errors: {
|
||||
|
||||
+12
-1
@@ -35,6 +35,12 @@ export const eu = {
|
||||
title: "Aukerak",
|
||||
language: "Hizkuntza",
|
||||
system: "Sistema",
|
||||
skin: {
|
||||
label: "Itxura",
|
||||
modern: "Modernoa",
|
||||
win7: "Windows 7",
|
||||
restartHint: "Hurrengoan miniSCP abiaraztean aplikatuko da",
|
||||
},
|
||||
theme: { label: "Gaia", system: "Sistema", light: "Argia", dark: "Iluna" },
|
||||
tone: { label: "Tonua", dark: "Iluna", darker: "Ilunagoa" },
|
||||
accent: { label: "Azentua" },
|
||||
@@ -49,7 +55,7 @@ export const eu = {
|
||||
teal: "Turkesa",
|
||||
},
|
||||
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" },
|
||||
transfers: { label: "Transferentziak", single: "Banaka", multi: "Anitz" },
|
||||
atOnce: "Aldi berean",
|
||||
@@ -290,6 +296,11 @@ export const eu = {
|
||||
copyPath: "Kopiatu bidea",
|
||||
delete: "Ezabatu",
|
||||
columns: "Zutabeak",
|
||||
tabNew: "Fitxa berria",
|
||||
tabClose: "Itxi fitxa",
|
||||
tabCloseOthers: "Itxi beste fitxak",
|
||||
tabCloseRight: "Itxi eskuineko fitxak",
|
||||
tabDisconnect: "Deskonektatu",
|
||||
trashTooltip: "Finder-en «Jarri berriro» ez dago erabilgarri hemendik zakarrontzira botatako elementuentzat",
|
||||
},
|
||||
errors: {
|
||||
|
||||
+12
-1
@@ -33,6 +33,12 @@ export const fr = {
|
||||
title: "Options",
|
||||
language: "Langue",
|
||||
system: "Système",
|
||||
skin: {
|
||||
label: "Apparence",
|
||||
modern: "Moderne",
|
||||
win7: "Windows 7",
|
||||
restartHint: "Prend effet au prochain démarrage de miniSCP",
|
||||
},
|
||||
theme: { label: "Thème", system: "Système", light: "Clair", dark: "Sombre" },
|
||||
tone: { label: "Ton", dark: "Sombre", darker: "Plus sombre" },
|
||||
accent: { label: "Accent" },
|
||||
@@ -47,7 +53,7 @@ export const fr = {
|
||||
teal: "Sarcelle",
|
||||
},
|
||||
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" },
|
||||
transfers: { label: "Transferts", single: "Un", multi: "Plusieurs" },
|
||||
atOnce: "Simultanés",
|
||||
@@ -288,6 +294,11 @@ export const fr = {
|
||||
copyPath: "Copier le chemin",
|
||||
delete: "Supprimer",
|
||||
columns: "Colonnes",
|
||||
tabNew: "Nouvel onglet",
|
||||
tabClose: "Fermer l'onglet",
|
||||
tabCloseOthers: "Fermer les autres onglets",
|
||||
tabCloseRight: "Fermer les onglets à droite",
|
||||
tabDisconnect: "Déconnecter",
|
||||
trashTooltip: "La restauration du Finder n'est pas disponible pour les éléments mis à la corbeille d'ici",
|
||||
},
|
||||
errors: {
|
||||
|
||||
+12
-1
@@ -34,6 +34,12 @@ export const ja = {
|
||||
title: "オプション",
|
||||
language: "言語",
|
||||
system: "システム",
|
||||
skin: {
|
||||
label: "外観",
|
||||
modern: "モダン",
|
||||
win7: "Windows 7",
|
||||
restartHint: "次回 miniSCP を起動したときに適用されます",
|
||||
},
|
||||
theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" },
|
||||
tone: { label: "トーン", dark: "ダーク", darker: "より暗く" },
|
||||
accent: { label: "アクセント" },
|
||||
@@ -48,7 +54,7 @@ export const ja = {
|
||||
teal: "ティール",
|
||||
},
|
||||
fileIcons: { label: "ファイルアイコン", plain: "シンプル", multi: "カラー" },
|
||||
interface: { label: "インターフェース", compact: "コンパクト", default: "標準", large: "大" },
|
||||
interface: { label: "インターフェース", smaller: "縮小", larger: "拡大", reset: "100%に戻す" },
|
||||
speedUnit: { label: "速度の単位" },
|
||||
transfers: { label: "転送", single: "1件ずつ", multi: "複数" },
|
||||
atOnce: "同時数",
|
||||
@@ -288,6 +294,11 @@ export const ja = {
|
||||
copyPath: "パスをコピー",
|
||||
delete: "削除",
|
||||
columns: "列",
|
||||
tabNew: "新しいタブ",
|
||||
tabClose: "タブを閉じる",
|
||||
tabCloseOthers: "他のタブを閉じる",
|
||||
tabCloseRight: "右側のタブを閉じる",
|
||||
tabDisconnect: "切断",
|
||||
trashTooltip: "ここからゴミ箱に入れた項目は Finder の「戻す」を利用できません",
|
||||
},
|
||||
errors: {
|
||||
|
||||
+12
-1
@@ -34,6 +34,12 @@ export const zh = {
|
||||
title: "选项",
|
||||
language: "语言",
|
||||
system: "系统",
|
||||
skin: {
|
||||
label: "外观",
|
||||
modern: "现代",
|
||||
win7: "Windows 7",
|
||||
restartHint: "下次启动 miniSCP 时生效",
|
||||
},
|
||||
theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" },
|
||||
tone: { label: "色调", dark: "深色", darker: "更深" },
|
||||
accent: { label: "强调色" },
|
||||
@@ -48,7 +54,7 @@ export const zh = {
|
||||
teal: "青色",
|
||||
},
|
||||
fileIcons: { label: "文件图标", plain: "简约", multi: "彩色" },
|
||||
interface: { label: "界面", compact: "紧凑", default: "默认", large: "大" },
|
||||
interface: { label: "界面", smaller: "缩小", larger: "放大", reset: "重置为100%" },
|
||||
speedUnit: { label: "速度单位" },
|
||||
transfers: { label: "传输", single: "单个", multi: "多个" },
|
||||
atOnce: "同时",
|
||||
@@ -288,6 +294,11 @@ export const zh = {
|
||||
copyPath: "复制路径",
|
||||
delete: "删除",
|
||||
columns: "列",
|
||||
tabNew: "新建标签页",
|
||||
tabClose: "关闭标签页",
|
||||
tabCloseOthers: "关闭其他标签页",
|
||||
tabCloseRight: "关闭右侧标签页",
|
||||
tabDisconnect: "断开连接",
|
||||
trashTooltip: "从此处移入废纸篓的项目无法使用访达的放回原处功能",
|
||||
},
|
||||
errors: {
|
||||
|
||||
+45
-12
@@ -1,6 +1,5 @@
|
||||
import type {
|
||||
AppSettings,
|
||||
ColumnKey,
|
||||
ConnectionConfig,
|
||||
ConnectionsFile,
|
||||
EntryType,
|
||||
@@ -126,20 +125,18 @@ export type AppRPC = {
|
||||
response: Result<{ names: string[] }>;
|
||||
};
|
||||
clearEditLeftovers: { params: Record<string, never>; response: Result };
|
||||
// context menu + local shell integration
|
||||
showRowMenu: {
|
||||
params: {
|
||||
path: string;
|
||||
name: string;
|
||||
type: EntryType;
|
||||
isLocal: boolean;
|
||||
hasEntry: boolean;
|
||||
};
|
||||
// local shell integration
|
||||
/**
|
||||
* Shell actions the context menu triggers on a LOCAL path. The menu
|
||||
* itself is HTML (ui/src/app/context-menu.ts) — electrobun's native
|
||||
* context menu is a no-op on Linux — but these three still need the
|
||||
* bun side's Utils.
|
||||
*/
|
||||
shellItemAction: {
|
||||
params: { action: "reveal" | "open" | "copyPath"; path: string };
|
||||
response: Result;
|
||||
};
|
||||
moveToTrash: { params: { path: string }; response: Result };
|
||||
/** Native column-picker menu (right-click on the list headers). */
|
||||
showColumnsMenu: { params: { visible: ColumnKey[] }; response: Result };
|
||||
/** Native About box (name, Build n, commit, engine versions). */
|
||||
showAbout: { params: Record<string, never>; response: Result };
|
||||
/** Windows drive roots for the local panel ("C:\", …); empty elsewhere. */
|
||||
@@ -157,6 +154,35 @@ export type AppRPC = {
|
||||
managerConnect: { params: { id: string }; response: Result };
|
||||
managerOpenStack: { params: { id: string }; response: Result };
|
||||
managerClose: { params: Record<string, never>; response: Result };
|
||||
// custom window chrome (win7 skin — the window is frameless, so the
|
||||
// webview's title bar owns the controls). Served by every window kind.
|
||||
windowMinimize: { params: Record<string, never>; response: Result };
|
||||
/** Maximize/restore in one call; responds with the resulting state. */
|
||||
windowToggleMaximize: {
|
||||
params: Record<string, never>;
|
||||
response: Result<{ maximized: boolean }>;
|
||||
};
|
||||
windowClose: { params: Record<string, never>; response: Result };
|
||||
/** First paint: the title bar needs the state before any resize fires. */
|
||||
windowIsMaximized: {
|
||||
params: Record<string, never>;
|
||||
response: Result<{ maximized: boolean }>;
|
||||
};
|
||||
/**
|
||||
* STOPGAP — the two halves of the synthesized resize. A frameless GTK
|
||||
* window has no resize borders and electrobun exposes no native resize
|
||||
* gesture, so the webview drags its own handles and moves the frame
|
||||
* itself. Delete both with the rest of the hack; see the removal
|
||||
* checklist in ui/src/app/skins/7like/window-resize/window-resize.ts.
|
||||
*/
|
||||
windowGetFrame: {
|
||||
params: Record<string, never>;
|
||||
response: Result<{ x: number; y: number; width: number; height: number }>;
|
||||
};
|
||||
windowSetFrame: {
|
||||
params: { x: number; y: number; width: number; height: number };
|
||||
response: Result;
|
||||
};
|
||||
// auto-updater (electrobun Updater)
|
||||
/** Main window: re-run the update check on demand (menu item). */
|
||||
checkForUpdate: {
|
||||
@@ -187,6 +213,13 @@ export type AppRPC = {
|
||||
editorCloseRequest: Record<string, never>;
|
||||
/** bun → main: the modal connection manager opened/closed. */
|
||||
managerState: { open: boolean };
|
||||
/**
|
||||
* bun → any window: maximize state changed, so the custom title bar can
|
||||
* swap its maximize/restore glyph. electrobun has no maximize event —
|
||||
* this is derived from the window's `resize` event (see windows.ts
|
||||
* trackMaximizeState), which also covers OS-initiated Aero Snap.
|
||||
*/
|
||||
windowState: { maximized: boolean };
|
||||
/** bun → main: the manager window changed the connection store. */
|
||||
connectionsChanged: Record<string, never>;
|
||||
/**
|
||||
|
||||
+38
-3
@@ -78,6 +78,18 @@ export type AccentTone =
|
||||
export interface AppSettings {
|
||||
/** UI language; "system" resolves to the OS locale (fallback English). */
|
||||
locale: "system" | SupportedLocale;
|
||||
/**
|
||||
* Visual skin. "modern" is the shadcn-style look; "win7" swaps in 7.css
|
||||
* (Windows 7 Aero) and switches the window to custom frameless chrome.
|
||||
* Each skin is a whole component tree under ui/src/app/skins/ — "modern" is
|
||||
* shadlike/, "win7" is 7like/ — selected at bootstrap in ui/src/main.ts.
|
||||
* The value is NOT named after its folder on purpose: it is persisted in
|
||||
* user settings, and "win7" is also the literal class 7.scoped.css is
|
||||
* precompiled to require, so neither can be renamed freely.
|
||||
* Changing this needs a window reopen to take effect on the frame itself —
|
||||
* the CSS half applies live.
|
||||
*/
|
||||
skin: "modern" | "win7";
|
||||
theme: "system" | "light" | "dark";
|
||||
/** Dark appearance: grey ("dark") or near-black AMOLED ("darker"). */
|
||||
darkTone: "dark" | "darker";
|
||||
@@ -88,8 +100,13 @@ export interface AppSettings {
|
||||
/** Visible optional file-list columns (both panels; Name always shows). */
|
||||
columns: ColumnKey[];
|
||||
speedUnit: "MBps" | "Mbps";
|
||||
/** Size of the panel interface: file list, path bar, header controls. */
|
||||
textSize: "compact" | "default" | "large";
|
||||
/**
|
||||
* Interface scale factor, 0.5–4 in 0.1 steps (shown as 50%–400%). Works by
|
||||
* setting the root font-size — every size in the UI is rem, so one lever
|
||||
* scales BOTH skins whole. Replaced the 3-tier `textSize`
|
||||
* (compact|default|large); SettingsStore.load migrates old files.
|
||||
*/
|
||||
uiScale: number;
|
||||
/** Run transfer jobs one at a time, or several in parallel. */
|
||||
transferMode: "single" | "multi";
|
||||
/** How many jobs run at once when transferMode is "multi". */
|
||||
@@ -108,13 +125,14 @@ export interface AppSettings {
|
||||
|
||||
export const DEFAULT_SETTINGS: AppSettings = {
|
||||
locale: "system",
|
||||
skin: "modern",
|
||||
theme: "system",
|
||||
darkTone: "dark",
|
||||
accent: "blue",
|
||||
iconColors: "multi",
|
||||
columns: ["size", "mtime"],
|
||||
speedUnit: "MBps",
|
||||
textSize: "default",
|
||||
uiScale: 1,
|
||||
transferMode: "single",
|
||||
transferConcurrency: 3,
|
||||
autoClearCompleted: true,
|
||||
@@ -123,6 +141,23 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
||||
// autoRefresh: true,
|
||||
};
|
||||
|
||||
export const UI_SCALE_MIN = 0.5;
|
||||
export const UI_SCALE_MAX = 4;
|
||||
export const UI_SCALE_STEP = 0.1;
|
||||
|
||||
/**
|
||||
* Clamp + snap a scale factor to the legal range and step grid. Shared by the
|
||||
* store (a hand-edited settings.json must not be able to render the UI
|
||||
* unusable), the settings service and the stepper — one definition of "valid",
|
||||
* same as effectiveConcurrency below.
|
||||
*/
|
||||
export function clampUiScale(value: number): number {
|
||||
if (!Number.isFinite(value)) return 1;
|
||||
const clamped = Math.min(UI_SCALE_MAX, Math.max(UI_SCALE_MIN, value));
|
||||
// snap to the step grid; toFixed strips float noise (0.7000000000000001 -> 0.7)
|
||||
return Number((Math.round(clamped / UI_SCALE_STEP) * UI_SCALE_STEP).toFixed(2));
|
||||
}
|
||||
|
||||
export function effectiveConcurrency(settings: AppSettings): number {
|
||||
if (settings.transferMode === "single") return 1;
|
||||
return Math.max(1, Math.min(8, Math.round(settings.transferConcurrency) || 1));
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Skin harness — a static gallery rendered headlessly by scripts/shot.ts so the
|
||||
Win7 skin can be LOOKED AT during development instead of inferred from a
|
||||
clean build log. Not shipped: it lives outside ui/src, so the Angular build
|
||||
never sees it.
|
||||
|
||||
Each section pairs 7.css's own documented markup ("reference") with what the
|
||||
app currently renders ("ours"). Where those two disagree visually, 7.css is
|
||||
not reaching our component — that mismatch is the whole point of the page.
|
||||
|
||||
Layout here uses INLINE STYLES on purpose: Tailwind v4 only emits utilities it
|
||||
found while scanning ui/src, so utility classes used only here would not exist
|
||||
in the built stylesheet. {{STYLES}} is replaced by scripts/shot.ts with the
|
||||
real hashed build output.
|
||||
-->
|
||||
<html class="win7" lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>7.css harness</title>
|
||||
<link rel="stylesheet" href="{{STYLES}}" />
|
||||
<style>
|
||||
body { margin: 0; padding: 20px; }
|
||||
h2 { font-size: 13px; margin: 22px 0 6px; opacity: 0.75; }
|
||||
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; }
|
||||
.cell { min-width: 0; }
|
||||
.cell > figcaption {
|
||||
font-size: 11px; opacity: 0.6; margin-bottom: 5px; text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.stage { padding: 14px; background: #6ba5d7; border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>1 — Window frame, glass, drop shadow, active colour</h2>
|
||||
<div class="pair">
|
||||
<figure class="cell" style="margin:0">
|
||||
<figcaption>reference — .window.glass.active</figcaption>
|
||||
<div class="stage">
|
||||
<div class="window glass active" style="width: 320px">
|
||||
<div class="title-bar">
|
||||
<div class="title-bar-text">miniSCP</div>
|
||||
<div class="title-bar-controls">
|
||||
<button aria-label="Minimize"></button>
|
||||
<button aria-label="Maximize"></button>
|
||||
<button aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="window-body"><p>Glass frame, 6px radius, drop shadow.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</figure>
|
||||
<figure class="cell" style="margin:0">
|
||||
<figcaption>ours — app.html shell (active above, inactive below)</figcaption>
|
||||
<div class="stage">
|
||||
<div class="window glass active" style="width: 320px">
|
||||
<div class="title-bar">
|
||||
<div class="title-bar-text">miniSCP</div>
|
||||
<div class="title-bar-controls">
|
||||
<button aria-label="Minimize"></button>
|
||||
<button aria-label="Maximize"></button>
|
||||
<button aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="window-body"><p style="margin:0">Focused window.</p></div>
|
||||
</div>
|
||||
<div class="window glass" style="width: 320px; margin-top: 12px">
|
||||
<div class="title-bar">
|
||||
<div class="title-bar-text">miniSCP</div>
|
||||
<div class="title-bar-controls">
|
||||
<button aria-label="Minimize"></button>
|
||||
<button aria-label="Maximize"></button>
|
||||
<button aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="window-body"><p style="margin:0">Unfocused — chrome desaturates.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</figure>
|
||||
</div>
|
||||
|
||||
<h2>2 — ListView (file panel)</h2>
|
||||
<div class="pair">
|
||||
<figure class="cell" style="margin:0">
|
||||
<figcaption>reference — <table></figcaption>
|
||||
<table class="has-shadow" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th class="highlighted indicator">Size</th>
|
||||
<th>Modified</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>..</td><td style="text-align:right">—</td><td>—</td></tr>
|
||||
<tr><td>Documents</td><td style="text-align:right">—</td><td>3/29/2007 12:00 AM</td></tr>
|
||||
<tr class="highlighted"><td>report.pdf</td><td style="text-align:right">27 KB</td><td>3/23/2007 12:00 AM</td></tr>
|
||||
<tr><td>notes.txt</td><td style="text-align:right">4 KB</td><td>3/13/2007 12:00 AM</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
<figure class="cell" style="margin:0">
|
||||
<figcaption>ours — focused pane (top) vs unfocused pane (bottom)</figcaption>
|
||||
<!-- Focused pane: strong band on selected rows AND the cursor row;
|
||||
the cursor row additionally gets the dotted focus rect. -->
|
||||
<table class="is-active">
|
||||
<colgroup>
|
||||
<col />
|
||||
<col style="width: 5.5rem" />
|
||||
<col style="width: 8.5rem" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th class="highlighted indicator up" style="text-align:right">Size</th>
|
||||
<th>Modified</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>..</td><td style="text-align:right">—</td><td>—</td></tr>
|
||||
<tr class="highlighted"><td>Documents</td><td style="text-align:right">—</td><td>3/29/2007 12:00 AM</td></tr>
|
||||
<tr class="highlighted"><td>report.pdf</td><td style="text-align:right">27 KB</td><td>3/23/2007 12:00 AM</td></tr>
|
||||
<tr class="highlighted is-cursor"><td>notes.txt</td><td style="text-align:right">4 KB</td><td>3/13/2007 12:00 AM</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Unfocused pane: same rows, faint inactive wash, no dots. -->
|
||||
<table style="margin-top:10px">
|
||||
<colgroup>
|
||||
<col />
|
||||
<col style="width: 5.5rem" />
|
||||
<col style="width: 8.5rem" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th style="text-align:right">Size</th>
|
||||
<th>Modified</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>..</td><td style="text-align:right">—</td><td>—</td></tr>
|
||||
<tr class="highlighted"><td>Documents</td><td style="text-align:right">—</td><td>3/29/2007 12:00 AM</td></tr>
|
||||
<tr><td>report.pdf</td><td style="text-align:right">27 KB</td><td>3/23/2007 12:00 AM</td></tr>
|
||||
<tr class="highlighted"><td>notes.txt</td><td style="text-align:right">4 KB</td><td>3/13/2007 12:00 AM</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
</div>
|
||||
|
||||
<h2>2b — Selection geometry (rows must NOT shift when selected)</h2>
|
||||
<div class="pair">
|
||||
<figure class="cell" style="margin:0">
|
||||
<figcaption>no selection</figcaption>
|
||||
<table class="is-active">
|
||||
<colgroup><col /><col style="width:6rem" /></colgroup>
|
||||
<thead><tr><th>Name</th><th>Size</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>aaa.txt</td><td>1 KB</td></tr>
|
||||
<tr><td>bbb.txt</td><td>2 KB</td></tr>
|
||||
<tr><td>ccc.txt</td><td>3 KB</td></tr>
|
||||
<tr><td>ddd.txt</td><td>4 KB</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
<figure class="cell" style="margin:0">
|
||||
<figcaption>middle row selected — baselines must still line up</figcaption>
|
||||
<table class="is-active">
|
||||
<colgroup><col /><col style="width:6rem" /></colgroup>
|
||||
<thead><tr><th>Name</th><th>Size</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>aaa.txt</td><td>1 KB</td></tr>
|
||||
<tr class="highlighted"><td>bbb.txt</td><td>2 KB</td></tr>
|
||||
<tr><td>ccc.txt</td><td>3 KB</td></tr>
|
||||
<tr><td>ddd.txt</td><td>4 KB</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
</div>
|
||||
|
||||
<h2>3 — Context menu</h2>
|
||||
<div class="pair">
|
||||
<figure class="cell" style="margin:0">
|
||||
<figcaption>reference — ul[role=menu]</figcaption>
|
||||
<ul role="menu" style="width:200px">
|
||||
<li role="menuitem"><a href="#m">Send to Other Panel</a></li>
|
||||
<li role="menuitem"><a href="#m">Open</a></li>
|
||||
<li role="menuitem" class="has-divider"><a href="#m">Rename</a></li>
|
||||
<li role="menuitem"><a href="#m">Copy Path</a></li>
|
||||
<li role="menuitem" aria-disabled="true"><a href="#m">Permissions</a></li>
|
||||
<li role="menuitem" class="has-divider"><a href="#m">Delete</a></li>
|
||||
<li role="menuitem">
|
||||
<input type="checkbox" id="h-col-size" checked />
|
||||
<label for="h-col-size">Size</label>
|
||||
</li>
|
||||
<li role="menuitem">
|
||||
<input type="checkbox" id="h-col-kind" />
|
||||
<label for="h-col-kind">Kind</label>
|
||||
</li>
|
||||
</ul>
|
||||
</figure>
|
||||
<figure class="cell" style="margin:0">
|
||||
<figcaption>ours — context-menu.ts output (buttons, divider, accelerator, checkables)</figcaption>
|
||||
<!-- w7-ctx is the app's marker class (flat highlight, no Aero afterglow).
|
||||
It also carries a position:fixed restatement, which the app wants and a
|
||||
review page in normal flow does not — hence the inline static. -->
|
||||
<ul role="menu" class="w7-ctx" style="width:200px;position:static">
|
||||
<li role="menuitem"><button type="button">Send to Other Panel</button></li>
|
||||
<li role="menuitem"><button type="button">Open</button></li>
|
||||
<li role="menuitem"><button type="button">Edit <span>F4</span></button></li>
|
||||
<li role="menuitem" class="has-divider"><button type="button">Rename</button></li>
|
||||
<li role="menuitem"><button type="button">Copy Path</button></li>
|
||||
<li role="menuitem" class="has-divider"><button type="button">Delete</button></li>
|
||||
<li role="menuitem">
|
||||
<input type="checkbox" id="ours-col-size" checked tabindex="-1" />
|
||||
<label for="ours-col-size">Size</label>
|
||||
</li>
|
||||
<li role="menuitem">
|
||||
<input type="checkbox" id="ours-col-kind" tabindex="-1" />
|
||||
<label for="ours-col-kind">Kind</label>
|
||||
</li>
|
||||
</ul>
|
||||
</figure>
|
||||
</div>
|
||||
|
||||
<h2>4 — Controls 7.css already owns (sanity check)</h2>
|
||||
<div class="pair">
|
||||
<figure class="cell" style="margin:0">
|
||||
<figcaption>buttons / textbox / progress</figcaption>
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div style="display:flex;gap:6px"><button>OK</button><button>Cancel</button><button disabled>Disabled</button></div>
|
||||
<input type="text" value="/home/supongo/code" style="width:100%" />
|
||||
<div role="progressbar" style="width:100%"><div style="width:42%"></div></div>
|
||||
<div role="progressbar" class="marquee" style="width:100%"></div>
|
||||
</div>
|
||||
</figure>
|
||||
<figure class="cell" style="margin:0">
|
||||
<figcaption>reference — menu[role=tablist]</figcaption>
|
||||
<menu role="tablist">
|
||||
<button role="tab" aria-selected="true">local</button>
|
||||
<button role="tab">server-a</button>
|
||||
<button role="tab">server-b</button>
|
||||
</menu>
|
||||
<div role="tabpanel" style="padding:10px">Tab body</div>
|
||||
</figure>
|
||||
</div>
|
||||
|
||||
<h2>5 — Workspace tab strip (ours, as tab-bar.ts renders it)</h2>
|
||||
<!--
|
||||
The live app only ever has ONE workspace at startup and there is no way to
|
||||
click "+" from a headless capture, so multi-tab state can only be checked
|
||||
here. This mirrors tab-bar.ts exactly: the tabs sit in a menu[role=tablist]
|
||||
inside a .w7-toolbar row, and the "+" / right-hand tools sit OUTSIDE the
|
||||
menu so 7.css's descendant selector does not style them as tabs.
|
||||
-->
|
||||
<div class="pair">
|
||||
<figure class="cell" style="margin:0">
|
||||
<figcaption>ours — 3 workspaces, 2nd active</figcaption>
|
||||
<div class="w7-toolbar relative flex h-9 items-center gap-0.5 border-b border-border bg-chrome pt-1 pr-1.5 pl-1.5">
|
||||
<menu role="tablist" class="flex min-w-0 items-center overflow-x-auto">
|
||||
<button role="tab" class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5">
|
||||
<span class="truncate">Local</span>
|
||||
</button>
|
||||
<button role="tab" aria-selected="true" class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5">
|
||||
<span class="truncate">build-server</span>
|
||||
</button>
|
||||
<button role="tab" class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5">
|
||||
<span class="truncate">a-very-long-workspace-name-that-truncates</span>
|
||||
</button>
|
||||
</menu>
|
||||
<button class="ml-0.5 grid size-6 place-items-center rounded-md">+</button>
|
||||
<div class="flex-1"></div>
|
||||
<button class="grid h-6 px-2 py-1 place-items-center rounded-md text-[11px]">Connections</button>
|
||||
</div>
|
||||
<div style="height:60px;background:var(--w7-tab-bg,#fff);border:1px solid #8e8f8f;border-top:0"></div>
|
||||
</figure>
|
||||
<figure class="cell" style="margin:0">
|
||||
<figcaption>ours — single workspace (the live startup state)</figcaption>
|
||||
<div class="w7-toolbar relative flex h-9 items-center gap-0.5 border-b border-border bg-chrome pt-1 pr-1.5 pl-1.5">
|
||||
<menu role="tablist" class="flex min-w-0 items-center overflow-x-auto">
|
||||
<button role="tab" aria-selected="true" class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5">
|
||||
<span class="truncate">Local</span>
|
||||
</button>
|
||||
</menu>
|
||||
<button class="ml-0.5 grid size-6 place-items-center rounded-md">+</button>
|
||||
<div class="flex-1"></div>
|
||||
<button class="grid h-6 px-2 py-1 place-items-center rounded-md text-[11px]">Connections</button>
|
||||
</div>
|
||||
<div style="height:60px;background:var(--w7-tab-bg,#fff);border:1px solid #8e8f8f;border-top:0"></div>
|
||||
</figure>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -9,6 +9,7 @@
|
||||
"private": true,
|
||||
"packageManager": "bun@1.3.13",
|
||||
"dependencies": {
|
||||
"7.css": "^0.21.1",
|
||||
"@angular/common": "^22.0.0",
|
||||
"@angular/compiler": "^22.0.0",
|
||||
"@angular/core": "^22.0.0",
|
||||
|
||||
@@ -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 type { FileEntry } from '@shared/types';
|
||||
import { ContextMenuService } from './context-menu.service';
|
||||
import { DialogsService } from './dialogs.service';
|
||||
import { joinPath } from './format';
|
||||
import { type PanelState, type Side } from './panels.service';
|
||||
@@ -26,21 +27,25 @@ export class FileActionsService {
|
||||
private readonly rpc = inject(RpcService);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly dialogs = inject(DialogsService);
|
||||
private readonly menu = inject(ContextMenuService);
|
||||
private readonly settings = inject(SettingsService);
|
||||
|
||||
menuTarget: MenuTarget | null = null;
|
||||
|
||||
/** Right-click entry point: remember the target and ask bun for a native menu. */
|
||||
async openContextMenu(target: MenuTarget): Promise<void> {
|
||||
/** Right-click entry point: remember the target, then open the menu on it. */
|
||||
openContextMenu(event: MouseEvent, target: MenuTarget): void {
|
||||
this.menuTarget = target;
|
||||
const { panel, entry } = target;
|
||||
await this.rpc.request.showRowMenu({
|
||||
path: entry?.path ?? panel.cwd(),
|
||||
name: entry?.name ?? '',
|
||||
type: entry?.type ?? 'dir',
|
||||
isLocal: panel.sessionId() === 'local',
|
||||
hasEntry: !!entry,
|
||||
});
|
||||
this.menu.open(
|
||||
event,
|
||||
this.menu.rowItems({
|
||||
path: entry?.path ?? panel.cwd(),
|
||||
name: entry?.name ?? '',
|
||||
type: entry?.type ?? 'dir',
|
||||
isLocal: panel.sessionId() === 'local',
|
||||
hasEntry: !!entry,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async trash(panel: PanelState, entry: FileEntry): Promise<void> {
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close every tab except one — the tab menu's "Close Other Tabs".
|
||||
*
|
||||
* Snapshot the ids FIRST: closeWorkspace mutates the list on every call, so
|
||||
* iterating the live signal would skip half of them. It also refuses the
|
||||
* last tab, which is exactly the wanted floor here.
|
||||
*/
|
||||
closeOtherWorkspaces(id: number): void {
|
||||
for (const other of this.workspaces().map((w) => w.id)) {
|
||||
if (other !== id) this.closeWorkspace(other);
|
||||
}
|
||||
this.setActiveWorkspace(id);
|
||||
}
|
||||
|
||||
/** Close every tab to the right of one, in visual order. */
|
||||
closeWorkspacesToRight(id: number): void {
|
||||
const list = this.workspaces();
|
||||
const index = list.findIndex((w) => w.id === id);
|
||||
if (index < 0) return;
|
||||
for (const target of list.slice(index + 1).map((w) => w.id)) {
|
||||
this.closeWorkspace(target);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop both of a tab's remote sessions, keeping the tab itself. Unlike
|
||||
* `disconnectPanel` this targets a tab by id, because the right-clicked tab
|
||||
* is not necessarily the active one.
|
||||
*/
|
||||
async disconnectWorkspace(id: number): Promise<void> {
|
||||
const workspace = this.workspaces().find((w) => w.id === id);
|
||||
if (!workspace) return;
|
||||
for (const panel of [workspace.left, workspace.right]) {
|
||||
const sessionId = panel.sessionId();
|
||||
if (sessionId === 'local') continue;
|
||||
await this.rpc.request.disconnect({ sessionId });
|
||||
this.resetToLocal(panel);
|
||||
}
|
||||
}
|
||||
|
||||
setActiveWorkspace(id: number): void {
|
||||
if (this.activeWorkspaceId() !== id) {
|
||||
this.activeWorkspaceId.set(id);
|
||||
@@ -0,0 +1,67 @@
|
||||
// Injected by the bun process via BrowserWindow preload (src/bun/windows.ts
|
||||
// buildPreload) before any page script. Brand-neutral names so rebrands never
|
||||
// touch this file.
|
||||
import type { EditorOpenContext } from '@shared/rpc';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__APP_PLATFORM?: string;
|
||||
/** Which root component this window boots. */
|
||||
__APP_MODE?: 'main' | 'editor' | 'manager';
|
||||
/** Which skin tree this window boots. Baked into the preload because
|
||||
* settings resolve too late to decide layout without a flash. */
|
||||
__APP_SKIN?: 'modern' | 'win7';
|
||||
/** Window-kind specific context (EditorOpenContext for editor windows). */
|
||||
__APP_CTX?: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?? '') : '';
|
||||
|
||||
export const IS_MAC = PLATFORM === 'darwin';
|
||||
|
||||
export const IS_WINDOWS = PLATFORM === 'win32';
|
||||
|
||||
export const IS_LINUX = PLATFORM === 'linux';
|
||||
|
||||
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. Keyed on the real
|
||||
* platform — the keyboard does not change with the window chrome. */
|
||||
export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+';
|
||||
export const ALT_LABEL = IS_MAC ? '⌥' : 'Alt+';
|
||||
|
||||
export const APP_MODE: 'main' | 'editor' | 'manager' =
|
||||
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
|
||||
|
||||
export const EDITOR_CONTEXT =
|
||||
typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined;
|
||||
|
||||
/**
|
||||
* Which skin tree this window booted. Read from the preload rather than from
|
||||
* SettingsService because both are needed before first paint — main.ts picks a
|
||||
* root component from it, and it must not change under a running window (the
|
||||
* native frame is fixed at BrowserWindow construction).
|
||||
*/
|
||||
export const APP_SKIN: 'modern' | 'win7' =
|
||||
typeof window !== 'undefined' ? (window.__APP_SKIN ?? 'modern') : 'modern';
|
||||
|
||||
export const IS_WIN7_CHROME = APP_SKIN === 'win7';
|
||||
|
||||
/**
|
||||
* True when macOS traffic lights overlay the app's own header row — the fused
|
||||
* `hiddenInset` layout, which reserves `pl-24` for them. False under the win7
|
||||
* skin even on macOS: `titleBarStyle: 'hidden'` removes the traffic lights
|
||||
* altogether, so reserving space would leave a gap. Only the shadlike tree
|
||||
* reads this; 7like draws its own title bar and never defers to native chrome.
|
||||
*/
|
||||
export const IS_MAC_CHROME = IS_MAC && !IS_WIN7_CHROME;
|
||||
|
||||
/**
|
||||
* Kill the webview's default right-click menu (Reload, Inspect Element…)
|
||||
* everywhere the app doesn't show its own native menu. Text fields keep the
|
||||
* system text menu (cut/copy/paste, spelling).
|
||||
*/
|
||||
export function blockNativeContextMenu(event: Event): void {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest('input, textarea, [contenteditable="true"]')) return;
|
||||
event.preventDefault();
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export class RpcService {
|
||||
private managerStateListeners = new Set<(open: boolean) => void>();
|
||||
private connectionsListeners = new Set<() => void>();
|
||||
private updateStatusListeners = new Set<(status: UpdateStatus) => void>();
|
||||
private windowStateListeners = new Set<(maximized: boolean) => void>();
|
||||
|
||||
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
|
||||
private ready: Promise<void> = Promise.resolve();
|
||||
@@ -71,6 +72,9 @@ export class RpcService {
|
||||
updateStatus: (status) => {
|
||||
for (const listener of this.updateStatusListeners) listener(status);
|
||||
},
|
||||
windowState: ({ maximized }) => {
|
||||
for (const listener of this.windowStateListeners) listener(maximized);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -99,6 +103,15 @@ export class RpcService {
|
||||
this.menuListeners.add(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an action that originated in the UI (the HTML context menu) as if
|
||||
* it had arrived from bun, so app.ts keeps ONE handler for menu actions
|
||||
* regardless of whether the app menubar or a right-click produced them.
|
||||
*/
|
||||
emitMenuAction(action: string): void {
|
||||
for (const listener of this.menuListeners) listener(action);
|
||||
}
|
||||
|
||||
onSettingsChanged(listener: (settings: AppSettings) => void): void {
|
||||
this.settingsListeners.add(listener);
|
||||
}
|
||||
@@ -123,6 +136,12 @@ export class RpcService {
|
||||
this.updateStatusListeners.add(listener);
|
||||
}
|
||||
|
||||
/** win7 skin: the frameless window's maximize state changed (derived from
|
||||
* the native resize event, so Aero Snap and Win+Up land here too). */
|
||||
onWindowState(listener: (maximized: boolean) => void): void {
|
||||
this.windowStateListeners.add(listener);
|
||||
}
|
||||
|
||||
/** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */
|
||||
get request() {
|
||||
const rpc = this.rpc;
|
||||
@@ -1,19 +1,20 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
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 { RpcService } from './rpc.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SettingsService {
|
||||
readonly locale = signal<AppSettings['locale']>(DEFAULT_SETTINGS.locale);
|
||||
readonly skin = signal<AppSettings['skin']>(DEFAULT_SETTINGS.skin);
|
||||
readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme);
|
||||
readonly darkTone = signal<AppSettings['darkTone']>(DEFAULT_SETTINGS.darkTone);
|
||||
readonly accent = signal<AppSettings['accent']>(DEFAULT_SETTINGS.accent);
|
||||
readonly iconColors = signal<AppSettings['iconColors']>(DEFAULT_SETTINGS.iconColors);
|
||||
readonly columns = signal<ColumnKey[]>(DEFAULT_SETTINGS.columns);
|
||||
readonly speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit);
|
||||
readonly textSize = signal<AppSettings['textSize']>(DEFAULT_SETTINGS.textSize);
|
||||
readonly uiScale = signal<number>(DEFAULT_SETTINGS.uiScale);
|
||||
readonly transferMode = signal<AppSettings['transferMode']>(DEFAULT_SETTINGS.transferMode);
|
||||
readonly transferConcurrency = signal(DEFAULT_SETTINGS.transferConcurrency);
|
||||
readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted);
|
||||
@@ -50,22 +51,26 @@ export class SettingsService {
|
||||
|
||||
private applyAll(settings: AppSettings): void {
|
||||
this.locale.set(settings.locale);
|
||||
this.skin.set(settings.skin);
|
||||
this.theme.set(settings.theme);
|
||||
this.darkTone.set(settings.darkTone);
|
||||
this.accent.set(settings.accent);
|
||||
this.iconColors.set(settings.iconColors);
|
||||
this.columns.set(settings.columns);
|
||||
this.speedUnit.set(settings.speedUnit);
|
||||
this.textSize.set(settings.textSize);
|
||||
this.uiScale.set(settings.uiScale);
|
||||
this.transferMode.set(settings.transferMode);
|
||||
this.transferConcurrency.set(settings.transferConcurrency);
|
||||
this.autoClearCompleted.set(settings.autoClearCompleted);
|
||||
this.editor.set(settings.editor);
|
||||
this.editorApp.set(settings.editorApp);
|
||||
// NO applySkin here, deliberately — see setSkin. applyAll also runs on the
|
||||
// settingsChanged push, so flipping the class here would break every OPEN
|
||||
// editor and manager window too, not just the one that changed the setting.
|
||||
this.applyTheme();
|
||||
this.applyDarkTone();
|
||||
this.applyAccent();
|
||||
this.applyTextSize();
|
||||
this.applyUiScale();
|
||||
this.applyLocale();
|
||||
}
|
||||
|
||||
@@ -75,6 +80,30 @@ export class SettingsService {
|
||||
void this.persist();
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the preference and NOTHING else — the skin is a launch-time choice.
|
||||
*
|
||||
* It deliberately does not touch the `.win7` class, which is the whole bug
|
||||
* this replaced: each skin's CSS is written against its OWN component tree's
|
||||
* markup, and the tree is fixed at bootstrap from the preload-baked
|
||||
* __APP_SKIN. Flipping the class live therefore painted 7.css onto shadlike
|
||||
* markup that has no .window.glass, no .title-bar, no menu[role=tablist] and
|
||||
* no <table> ListView — a broken window, not a reskinned one.
|
||||
*
|
||||
* A reload would not have helped either: the preload string is frozen at
|
||||
* webview construction (a persistent WebKitUserScript), and titleBarStyle /
|
||||
* transparent have no setters, so the native frame cannot change on a live
|
||||
* window. win7 -> modern without a reframe is the dangerous direction — the
|
||||
* modern tree draws no title bar, so it would land in a frameless window
|
||||
* with no way to move, minimize or close it.
|
||||
*
|
||||
* The options row shows the saved value as pending until the next start.
|
||||
*/
|
||||
setSkin(skin: AppSettings['skin']): void {
|
||||
this.skin.set(skin);
|
||||
void this.persist();
|
||||
}
|
||||
|
||||
setTheme(theme: AppSettings['theme']): void {
|
||||
this.theme.set(theme);
|
||||
this.applyTheme();
|
||||
@@ -111,9 +140,9 @@ export class SettingsService {
|
||||
void this.persist();
|
||||
}
|
||||
|
||||
setTextSize(size: AppSettings['textSize']): void {
|
||||
this.textSize.set(size);
|
||||
this.applyTextSize();
|
||||
setUiScale(scale: number): void {
|
||||
this.uiScale.set(clampUiScale(scale));
|
||||
this.applyUiScale();
|
||||
void this.persist();
|
||||
}
|
||||
|
||||
@@ -142,6 +171,12 @@ export class SettingsService {
|
||||
void this.persist();
|
||||
}
|
||||
|
||||
// There is deliberately no applySkin(). The `.win7` class is written once by
|
||||
// buildPreload at DOCUMENT_START for the skin this window actually booted,
|
||||
// and must never change afterwards — see setSkin for why. Every other apply*
|
||||
// below is safe to run live because it only re-points tokens, and both skins
|
||||
// consume the same token system.
|
||||
|
||||
private applyTheme(): void {
|
||||
const theme = this.theme();
|
||||
if (theme === 'system') {
|
||||
@@ -169,12 +204,17 @@ export class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
private applyTextSize(): void {
|
||||
const size = this.textSize();
|
||||
if (size === 'default') {
|
||||
delete document.documentElement.dataset['text'];
|
||||
/** The whole interface scale is ONE declaration: every size in the app is
|
||||
* rem, so moving the root font-size moves both skins in lockstep. 1 = the
|
||||
* browser-default 16px root; delete-when-default keeps the pristine state
|
||||
* attribute-free like the other axes. buildPreload mirrors this for a
|
||||
* flash-free first paint. */
|
||||
private applyUiScale(): void {
|
||||
const scale = this.uiScale();
|
||||
if (scale === 1) {
|
||||
document.documentElement.style.removeProperty('font-size');
|
||||
} else {
|
||||
document.documentElement.dataset['text'] = size;
|
||||
document.documentElement.style.fontSize = `${16 * scale}px`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,13 +225,14 @@ export class SettingsService {
|
||||
protected settings(): AppSettings {
|
||||
return {
|
||||
locale: this.locale(),
|
||||
skin: this.skin(),
|
||||
theme: this.theme(),
|
||||
darkTone: this.darkTone(),
|
||||
accent: this.accent(),
|
||||
iconColors: this.iconColors(),
|
||||
columns: this.columns(),
|
||||
speedUnit: this.speedUnit(),
|
||||
textSize: this.textSize(),
|
||||
uiScale: this.uiScale(),
|
||||
transferMode: this.transferMode(),
|
||||
transferConcurrency: this.transferConcurrency(),
|
||||
autoClearCompleted: this.autoClearCompleted(),
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Every window-level shortcut the app answers to, by name. Components switch
|
||||
* on these ids instead of key-matching inline, so the whole keymap is ONE
|
||||
* table — the precondition for the vim-style bindings planned next, which
|
||||
* will need a mode/leader state machine wrapped around exactly this point.
|
||||
*/
|
||||
export type ShortcutId =
|
||||
// main window (workspace/tab management)
|
||||
| 'new-tab'
|
||||
| 'close-tab'
|
||||
| 'cycle-tab'
|
||||
| 'cycle-tab-back'
|
||||
| 'go-to-tab' // 1-9; the digit rides in ShortcutMatch.digit
|
||||
// editor window
|
||||
| 'save'
|
||||
| 'find'
|
||||
| 'close-window'
|
||||
| 'toggle-wrap'
|
||||
// any window
|
||||
| 'scale-up'
|
||||
| 'scale-down'
|
||||
| 'scale-reset';
|
||||
|
||||
export interface ShortcutMatch {
|
||||
id: ShortcutId;
|
||||
/** Only for go-to-tab: the digit pressed, 1–9. */
|
||||
digit?: number;
|
||||
}
|
||||
|
||||
/** Which keymap a window consults. 'manager' answers only to the any-window
|
||||
* shortcuts (scale). Escape/Tab stay component-local: their meaning is
|
||||
* contextual state (find bar open? close prompt up? which pane?), not a
|
||||
* global binding. */
|
||||
export type ShortcutScope = 'main' | 'editor' | 'manager';
|
||||
|
||||
/**
|
||||
* The keyboard layer: modifier helpers + one declarative keymap.
|
||||
*
|
||||
* House rules encoded here rather than re-derived per component (they were
|
||||
* previously copy-pasted inline in six places, drifting apart):
|
||||
* - `mod` is metaKey OR ctrlKey — ⌘ on macOS, Ctrl elsewhere. The keyboard
|
||||
* does not change with the window chrome (platform.ts says the same about
|
||||
* MOD_LABEL).
|
||||
* - Layout-dependent keys match on `event.code`, not `event.key`: digits
|
||||
* (AZERTY's digit row shifts), +/- (key varies wildly across layouts), and
|
||||
* letter chords with Alt (macOS Option produces Ω for ⌥Z). Plain letter
|
||||
* chords match on `key` so they follow the user's layout.
|
||||
* - isTypingTarget guards shortcuts that would be hostile mid-typing. It is
|
||||
* deliberately NOT applied inside resolve(): the existing bindings rely on
|
||||
* the path bar's stopPropagation and the editor WANTS Mod+S while typing.
|
||||
* Callers opt in per shortcut (the scale keys do).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ShortcutsService {
|
||||
/** The platform command modifier: ⌘ on macOS, Ctrl elsewhere. Mouse events
|
||||
* qualify too — Mod+click toggle-select in the panels uses the same rule. */
|
||||
isMod(event: KeyboardEvent | MouseEvent): boolean {
|
||||
return event.metaKey || event.ctrlKey;
|
||||
}
|
||||
|
||||
/** True when the event originates from something the user types into. */
|
||||
isTypingTarget(event: KeyboardEvent): boolean {
|
||||
const target = event.target as HTMLElement | null;
|
||||
return !!target?.closest('input, textarea, select, [contenteditable="true"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an event against the scope's keymap. Returns null for anything the
|
||||
* table does not claim — callers must then leave the event alone.
|
||||
*/
|
||||
resolve(event: KeyboardEvent, scope: ShortcutScope): ShortcutMatch | null {
|
||||
const mod = this.isMod(event);
|
||||
const key = event.key.toLowerCase();
|
||||
|
||||
// ---- any window: interface scale ----
|
||||
// event.code, not key: '+' needs Shift on many layouts and '-' moves
|
||||
// around; the physical Equal/Minus keys (and the numpad pair) are stable.
|
||||
if (mod && !event.altKey) {
|
||||
if (event.code === 'Equal' || event.code === 'NumpadAdd') return { id: 'scale-up' };
|
||||
if (event.code === 'Minus' || event.code === 'NumpadSubtract') return { id: 'scale-down' };
|
||||
if (event.code === 'Digit0' || event.code === 'Numpad0') return { id: 'scale-reset' };
|
||||
}
|
||||
|
||||
if (scope === 'manager') return null; // any-window shortcuts only
|
||||
|
||||
if (scope === 'main') {
|
||||
if (mod && key === 't') return { id: 'new-tab' };
|
||||
if (mod && key === 'w') return { id: 'close-tab' };
|
||||
// ctrlKey only, never meta: ⌘Tab is the macOS app switcher
|
||||
if (event.ctrlKey && event.key === 'Tab') {
|
||||
return { id: event.shiftKey ? 'cycle-tab-back' : 'cycle-tab' };
|
||||
}
|
||||
if (mod && !event.shiftKey && !event.altKey) {
|
||||
const digit = /^(?:Digit|Numpad)([1-9])$/.exec(event.code)?.[1];
|
||||
if (digit) return { id: 'go-to-tab', digit: Number(digit) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// scope === 'editor'
|
||||
if (mod && key === 's') return { id: 'save' };
|
||||
if (mod && key === 'f') return { id: 'find' };
|
||||
// on macOS the app menu intercepts ⌘W before this ever fires; this arm is
|
||||
// the Windows/Linux path
|
||||
if (mod && key === 'w') return { id: 'close-window' };
|
||||
if (event.altKey && event.code === 'KeyZ') return { id: 'toggle-wrap' };
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// Injected by the bun process via BrowserWindow preload (src/bun/windows.ts
|
||||
// buildPreload) before any page script. Brand-neutral names so rebrands never
|
||||
// touch this file.
|
||||
import type { EditorOpenContext } from '@shared/rpc';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__APP_PLATFORM?: string;
|
||||
/** Which root component this window boots. */
|
||||
__APP_MODE?: 'main' | 'editor' | 'manager';
|
||||
/** Window-kind specific context (EditorOpenContext for editor windows). */
|
||||
__APP_CTX?: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?? '') : '';
|
||||
|
||||
/** True when the window uses macOS hiddenInset chrome (header fused with titlebar). */
|
||||
export const IS_MAC_CHROME = PLATFORM === 'darwin';
|
||||
|
||||
export const IS_WINDOWS = PLATFORM === 'win32';
|
||||
|
||||
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. */
|
||||
export const MOD_LABEL = IS_MAC_CHROME ? '⌘' : 'Ctrl+';
|
||||
export const ALT_LABEL = IS_MAC_CHROME ? '⌥' : 'Alt+';
|
||||
|
||||
export const APP_MODE: 'main' | 'editor' | 'manager' =
|
||||
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
|
||||
|
||||
export const EDITOR_CONTEXT =
|
||||
typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined;
|
||||
|
||||
/**
|
||||
* Kill the webview's default right-click menu (Reload, Inspect Element…)
|
||||
* everywhere the app doesn't show its own native menu. Text fields keep the
|
||||
* system text menu (cut/copy/paste, spelling).
|
||||
*/
|
||||
export function blockNativeContextMenu(event: Event): void {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest('input, textarea, [contenteditable="true"]')) return;
|
||||
event.preventDefault();
|
||||
}
|
||||
@@ -0,0 +1,840 @@
|
||||
/* The SCOPED 7.css build: postcss-prefix-selector rewrote every selector to
|
||||
`.win7` (root rules) or `.win7 <sel>` (the rest), so the whole framework is
|
||||
inert until the `.win7` class is on <html> — written once by buildPreload
|
||||
for the skin this window booted, and never changed after. All 61
|
||||
icons are data URIs, so nothing needs copying into assets — and NOT the
|
||||
`7.inline.css` build, which flattens away the --w7-* vars the dark palette
|
||||
below depends on. The deep path is required: 7.css ships no `exports` map
|
||||
and Tailwind's resolver only reads `style`/`mainFields`, so a bare
|
||||
`@import "7.css"` finds no entry. */
|
||||
@import "7.css/dist/7.scoped.css" layer(skin);
|
||||
|
||||
/* ==========================================================================
|
||||
Windows 7 skin — buildPreload puts `.win7` on <html> at first paint
|
||||
==========================================================================
|
||||
|
||||
Three tiers, and the split matters:
|
||||
|
||||
- `@layer skin` (the 7.css import at the top of this file) owns the
|
||||
CONTROLS: buttons, inputs, tabs, progress bars, tree views, the Aero
|
||||
title bar. It sits ABOVE `utilities`, so a bare `button` rule in 7.css
|
||||
beats `.rounded-md` on that same button. That inversion is the whole
|
||||
trick — it restyles ~1900 inline utility classes with no template edits.
|
||||
- This unlayered section owns the BRIDGE: it re-points the app's own
|
||||
semantic tokens at Win7 values, so utilities Tailwind still controls
|
||||
(bg-card, border-border, text-muted-foreground) also come out
|
||||
Win7-correct. It adds NO new tokens on purpose — a new colour token
|
||||
would need a matching `--color-*` line in `@theme inline`.
|
||||
- `@layer skinfix` (last, above `skin`) hands individual properties BACK to
|
||||
the utilities layer with `revert-layer`, for the places where 7.css's
|
||||
Win7-dialog metrics are wrong for this app.
|
||||
|
||||
WARNING for future edits: because `skin` outranks `utilities`, any Tailwind
|
||||
utility added later to a <button>, <input>, <select>, <textarea> or <table>
|
||||
silently loses under this skin. `skinfix` is the only place to fix that.
|
||||
|
||||
Specificity notes — the blocks below must beat the token blocks above:
|
||||
:root.win7 (0,2,0) beats base :root (0,1,0)
|
||||
:root.win7[data-theme='dark'] (0,3,0) beats :root[data-theme=…] (0,2,0)
|
||||
and ties the darker-tone block (0,3,0),
|
||||
winning on source order (it is last).
|
||||
The accent formula blocks are deliberately NOT overridden: they only set
|
||||
--primary/--ring/--cursor-tint, and letting them through is what makes the
|
||||
accent picker recolour the Aero chrome — authentic, Win7 shipped exactly
|
||||
that ("Window Color" personalization). */
|
||||
|
||||
/* Segoe UI is not on macOS/Linux and is not redistributable. Noto Sans is the
|
||||
substitute 7.css itself names in its --w7-font fallback stack. Vendored
|
||||
(OFL-1.1) from @fontsource/noto-sans@5.3.0, files/noto-sans-latin-{400,700}
|
||||
-normal.woff2 -> ./fonts/, i.e. INSIDE this skin: the font exists only for
|
||||
the Win7 look, and keeping it here means the url() below stays relative to
|
||||
this file wherever the skin folder moves. (It used to sit at ui/src/fonts/
|
||||
next to the old single stylesheet — splitting this file out rebased the
|
||||
url() three directories deeper and would have silently 404'd the font.)
|
||||
Vendored rather than @imported from node_modules because Tailwind inlines
|
||||
imported CSS, which would leave @fontsource's relative url() rebased against
|
||||
the wrong directory. They live under src/ (not public/) so esbuild emits them
|
||||
as hashed assets and rewrites the url() — a public/ path is not resolvable at
|
||||
build time. */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans App';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: block;
|
||||
src: url('./fonts/noto-sans-latin-400-normal.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Noto Sans App';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: block;
|
||||
src: url('./fonts/noto-sans-latin-700-normal.woff2') format('woff2');
|
||||
}
|
||||
|
||||
:root.win7 {
|
||||
/* Real Segoe first (Windows), bundled substitute everywhere else. */
|
||||
--w7-family: 'Segoe UI', 'SegoeUI', 'Noto Sans App', system-ui, sans-serif;
|
||||
/* ~7.css's 9pt, in REM so it follows the interface scale (the root
|
||||
font-size lever) like every other size in the app — 0.75rem = 12px at the
|
||||
16px root. Restating the `font` SHORTHAND (not just the family) is
|
||||
deliberate: 7.css applies `font: var(--w7-font)` on 15 component rules,
|
||||
and the shorthand also resets weight and line-height — pinning 400/1.4
|
||||
here stops those rules from flattening the app's `font-medium` / leading. */
|
||||
--w7-fs: 0.75rem;
|
||||
--w7-font: 400 var(--w7-fs) / 1.4 var(--w7-family);
|
||||
|
||||
/* Surface ramp, Win7 Explorer. This INVERTS the modern ramp's direction:
|
||||
there --chrome (frame strips) sits below --background (content panes);
|
||||
here the content pane is the brightest thing on screen and the frame is
|
||||
the #f0f0f0 window surface. Deliberate — that is what Explorer was. */
|
||||
--background: #ffffff;
|
||||
--chrome: #f0f0f0;
|
||||
--card: #f0f0f0;
|
||||
--foreground: #000000;
|
||||
--card-foreground: #000000;
|
||||
--muted-foreground: #6d6d6d;
|
||||
--secondary: #e8e8e8;
|
||||
--secondary-foreground: #000000;
|
||||
--muted: #e8e8e8;
|
||||
/* Win7's hover is a pale blue wash, not a grey — matches --w7-el-grad-h */
|
||||
--accent: #eaf6fd;
|
||||
--accent-foreground: #000000;
|
||||
--border: #d5d5d5;
|
||||
--input: #abadb3; /* 7.css's own text-input border */
|
||||
--destructive: #c62828;
|
||||
--destructive-foreground: #ffffff;
|
||||
--success: #06b025; /* the Win7 progress-bar green */
|
||||
|
||||
/* 0.3125rem = 5px at the 16px root — NOT 3px: `rounded-md` is 74 of the 112
|
||||
radius utilities in the templates and compiles to
|
||||
calc(var(--radius) - 0.125rem), so 5 lands it exactly on --w7-el-bdr (3px).
|
||||
--radius itself feeds `rounded-lg` (12 uses) at 5px, near the 6px window
|
||||
radius. Bare `rounded` (26 uses) is a Tailwind literal 0.25rem. Rem so the
|
||||
corners follow the interface scale with everything else. */
|
||||
--radius: 0.3125rem;
|
||||
}
|
||||
|
||||
/* Light values for every 7.css literal the skinfix rules below restate through
|
||||
vars (7.css hardcodes these outside its var system). The values are 7.css's
|
||||
own, verbatim — light must render exactly as stock. Dark overrides live in
|
||||
the duplicated dark blocks above, which is the house pattern: rules are
|
||||
written ONCE and theme through tokens. (The previous attribute-keyed dark
|
||||
rules silently missed SYSTEM-dark — the media-query path sets no attribute.) */
|
||||
:root.win7 {
|
||||
/* ListView (listview.css) */
|
||||
--w7-lv-bg: #fff;
|
||||
--w7-lv-bd: #c0c1cd;
|
||||
--w7-lv-c: #000;
|
||||
--w7-lv-head-grad: linear-gradient(180deg, #fff 45%, #fafafa 0, #f0f0f0);
|
||||
--w7-lv-head-bd: #d7d7d7;
|
||||
--w7-lv-cell-bd: #eee;
|
||||
/* Selected row. Explorer painted a FLAT blue, not a gradient — 7.css reuses
|
||||
its Aero element-hover gradient for the band, which is what made the list
|
||||
look glossy. These are dedicated tokens on purpose: --w7-el-grad-h is
|
||||
consumed by buttons and tabs and must keep its gradient.
|
||||
`-idle` is the unfocused pane's band. Explorer greyed the selection out
|
||||
when the list lost focus rather than dropping it, which is exactly the
|
||||
commander convention this app already follows (see the pane-focus model
|
||||
below): the band never disappears, only its strength changes. */
|
||||
--w7-sel-bg: #cbe8f6;
|
||||
--w7-sel-bg-idle: #dedede;
|
||||
/* Tabs (tabs.css). 7.css hardcodes `color: #222` on every tablist button,
|
||||
outside its own var system, which is unreadable on the dark surface. */
|
||||
--w7-tab-c: #222;
|
||||
/* Menu (menu.css) */
|
||||
--w7-mn-c: #000;
|
||||
--w7-mn-bd: #0006;
|
||||
--w7-mn-stripe: inset 1px 0 #00000026, inset -1px 0 #fff;
|
||||
--w7-mn-divider: inset 0 1px #00000026, inset 0 -1px #fff;
|
||||
--w7-mn-check-ring: #b3d3f9;
|
||||
--w7-mn-check-c: #0c12a1;
|
||||
/* Glass caption streaks (window.css .window.glass>.title-bar): a literal.
|
||||
The two #fff5 corner shines wash a DARK caption silver, so dark subdues
|
||||
every white in the stack while keeping the streak structure. */
|
||||
--w7-glass-title: linear-gradient(135deg, #fff5 70px, transparent 100px), linear-gradient(225deg, #fff5 70px, transparent 100px), linear-gradient(54deg, #0002 0 4%, #6661 6% 6%, #0002 8% 10%, #0002 15% 16%, #aaa1 17% 18%, #0002 23% 24%, #bbb2 25% 26%, #0002 31% 33%, #0002 34% 34.5%, #bbb2 36% 40%, #0002 41% 41.5%, #bbb2 44% 45%, #bbb2 46% 47%, #0002 48% 49%, #0002 50% 50.5%, #0002 56% 56.5%, #bbb2 57% 63%, #0002 67% 69%, #bbb2 69.5% 70%, #0002 73.5% 74%, #bbb2 74.5% 79%, #0002 80% 84%, #aaa2 85% 86%, #0002 87%, #bbb1 90%) 0 / 100vw 100vh no-repeat fixed;
|
||||
/* Caption text (window.css .title-bar-text: black + 8-fold white halo) */
|
||||
--w7-cap-c: #000;
|
||||
--w7-cap-glow: 0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff,
|
||||
0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff;
|
||||
}
|
||||
|
||||
/* Hand-authored dark Win7. Microsoft never shipped one — this exists so the
|
||||
theme and tone settings stay live under the skin. Only the colour-carrying
|
||||
--w7-* vars are listed: --w7-el-grad and --w7-w-grad are DERIVED from the
|
||||
values below and recolour for free, while --w7-el-grad-h/-a and
|
||||
--w7-li-bg-hl are literal hex in 7.css and so must be restated. The ~25
|
||||
geometry vars and ~20 base64 icon vars are theme-independent.
|
||||
DUPLICATED for forced-dark and system-dark, per the rule the token blocks
|
||||
above set: every dark value goes in BOTH. */
|
||||
:root.win7[data-theme='dark'] {
|
||||
--w7-surface: #2b2b2b;
|
||||
--w7-el-bg: #3a3a3a;
|
||||
--w7-el-bg-d: #333333;
|
||||
--w7-el-bg-s-1: #333333;
|
||||
--w7-el-bg-s-2: #262626;
|
||||
--w7-el-bd: #565656;
|
||||
--w7-el-bd-h: #4a8cbe;
|
||||
--w7-el-bd-a: #3f6c8c;
|
||||
--w7-el-bd-d: #3f3f3f;
|
||||
--w7-el-c: #f0f0f0;
|
||||
--w7-el-c-d: #6f6f6f;
|
||||
--w7-el-sd: inset 0 0 0 1px #ffffff14;
|
||||
--w7-el-grad-h: linear-gradient(#3c5a6b 45%, #315062 45%, #2b4557);
|
||||
--w7-el-grad-a: linear-gradient(#2c4657, #24384a 30% 50%, #1d2f3f 50%, #16242f);
|
||||
--w7-li-bd-hl: #3f6c8c;
|
||||
--w7-li-bg-hl: linear-gradient(#ffffff14, #4a6b8433 90%, #ffffff1a);
|
||||
--w7-w-bd: #000000e6;
|
||||
/* Ares-style black glass: near-black ground; 7.css's own alpha streaks and
|
||||
highlight gradients become the gloss. Blue lives in accents (selection,
|
||||
active tab), not in the glass itself. */
|
||||
--w7-w-bg: #101013;
|
||||
--w7-tab-bg: #1c1c1e;
|
||||
--w7-title-c: #f5f5f5;
|
||||
--w7-link-c: #6fb3ff;
|
||||
/* literal restatements consumed by the skinfix rules (see :root.win7) */
|
||||
--w7-lv-bg: var(--w7-el-bg-s-2);
|
||||
--w7-lv-bd: var(--w7-el-bd-d);
|
||||
--w7-lv-c: var(--w7-el-c);
|
||||
--w7-lv-head-grad: linear-gradient(180deg, var(--w7-el-bg) 45%, var(--w7-el-bg-d) 0, var(--w7-el-bg-s-1));
|
||||
--w7-lv-head-bd: var(--w7-el-bd-d);
|
||||
--w7-lv-cell-bd: var(--w7-el-bd-d);
|
||||
--w7-sel-bg: #2a4a63;
|
||||
--w7-sel-bg-idle: #3a3a3a;
|
||||
--w7-tab-c: var(--w7-el-c);
|
||||
--w7-mn-c: var(--w7-el-c);
|
||||
--w7-mn-bd: var(--w7-el-bd-d);
|
||||
--w7-mn-stripe: inset 1px 0 #00000059, inset -1px 0 #ffffff14;
|
||||
--w7-mn-divider: inset 0 1px #00000059, inset 0 -1px #ffffff14;
|
||||
--w7-mn-check-ring: var(--w7-li-bd-hl);
|
||||
--w7-mn-check-c: var(--w7-el-c);
|
||||
--w7-glass-title: linear-gradient(135deg, #ffffff1a 70px, transparent 100px), linear-gradient(225deg, #ffffff1a 70px, transparent 100px), linear-gradient(54deg, #0003 0 4%, #ffffff08 6% 6%, #0003 8% 10%, #0003 15% 16%, #ffffff0d 17% 18%, #0003 23% 24%, #ffffff10 25% 26%, #0003 31% 33%, #0003 34% 34.5%, #ffffff10 36% 40%, #0003 41% 41.5%, #ffffff10 44% 45%, #ffffff10 46% 47%, #0003 48% 49%, #0003 50% 50.5%, #0003 56% 56.5%, #ffffff10 57% 63%, #0003 67% 69%, #ffffff10 69.5% 70%, #0003 73.5% 74%, #ffffff10 74.5% 79%, #0003 80% 84%, #ffffff0d 85% 86%, #0003 87%, #ffffff0a 90%) 0 / 100vw 100vh no-repeat fixed;
|
||||
/* Ares: white caption on black glass, dark halo instead of white */
|
||||
--w7-cap-c: var(--w7-title-c);
|
||||
--w7-cap-glow: 0 0 8px #000c, 0 0 8px #000c, 0 1px 2px #000;
|
||||
--w7-link-c-h: #9acbff;
|
||||
|
||||
--background: #1f1f1f;
|
||||
--chrome: #2b2b2b;
|
||||
--card: #2b2b2b;
|
||||
--foreground: #f0f0f0;
|
||||
--card-foreground: #f0f0f0;
|
||||
--muted-foreground: #a0a0a0;
|
||||
--secondary: #383838;
|
||||
--secondary-foreground: #f0f0f0;
|
||||
--muted: #383838;
|
||||
--accent: #2f4a5c;
|
||||
--accent-foreground: #f0f0f0;
|
||||
--border: #3c3c3c;
|
||||
--input: #55585c;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root.win7:not([data-theme='light']) {
|
||||
--w7-surface: #2b2b2b;
|
||||
--w7-el-bg: #3a3a3a;
|
||||
--w7-el-bg-d: #333333;
|
||||
--w7-el-bg-s-1: #333333;
|
||||
--w7-el-bg-s-2: #262626;
|
||||
--w7-el-bd: #565656;
|
||||
--w7-el-bd-h: #4a8cbe;
|
||||
--w7-el-bd-a: #3f6c8c;
|
||||
--w7-el-bd-d: #3f3f3f;
|
||||
--w7-el-c: #f0f0f0;
|
||||
--w7-el-c-d: #6f6f6f;
|
||||
--w7-el-sd: inset 0 0 0 1px #ffffff14;
|
||||
--w7-el-grad-h: linear-gradient(#3c5a6b 45%, #315062 45%, #2b4557);
|
||||
--w7-el-grad-a: linear-gradient(#2c4657, #24384a 30% 50%, #1d2f3f 50%, #16242f);
|
||||
--w7-li-bd-hl: #3f6c8c;
|
||||
--w7-li-bg-hl: linear-gradient(#ffffff14, #4a6b8433 90%, #ffffff1a);
|
||||
--w7-w-bd: #000000e6;
|
||||
--w7-w-bg: #101013;
|
||||
--w7-tab-bg: #1c1c1e;
|
||||
--w7-title-c: #f5f5f5;
|
||||
--w7-link-c: #6fb3ff;
|
||||
/* literal restatements consumed by the skinfix rules (see :root.win7) */
|
||||
--w7-lv-bg: var(--w7-el-bg-s-2);
|
||||
--w7-lv-bd: var(--w7-el-bd-d);
|
||||
--w7-lv-c: var(--w7-el-c);
|
||||
--w7-lv-head-grad: linear-gradient(180deg, var(--w7-el-bg) 45%, var(--w7-el-bg-d) 0, var(--w7-el-bg-s-1));
|
||||
--w7-lv-head-bd: var(--w7-el-bd-d);
|
||||
--w7-lv-cell-bd: var(--w7-el-bd-d);
|
||||
--w7-sel-bg: #2a4a63;
|
||||
--w7-sel-bg-idle: #3a3a3a;
|
||||
--w7-tab-c: var(--w7-el-c);
|
||||
--w7-mn-c: var(--w7-el-c);
|
||||
--w7-mn-bd: var(--w7-el-bd-d);
|
||||
--w7-mn-stripe: inset 1px 0 #00000059, inset -1px 0 #ffffff14;
|
||||
--w7-mn-divider: inset 0 1px #00000059, inset 0 -1px #ffffff14;
|
||||
--w7-mn-check-ring: var(--w7-li-bd-hl);
|
||||
--w7-mn-check-c: var(--w7-el-c);
|
||||
--w7-glass-title: linear-gradient(135deg, #ffffff1a 70px, transparent 100px), linear-gradient(225deg, #ffffff1a 70px, transparent 100px), linear-gradient(54deg, #0003 0 4%, #ffffff08 6% 6%, #0003 8% 10%, #0003 15% 16%, #ffffff0d 17% 18%, #0003 23% 24%, #ffffff10 25% 26%, #0003 31% 33%, #0003 34% 34.5%, #ffffff10 36% 40%, #0003 41% 41.5%, #ffffff10 44% 45%, #ffffff10 46% 47%, #0003 48% 49%, #0003 50% 50.5%, #0003 56% 56.5%, #ffffff10 57% 63%, #0003 67% 69%, #ffffff10 69.5% 70%, #0003 73.5% 74%, #ffffff10 74.5% 79%, #0003 80% 84%, #ffffff0d 85% 86%, #0003 87%, #ffffff0a 90%) 0 / 100vw 100vh no-repeat fixed;
|
||||
/* Ares: white caption on black glass, dark halo instead of white */
|
||||
--w7-cap-c: var(--w7-title-c);
|
||||
--w7-cap-glow: 0 0 8px #000c, 0 0 8px #000c, 0 1px 2px #000;
|
||||
--w7-link-c-h: #9acbff;
|
||||
|
||||
--background: #1f1f1f;
|
||||
--chrome: #2b2b2b;
|
||||
--card: #2b2b2b;
|
||||
--foreground: #f0f0f0;
|
||||
--card-foreground: #f0f0f0;
|
||||
--muted-foreground: #a0a0a0;
|
||||
--secondary: #383838;
|
||||
--secondary-foreground: #f0f0f0;
|
||||
--muted: #383838;
|
||||
--accent: #2f4a5c;
|
||||
--accent-foreground: #f0f0f0;
|
||||
--border: #3c3c3c;
|
||||
--input: #55585c;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark tone "darker": Aero has no meaningful true black, so rather than leave
|
||||
the Tone row a dead control the skin just goes a step deeper. Duplicated
|
||||
forced + system like everything else. */
|
||||
:root.win7[data-theme='dark'][data-dark-tone='darker'] {
|
||||
--w7-surface: #1a1a1a;
|
||||
--w7-el-bg: #262626;
|
||||
--w7-w-bg: #060607;
|
||||
--background: #121212;
|
||||
--chrome: #1a1a1a;
|
||||
--card: #1a1a1a;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root.win7:not([data-theme='light'])[data-dark-tone='darker'] {
|
||||
--w7-surface: #1a1a1a;
|
||||
--w7-el-bg: #262626;
|
||||
--w7-w-bg: #060607;
|
||||
--background: #121212;
|
||||
--chrome: #1a1a1a;
|
||||
--card: #1a1a1a;
|
||||
}
|
||||
}
|
||||
|
||||
/* Accent -> Aero window colour, so the existing accent picker keeps meaning
|
||||
under the skin. --w7-w-bg is the one colour 7.css documents as settable.
|
||||
LIGHT ONLY: in dark the glass is Ares black regardless of accent (the accent
|
||||
keeps tinting selection etc. through --primary). These arms out-rank the
|
||||
accent rule below on specificity — without them it wins the tie on source
|
||||
order and dark glass turns accent-coloured. */
|
||||
:root.win7[data-accent] {
|
||||
--w7-w-bg: var(--primary);
|
||||
}
|
||||
:root.win7[data-theme='dark'][data-accent] {
|
||||
--w7-w-bg: #101013;
|
||||
}
|
||||
:root.win7[data-theme='dark'][data-dark-tone='darker'][data-accent] {
|
||||
--w7-w-bg: #060607;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root.win7:not([data-theme='light'])[data-accent] {
|
||||
--w7-w-bg: #101013;
|
||||
}
|
||||
:root.win7:not([data-theme='light'])[data-dark-tone='darker'][data-accent] {
|
||||
--w7-w-bg: #060607;
|
||||
}
|
||||
}
|
||||
|
||||
/* Unlayered on purpose: the html/body rule above sets font-family unlayered,
|
||||
so a layered override would lose. Family only — NOT font-size: the app's
|
||||
rem-based utilities are root-relative, and shifting the root would move
|
||||
every line box for no gain. */
|
||||
.win7 body {
|
||||
font-family: var(--w7-family);
|
||||
}
|
||||
|
||||
/* The win7 window is a FRAMELESS, natively transparent window whose only
|
||||
opaque surface is meant to be `.window` — that is the whole premise of the
|
||||
6px rounded frame, since the area outside the radius must let the desktop
|
||||
through. The unlayered `html, body { background: var(--background) }` above
|
||||
broke that premise silently: it painted an opaque rectangle behind the
|
||||
frame, so the rounded corners had an opaque ground filling them in and the
|
||||
window read as a rectangle with a drawn-on curve.
|
||||
Unlayered on purpose, same reason as the font-family rule: the html/body
|
||||
background it overrides is itself unlayered, and unlayered declarations beat
|
||||
every @layer regardless of specificity — a `skinfix` rule could not touch
|
||||
it. Scoped to .win7 so the modern skin keeps its opaque ground. */
|
||||
html.win7,
|
||||
html.win7 body {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Neutralizers live in `skin`, NOT `skinfix`, and this is load-bearing:
|
||||
`revert-layer` rolls a property back to the previous LAYER, so from inside
|
||||
`skin` it lands on `utilities` (the Tailwind classes already in the
|
||||
templates) — which is the whole point. From `skinfix` it would land back on
|
||||
7.css and do nothing. Within `skin` these beat the imported rules on equal
|
||||
specificity by source order, since the import is at the top of this file. */
|
||||
@layer skin {
|
||||
/* 7.css sizes every <button> like a Win7 dialog push button
|
||||
(min-width 75px, min-height 23px, padding 0 12px). Most of this app's 84
|
||||
buttons are ~24px icon squares sized by utilities, so without this the tab
|
||||
bar wraps and the options popover overflows. Hand the BOX MODEL back to
|
||||
the utilities layer and keep 7.css's paint. The title bar's own controls
|
||||
are matched by a MORE specific 7.css selector (.title-bar-controls button)
|
||||
and so keep their Aero glyph metrics untouched. */
|
||||
.win7 button {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: revert-layer;
|
||||
text-align: revert-layer;
|
||||
color: revert-layer;
|
||||
}
|
||||
|
||||
/* 7.css hardcodes #fff / #abadb3 / #222 on form controls and tables, outside
|
||||
its own var system. Reverting routes them back through the bridged tokens
|
||||
above, which is what makes dark mode nearly free. */
|
||||
.win7 input[type='text'],
|
||||
.win7 input[type='password'],
|
||||
.win7 input[type='number'],
|
||||
.win7 input[type='search'],
|
||||
.win7 textarea {
|
||||
background-color: revert-layer;
|
||||
border-color: revert-layer;
|
||||
}
|
||||
.win7 table {
|
||||
background-color: revert-layer;
|
||||
color: revert-layer;
|
||||
}
|
||||
|
||||
/* Win7 toolbar buttons are FLAT with a hover glow, not Aero slabs —
|
||||
Explorer's command bar worked exactly that way. `w7-toolbar` marks the
|
||||
three header rows; a plain `.title-bar` sibling is deliberately NOT one of
|
||||
them, so its controls keep the real Aero treatment. `w7-flat` is the
|
||||
per-button escape hatch. Both classes are inert under the modern skin.
|
||||
`:not([role='tab'])` is load-bearing: the workspace strip IS a w7-toolbar,
|
||||
so without the exemption this rule would strip the border, gradient and
|
||||
both pseudo-elements off every tab and leave 7.css's tab component drawing
|
||||
nothing. Tabs want the full Aero slab; the +/Connections/options buttons
|
||||
beside them want the flat treatment.
|
||||
Only `background` stays here — it is the one property that needs to land
|
||||
on `utilities` via revert-layer. The literals that suppress the Aero slab
|
||||
moved to `skinfix`, because 7.css restates border-color at
|
||||
`.win7 button:not(:disabled):not(:hover)` = (0,3,1), which outranks
|
||||
`.win7 .w7-flat` = (0,2,0). The toolbar arm only ever won that fight by
|
||||
tying on specificity and landing later in the same layer — an accident,
|
||||
and one w7-flat did not share: it rendered a bordered slab regardless. */
|
||||
.win7 .w7-toolbar button:not([role='tab']),
|
||||
.win7 .w7-flat {
|
||||
background: revert-layer;
|
||||
}
|
||||
.win7 .w7-toolbar button:not([role='tab'])::before,
|
||||
.win7 .w7-toolbar button:not([role='tab'])::after,
|
||||
.win7 .w7-flat::before,
|
||||
.win7 .w7-flat::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* Above `skin`, for rules that must state a value rather than defer to a
|
||||
neighbouring layer. */
|
||||
@layer skinfix {
|
||||
/* ---- Scale bridge: re-express 7.css's px metrics in rem ----
|
||||
7.css sizes everything in px (370 of them, zero rem) and its --w7-font
|
||||
shorthand reaches only 15 rules' TYPOGRAPHY — the boxes stay frozen. Under
|
||||
the interface scale (root font-size lever) win7 text would grow inside
|
||||
23px buttons, 14px table rows and a 15px-line-height caption. Every value
|
||||
below is 7.css's own, converted at the 16px root (23px -> 1.4375rem …),
|
||||
so scale 1.0 renders pixel-identical to stock and every other scale keeps
|
||||
the boxes proportional to the text.
|
||||
Deliberately NOT converted: 1px hairlines and focus rects (the tr border
|
||||
that stops selection shifting rows, .is-cursor's 1px dotted outline, menu
|
||||
bevels, the frame's inset glass ring) — those stay crisp at any scale. */
|
||||
|
||||
/* 7.css's own size vars — defined on .win7 in px; rem lands on the same
|
||||
pixels at scale 1. Covers checkbox/radio/slider/tooltip/treeview/menu
|
||||
gutters and both window metrics in one shot. */
|
||||
.win7 {
|
||||
--w7-cb-size: 0.875rem; /* 14px */
|
||||
--w7-rd-size: 0.875rem;
|
||||
--w7-rdd-size: 0.5rem; /* 8px */
|
||||
--w7-rdl-space: 0.375rem; /* 6px */
|
||||
--w7-sdt-w: 0.625rem; /* 10px */
|
||||
--w7-sdt-h: 1.125rem; /* 18px */
|
||||
--w7-blt-size: 1.125rem;
|
||||
--w7-tvb-size: 0.5rem;
|
||||
--w7-tv-left: 1.25rem; /* 20px */
|
||||
--w7-clc-size: 0.3125rem; /* 5px */
|
||||
--w7-lvc-size: 0.3125rem;
|
||||
--w7-mn-left: 1.75rem; /* 28px */
|
||||
--w7-el-bdr: 0.1875rem; /* 3px */
|
||||
--w7-w-space: 0.375rem; /* 6px */
|
||||
--w7-w-bdr: 0.375rem;
|
||||
}
|
||||
|
||||
/* The hardcoded box metrics the vars do not reach, restated in rem; skinfix
|
||||
outranks skin by layer, so these win over 7.css.
|
||||
NO blanket `.win7 button` rule here, deliberately: the `skin`-layer
|
||||
neutralizer strips 7.css's 75x23 metrics off every button (most are icon
|
||||
squares sized by utilities), and a skinfix restatement would outrank that
|
||||
neutralizer by LAYER and re-inflate all of them. The only buttons that
|
||||
legitimately wear Win7 push-button metrics are .w7-btn (opted in below)
|
||||
and the title-bar controls (which beat the neutralizer on specificity
|
||||
inside `skin` and are restated here). */
|
||||
.win7 input[type='email'],
|
||||
.win7 input[type='number'],
|
||||
.win7 input[type='password'],
|
||||
.win7 input[type='search'],
|
||||
.win7 input[type='text'],
|
||||
.win7 input[type='url'] {
|
||||
height: 1.4375rem;
|
||||
}
|
||||
.win7 input[type='email'],
|
||||
.win7 input[type='number'],
|
||||
.win7 input[type='password'],
|
||||
.win7 input[type='search'],
|
||||
.win7 input[type='text'],
|
||||
.win7 input[type='url'],
|
||||
.win7 textarea {
|
||||
padding: 0.1875rem 0.25rem 0.3125rem; /* 3px 4px 5px */
|
||||
}
|
||||
.win7 table > thead > tr > * {
|
||||
height: 1.375rem; /* 22px */
|
||||
padding: 0 0.5rem; /* 0 8px */
|
||||
}
|
||||
.win7 table > tbody > tr > * {
|
||||
height: 0.875rem; /* 14px */
|
||||
padding: 0.125rem 0.5rem; /* 2px 8px */
|
||||
}
|
||||
.win7 .title-bar-text {
|
||||
line-height: 0.9375rem; /* 15px */
|
||||
}
|
||||
.win7 .title-bar-controls button {
|
||||
min-height: 1.1875rem; /* 19px */
|
||||
min-width: 1.8125rem; /* 29px */
|
||||
padding: 0;
|
||||
}
|
||||
/* BOTH of 7.css's wide-close selectors, not just .is-close: the app's title
|
||||
bars key the buttons on aria-label, and the base rule above outranks the
|
||||
`skin`-layer variant BY LAYER — restating only one arm shrank the close
|
||||
button from 48px to 29px (caught by the harness pixel diff). */
|
||||
.win7 .title-bar-controls button.is-close,
|
||||
.win7 .title-bar-controls button[aria-label='Close'] {
|
||||
min-width: 3rem; /* 48px */
|
||||
}
|
||||
.win7 ul[role='menu'] {
|
||||
min-width: 9.375rem; /* 150px */
|
||||
padding: 0.125rem; /* 2px */
|
||||
}
|
||||
.win7 ul[role='menu'] > [role='menuitem'] > a,
|
||||
.win7 ul[role='menu'] > [role='menuitem'] > button,
|
||||
.win7 ul[role='menu'] > [role='menuitem'] > label {
|
||||
padding: 0.25rem 0.625rem 0.25rem 2rem; /* 4px 10px 4px 32px */
|
||||
}
|
||||
.win7 menu[role='tablist'] button {
|
||||
padding: 0.125rem 0.375rem; /* 2px 6px */
|
||||
}
|
||||
/* The raised active tab: 7.css gives it padding-bottom 4px at (0,2,2) inside
|
||||
`skin`, but the shorthand above outranks that BY LAYER and would flatten
|
||||
the raise — so the variant is restated here too. */
|
||||
.win7 menu[role='tablist'] button[aria-selected='true'] {
|
||||
padding-bottom: 0.25rem; /* 4px */
|
||||
}
|
||||
/* The 10pt checkmark glyph in menu checkables — one of five pt sizes in
|
||||
7.css that bypass --w7-font entirely. 10pt = 13.333px ≈ 0.8333rem. */
|
||||
.win7 ul [role='menuitem'] > input[type='checkbox']:checked + label:after {
|
||||
font-size: 0.8333rem;
|
||||
}
|
||||
|
||||
/* Opt back IN to real Win7 push-button metrics, for the dialog and options
|
||||
buttons where they are right. Same rem values as the button rule above —
|
||||
this class exists to undo the `skin`-layer neutralizer, not to differ. */
|
||||
.win7 .w7-btn {
|
||||
min-width: 4.6875rem;
|
||||
min-height: 1.4375rem;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
/* ---- ListView (file panels) ----
|
||||
7.css's listview.css hardcodes light literals (#fff, #c0c1cd, #eee and the
|
||||
white header gradient) instead of --w7-* vars, so unlike the rest of the
|
||||
skin it does NOT follow the hand-authored dark palette — the header labels
|
||||
came out invisible, dark on dark. These restate the same rules in dark
|
||||
terms; light keeps 7.css's own values untouched.
|
||||
|
||||
CRITICAL: the base surface goes on `table`, NEVER on `tbody > tr > *`.
|
||||
7.css paints the selection band on the ROW, and a cell background paints
|
||||
over a row background — setting one erased the selection highlight
|
||||
entirely in dark mode. */
|
||||
.win7 table {
|
||||
background-color: var(--w7-lv-bg);
|
||||
border-color: var(--w7-lv-bd);
|
||||
color: var(--w7-lv-c);
|
||||
}
|
||||
.win7 table > thead > tr > * {
|
||||
background: var(--w7-lv-head-grad);
|
||||
border-color: var(--w7-lv-head-bd);
|
||||
color: var(--w7-lv-c);
|
||||
}
|
||||
.win7 table > tbody > tr > :not(:last-child) {
|
||||
border-right-color: var(--w7-lv-cell-bd);
|
||||
}
|
||||
/* 7.css drops the cell separators inside a selected row; the rule above is
|
||||
more specific than its, so restore that behaviour explicitly. */
|
||||
.win7 table > tbody > tr.highlighted > :not(:last-child) {
|
||||
border-right: none;
|
||||
}
|
||||
.win7 table > tbody > tr.highlighted {
|
||||
background: var(--w7-sel-bg-idle);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* ---- Window frame ----
|
||||
The native window is transparent (chromeOptions in src/bun/windows.ts), and
|
||||
nothing but .window may paint an opaque surface — that is what lets the 6px
|
||||
corner radius actually be transparent at the corners. That invariant is NOT
|
||||
free: html/body paint an opaque background unlayered, and had to be reset
|
||||
to transparent for .win7 (see the rule above @layer skin). Anything else
|
||||
that paints full-bleed will fill the corners the same way.
|
||||
NO drop shadow, by request: 7.css's `2px 2px 10px 1px #000000b3` needed a
|
||||
margin gutter to fall into and still read heavy against a real desktop, so
|
||||
the frame keeps only the inset 1px glass highlight from that shorthand
|
||||
(dropping the whole box-shadow would lose the highlight too — it lives in
|
||||
the same declaration). No shadow -> no gutter -> full-bleed 100vh.
|
||||
Keyed on `w7-frame` — the app hook the three window ROOTS carry — never on
|
||||
bare .window: on any other .window (dialogs, demos) this sizing is
|
||||
nonsense and 7.css's own shadow is fine. */
|
||||
.win7 .window.w7-frame {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
box-shadow: inset 0 0 0 1px #fffa;
|
||||
}
|
||||
|
||||
/* A maximized Win7 window has SQUARE corners — the radius only exists to let
|
||||
the desktop show past a floating frame, and a rounded maximized window
|
||||
leaves four notches of desktop in the screen corners.
|
||||
Read off the title bar's own `is-maximized` (title-bar.ts publishes it on
|
||||
its host) with :has(), so the frame reacts without a second windowState
|
||||
subscription. .title-bar is a DIRECT child of .window — app-title-bar
|
||||
carries the class on its host element, which is why `>` holds here.
|
||||
The radius is declared three times in 7.css — on .window and on both the
|
||||
::before tint and the ::after glass layer — so all three must be zeroed or
|
||||
a rounded pseudo-element still cuts the corner off the square frame. */
|
||||
.win7 .window.w7-frame:has(> .title-bar.is-maximized),
|
||||
.win7 .window.w7-frame:has(> .title-bar.is-maximized)::before,
|
||||
.win7 .window.w7-frame:has(> .title-bar.is-maximized)::after {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* STOPGAP — the synthesized resize handles (window-resize/window-resize.ts,
|
||||
which carries the removal checklist). A maximized window has no edges to
|
||||
grab, so the overlay goes away entirely while it is maximized, and comes
|
||||
back on restore.
|
||||
Read off the title bar's own `is-maximized` with :has(), exactly like the
|
||||
corner-squaring rule above — that is what keeps this component free of a
|
||||
second `windowState` subscription (RpcService's push registries have no
|
||||
off(), so every subscription is permanent). */
|
||||
.win7 .window.w7-frame:has(> .title-bar.is-maximized) .w7-resize {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hovering the close button paints 7.css's red glow ::before
|
||||
(box-shadow: 0 0 7px 3px #e68e75), and box-shadows of CHILDREN are not
|
||||
clipped by the parent's border or radius — the caption buttons sit flush
|
||||
against the window's top edge (border-top: 0), so the glow spilled ABOVE
|
||||
the frame: a red tint across the top border and a red halo floating on the
|
||||
desktop past it (scripts/hover-probe.ts renders the forced-hover state and
|
||||
pixel-scans for exactly this; it caught 100 red pixels above the edge).
|
||||
Clip the title bar's children to its own box on top/left/right — the glow
|
||||
now dies at the frame border, which is where the real Win7 DWM stopped it
|
||||
too — while the bottom stays open (-1rem) so the glow's bleed toward the
|
||||
caption below survives; that part is authentic.
|
||||
clip-path also clips HIT-TESTING, but only for content outside the region:
|
||||
the title-bar's own box (the drag region) is entirely inside it.
|
||||
Scoped to w7-frame: demo/harness windows keep stock 7.css behaviour. */
|
||||
.win7 .window.w7-frame > .title-bar {
|
||||
clip-path: inset(0 0 -1rem 0);
|
||||
}
|
||||
|
||||
/* 7.css's glass layer is `backdrop-filter: blur(4px)` on .window::after —
|
||||
but CSS cannot sample the DESKTOP behind a transparent native window
|
||||
(there is no backdrop to read), and WebKitGTK paints the unsampleable
|
||||
layer BLACK: every see-through part of the glass — the frame ring, the
|
||||
area around the title-bar buttons — composited over black instead of over
|
||||
the desktop. With it off, the real desktop shows through the glass tint,
|
||||
which is closer to Aero than a blur we cannot have. (True blur-behind
|
||||
needs compositor support; GNOME offers none to X11/XWayland clients.) */
|
||||
.win7 .window.glass::after {
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
/* Constant row geometry. 7.css gives ONLY tr.highlighted a 1px border, and
|
||||
the table is border-collapse: collapse — so a selected row physically grew
|
||||
1px at top and bottom and shoved every row below it down by 2px as the
|
||||
cursor moved. Every row reserves that border instead, transparent when not
|
||||
selected, so selecting changes colour and nothing else.
|
||||
The border stays TRANSPARENT even when selected: it exists purely to
|
||||
reserve space. 7.css outlines the selected row (#aaddfa) on top of its
|
||||
fill, which stacked a second edge inside the band and, with the cursor
|
||||
row's dotted rect, drew three concentric outlines around one row. The fill
|
||||
paints under the transparent border (background-clip defaults to
|
||||
border-box), so the band still covers the full row. */
|
||||
.win7 table > tbody > tr {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
/* Pane-focus model (commander convention, matching Win7 Explorer):
|
||||
the CURSOR ROW always carries the selection band in BOTH panels —
|
||||
tr.highlighted covers "selected via Space" AND "cursor row" (the template
|
||||
ORs them). What changes with pane focus is the band's strength, never
|
||||
whether it exists:
|
||||
- focused pane -> the flat blue selection (below)
|
||||
- unfocused pane -> the grey idle band, which is what Explorer drew
|
||||
when the list lost focus — faint by design, not a bug.
|
||||
The dotted focus rectangle is NOT the focus signal; it is layered on top
|
||||
of the band in the focused pane only, the way Win7 drew the keyboard
|
||||
focus rect over the selected item.
|
||||
FLAT, and only a fill: 7.css bands the row with --w7-el-grad-h, its Aero
|
||||
element-hover ramp, which reads as a glossy button rather than a file
|
||||
selection, and outlines it as well. One solid colour, no edge — the
|
||||
dotted cursor rect is then the only line on the row, which is what makes
|
||||
it legible as the cursor. */
|
||||
.win7 table.is-active > tbody > tr.highlighted {
|
||||
background: var(--w7-sel-bg);
|
||||
}
|
||||
|
||||
/* One dotted rectangle around the ROW; per cell (tr > *) draws a dotted box
|
||||
around every cell instead — visibly wrong. */
|
||||
.win7 table > tbody > tr.is-cursor {
|
||||
outline: 1px dotted var(--w7-el-c);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
/* ---- Menu (context menu) ----
|
||||
The menu itself is pure 7.css — ul[role=menu] > li[role=menuitem], see
|
||||
ui/src/app/context-menu.ts for the exact contract. (A previous iteration
|
||||
claimed "7.css has no menu component" and hand-built a lookalike here;
|
||||
false — the markup was one level off and its selectors never matched.)
|
||||
Only menu.css's hardcoded LIGHT literals need restating for the dark
|
||||
palette, same as the ListView above: the black `color:initial`, the
|
||||
white bevel halves of the gutter stripe and dividers, and the checked-box
|
||||
ring (#b3d3f9). */
|
||||
.win7 ul[role='menu'] {
|
||||
color: var(--w7-mn-c);
|
||||
border-color: var(--w7-mn-bd);
|
||||
}
|
||||
|
||||
/* THE POSITIONING ONE — this is why right-click looked dead under the skin.
|
||||
menu.css declares `position: relative` on ul[role=menu] (it needs it for
|
||||
the gutter stripe's ::before and for submenu anchoring), and `skin`
|
||||
outranks `utilities`, so Tailwind's `.fixed` on the app's menu lost. Its
|
||||
[style.left/top.px] then became FLOW offsets from the menu's static
|
||||
position — the last child of an h-screen flex column — and every menu
|
||||
rendered below the viewport, unreachable behind `overflow: hidden`. The
|
||||
backdrop is a plain <div>, which 7.css does not match, so it stayed truly
|
||||
fixed and swallowed the next click: "right click does nothing, then the
|
||||
app ignores me once".
|
||||
Scoped to the app menu's own marker class, never blanket: 7.css's own
|
||||
relative positioning must survive for any other ul[role=menu].
|
||||
Regression test: scripts/menu-probe.ts (a resting pixel diff cannot see a
|
||||
positioning bug — nothing about the idle window changes). */
|
||||
.win7 ul[role='menu'].w7-ctx {
|
||||
position: fixed;
|
||||
}
|
||||
.win7 ul[role='menu']:before {
|
||||
box-shadow: var(--w7-mn-stripe);
|
||||
}
|
||||
.win7 ul [role='menuitem'].has-divider:after {
|
||||
box-shadow: var(--w7-mn-divider);
|
||||
}
|
||||
.win7 ul [role='menuitem'] > input[type] + label:before {
|
||||
box-shadow: 0 0 0 1px var(--w7-mn-check-ring);
|
||||
}
|
||||
.win7 ul [role='menuitem'] > input[type='checkbox']:checked + label:after {
|
||||
color: var(--w7-mn-check-c);
|
||||
}
|
||||
|
||||
/* Menu items: flat highlight, and no Aero afterglow trailing the pointer.
|
||||
Menu items ARE <button>s, so button.css reaches them with three effects
|
||||
that do not belong on a menu:
|
||||
(1) the highlight is 7.css's translucent wash
|
||||
`linear-gradient(#fff9, #e6ecf5cc 90%, #fffc)` + an #aaddfa edge — the
|
||||
same gradient the ListView selection above just dropped;
|
||||
(2) `button::before` is a blue glow overlay whose opacity fades out over
|
||||
a FULL SECOND (`button:not(:hover):before { transition: opacity 1s
|
||||
linear }`). menu.css sets `content: none` on the :HOVER arm only, so
|
||||
the pseudo reappears the instant you leave an item and then fades 1→0
|
||||
— a ghost dragging behind the pointer. Killing `content` in EVERY
|
||||
state is what removes it; suppressing the transition alone would not,
|
||||
because the glow itself is legitimate on real push buttons;
|
||||
(3) `button:focus` runs `pulse-anim` infinitely, and this menu moves REAL
|
||||
focus between items for keyboard nav — so a focused item would breathe.
|
||||
Scoped to the app's own menu (w7-ctx): 7.css's demo menus keep stock Aero. */
|
||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button,
|
||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > label {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button::before,
|
||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button::after {
|
||||
content: none;
|
||||
}
|
||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button:hover,
|
||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button:focus,
|
||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button:focus-visible,
|
||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > label:hover,
|
||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > label:focus-visible {
|
||||
background: var(--w7-sel-bg);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* ---- Flat buttons (toolbars + the w7-flat escape hatch) ----
|
||||
The paint-suppression half of the rule in `skin` (see there for the split).
|
||||
It lives in `skinfix` because 7.css restates border-color at (0,3,1) via
|
||||
`.win7 button:not(:disabled):not(:hover)` and again on :hover, which no
|
||||
reasonable `.w7-flat` selector outranks — layer order settles it instead
|
||||
of a specificity arms race. */
|
||||
.win7 .w7-toolbar button:not([role='tab']),
|
||||
.win7 .w7-flat {
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ---- Tabs (workspace strip) ----
|
||||
7.css owns the tab CHROME; these restore the tab CONTENT layout it breaks.
|
||||
`menu[role=tablist] button` sets `display: block` at (0,2,1), which
|
||||
outranks Tailwind's `.flex` in `utilities` — so the icon and the label
|
||||
rendered jammed together with no gap and no truncation. Restate the flex
|
||||
box here (skinfix outranks skin) and leave every painted property alone.
|
||||
The gap matches the gap-1.5 the template asks for. */
|
||||
.win7 menu[role='tablist'] button[role='tab'] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
/* tabs.css hardcodes `color: #222` on every tablist button — a light-mode
|
||||
literal outside 7.css's var system, which left the labels near-invisible
|
||||
on the dark surface. Same failure and same remedy as the ListView and menu
|
||||
literals above: route it through a token that both dark blocks override. */
|
||||
.win7 menu[role='tablist'] button {
|
||||
color: var(--w7-tab-c);
|
||||
}
|
||||
|
||||
/* The workspace strip scrolls horizontally when tabs overflow. Setting
|
||||
`overflow-x: auto` forces the computed `overflow-y` from visible to auto
|
||||
as well (CSS will not pair visible with a non-visible axis), so the strip
|
||||
clips vertically — and 7.css raises the ACTIVE tab with `margin-top: -2px`,
|
||||
which put its top border outside the scroll box and silently deleted it.
|
||||
Reserve those 2px as padding so the pulled-up tab lands inside the box.
|
||||
7.css already reserves the matching 3px on the left (padding-left: 3px)
|
||||
for the same tab's -3px left margin; it just never anticipated a scroller.
|
||||
Verified by pixel scan: the active tab's top edge must be #8e8f8f, not a
|
||||
straight jump from the toolbar grey to the tab fill. */
|
||||
.win7 .w7-toolbar menu[role='tablist'] {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.win7 .window.glass > .title-bar {
|
||||
background: var(--w7-glass-title);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* Caption text: 7.css hardcodes black + a white halo; in dark that becomes
|
||||
the Ares treatment (white caption, dark halo) through the same vars. */
|
||||
.win7 .title-bar-text {
|
||||
color: var(--w7-cap-c);
|
||||
text-shadow: var(--w7-cap-glow);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<!-- .window.glass is 7.css's Aero frame (radius, 1px border, inset highlight,
|
||||
drop shadow, backdrop blur); .active is what un-greys the title bar and its
|
||||
control glyphs — every .title-bar-controls rule is keyed on it. -->
|
||||
<div class="window glass w7-frame flex h-screen flex-col" [class.active]="windowFocused()">
|
||||
@if (!rpc.available) {
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm">
|
||||
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
|
||||
<p class="mt-2 text-sm font-medium">{{ i18n.t('app.notConnectedTitle') }}</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ i18n.t('app.notConnectedBody') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- the window is frameless, so this IS the chrome -->
|
||||
<app-title-bar [title]="appName" (closeRequested)="closeWindow()" />
|
||||
|
||||
<!-- 7.css's .window-body: the recessed inner panel, with the 6px inset and
|
||||
its own border that give a Win7 window its depth. -->
|
||||
<div class="window-body flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<app-tab-bar />
|
||||
|
||||
<main class="flex min-h-0 flex-1 divide-x divide-border">
|
||||
<app-file-panel side="left" />
|
||||
<app-file-panel side="right" />
|
||||
</main>
|
||||
|
||||
<app-transfer-bar />
|
||||
<app-status-bar />
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- modal block: the connection-manager window owns the app until closed -->
|
||||
@if (managerOpen()) {
|
||||
<div
|
||||
class="fixed inset-0 z-60 flex items-center justify-center bg-black/40"
|
||||
(mousedown)="focusManager()"
|
||||
>
|
||||
<div class="rounded-lg border border-border bg-card px-5 py-4 text-center shadow-xl">
|
||||
<ng-icon name="mynaServers" size="1.25rem" class="text-muted-foreground" />
|
||||
<p class="mt-1.5 text-xs text-muted-foreground">{{ i18n.t('app.managerOpen') }}</p>
|
||||
<button
|
||||
class="mt-3 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{{ i18n.t('app.showConnections') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- dialogs -->
|
||||
@if (dialogs.prompt(); as req) {
|
||||
<app-prompt-dialog [request]="req" />
|
||||
}
|
||||
@if (dialogs.confirm(); as req) {
|
||||
<app-confirm-dialog [request]="req" />
|
||||
}
|
||||
@if (dialogs.chmod(); as req) {
|
||||
<app-chmod-dialog [request]="req" />
|
||||
}
|
||||
@if (dialogs.overwrite(); as req) {
|
||||
<app-overwrite-dialog [request]="req" />
|
||||
}
|
||||
|
||||
<!-- STOPGAP: the frameless window has no WM resize borders, so we draw our
|
||||
own handles. Delete with the rest of the hack — checklist in
|
||||
window-resize/window-resize.ts. -->
|
||||
<app-window-resize />
|
||||
|
||||
<app-context-menu />
|
||||
<app-toasts />
|
||||
</div>
|
||||
@@ -0,0 +1,307 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { BRANDING } from '@shared/branding';
|
||||
import type { ColumnKey } from '@shared/types';
|
||||
import { ConnectionsService } from '@core/connections.service';
|
||||
import { ContextMenu } from './context-menu';
|
||||
import { ChmodDialog } from './dialogs/chmod-dialog';
|
||||
import { ConfirmDialog } from './dialogs/confirm-dialog';
|
||||
import { OverwriteDialog } from './dialogs/overwrite-dialog';
|
||||
import { PromptDialog } from './dialogs/prompt-dialog';
|
||||
import { DialogsService } from '@core/dialogs.service';
|
||||
import { FileActionsService } from '@core/file-actions.service';
|
||||
import { FilePanel } from './file-panel/file-panel';
|
||||
import { PanelsService } from '@core/panels.service';
|
||||
import { blockNativeContextMenu } from '@core/platform';
|
||||
import { RpcService } from '@core/rpc.service';
|
||||
import { SettingsService } from '@core/settings.service';
|
||||
import { ShortcutsService } from '@core/shortcuts.service';
|
||||
import { StatusBar } from './status-bar/status-bar';
|
||||
import { TabBar } from './tab-bar/tab-bar';
|
||||
import { TitleBar } from './title-bar/title-bar';
|
||||
import { ToastService } from '@core/toast.service';
|
||||
import { Toasts } from './toasts/toasts';
|
||||
import { TransferBar } from './transfer-bar/transfer-bar';
|
||||
import { WindowResize } from './window-resize/window-resize';
|
||||
import { TransfersService } from '@core/transfers.service';
|
||||
import { UpdaterService } from '@core/updater.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [
|
||||
NgIcon,
|
||||
FilePanel,
|
||||
TabBar,
|
||||
StatusBar,
|
||||
PromptDialog,
|
||||
ConfirmDialog,
|
||||
ChmodDialog,
|
||||
OverwriteDialog,
|
||||
Toasts,
|
||||
TransferBar,
|
||||
TitleBar,
|
||||
ContextMenu,
|
||||
WindowResize,
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
host: {
|
||||
'(window:keydown)': 'onWindowKeydown($event)',
|
||||
'(window:contextmenu)': 'blockContextMenu($event)',
|
||||
// Aero greys an inactive window's title bar and controls. The webview gets
|
||||
// DOM focus/blur when the native window's focus changes, so this needs no
|
||||
// RPC — see the `active` binding on .window in app.html.
|
||||
'(window:focus)': 'windowFocused.set(true)',
|
||||
'(window:blur)': 'windowFocused.set(false)',
|
||||
},
|
||||
})
|
||||
export class App {
|
||||
readonly i18n = inject(I18nService);
|
||||
readonly rpc = inject(RpcService);
|
||||
readonly toast = inject(ToastService);
|
||||
readonly dialogs = inject(DialogsService);
|
||||
readonly panels = inject(PanelsService);
|
||||
readonly actions = inject(FileActionsService);
|
||||
readonly connections = inject(ConnectionsService);
|
||||
private readonly transfers = inject(TransfersService);
|
||||
private readonly settings = inject(SettingsService);
|
||||
private readonly shortcuts = inject(ShortcutsService);
|
||||
private readonly updater = inject(UpdaterService);
|
||||
|
||||
/** The connection-manager window is open — this window is modal-blocked. */
|
||||
readonly managerOpen = signal(false);
|
||||
|
||||
/** Drives `.window.active`: Aero desaturates an unfocused window's chrome. */
|
||||
readonly windowFocused = signal(document.hasFocus());
|
||||
|
||||
/** Shown in the win7 title bar (no native titlebar to carry it). */
|
||||
readonly appName = BRANDING.appName;
|
||||
|
||||
/**
|
||||
* win7 title bar's X. Closing the main window quits, and the app-level
|
||||
* before-quit guard (dirty editors + running transfers) still runs — it is
|
||||
* the one close path electrobun lets us cancel.
|
||||
*/
|
||||
closeWindow(): void {
|
||||
void this.rpc.request.windowClose({});
|
||||
}
|
||||
|
||||
constructor() {
|
||||
document.title = BRANDING.appName;
|
||||
if (this.rpc.available) {
|
||||
this.rpc.onMenuAction((action) => void this.handleMenuAction(action));
|
||||
this.rpc.onManagerState((open) => this.managerOpen.set(open));
|
||||
void this.promptForEditLeftovers();
|
||||
}
|
||||
}
|
||||
|
||||
focusManager(): void {
|
||||
void this.rpc.request.openConnectionManager({});
|
||||
}
|
||||
|
||||
blockContextMenu(event: Event): void {
|
||||
blockNativeContextMenu(event);
|
||||
}
|
||||
|
||||
/** Temp files from external edits in a previous run (crash / quit mid-edit). */
|
||||
private async promptForEditLeftovers(): Promise<void> {
|
||||
const res = await this.rpc.request.getEditLeftovers({});
|
||||
if (!res.ok || !res.names.length) return;
|
||||
const shown = res.names.slice(0, 5).join(', ');
|
||||
const more =
|
||||
res.names.length > 5 ? this.i18n.t('toasts.leftoverMore', { count: res.names.length - 5 }) : '';
|
||||
this.dialogs.confirm.set({
|
||||
title: this.i18n.t('toasts.leftoverTitle'),
|
||||
body: this.i18n.t('toasts.leftoverBody', { count: res.names.length, names: shown + more }),
|
||||
confirmLabel: this.i18n.t('toasts.leftoverClear'),
|
||||
destructive: true,
|
||||
onConfirm: () => void this.rpc.request.clearEditLeftovers({}),
|
||||
});
|
||||
}
|
||||
|
||||
/** A connection by id; reloads once if unseen (a manager-window save can
|
||||
* race the connectionsChanged push). */
|
||||
private async findConnection(id: string) {
|
||||
const lookup = () => this.connections.connections().find((c) => c.id === id);
|
||||
if (!lookup()) await this.connections.reload();
|
||||
return lookup();
|
||||
}
|
||||
|
||||
/** Native application-menu / manager-window actions forwarded from bun. */
|
||||
private async handleMenuAction(action: string): Promise<void> {
|
||||
if (action.startsWith('ctx-')) {
|
||||
this.handleContextAction(action);
|
||||
return;
|
||||
}
|
||||
if (action.startsWith('connect:')) {
|
||||
const connection = await this.findConnection(action.slice('connect:'.length));
|
||||
if (connection) {
|
||||
// remote goes right, local stays left — the commander convention
|
||||
void this.panels.connectPanel('right', connection, connection.name, connection.localDir);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (action.startsWith('col:')) {
|
||||
this.settings.toggleColumn(action.slice('col:'.length) as ColumnKey);
|
||||
return;
|
||||
}
|
||||
if (action.startsWith('tab:')) {
|
||||
this.handleTabAction(action.slice('tab:'.length));
|
||||
return;
|
||||
}
|
||||
if (action.startsWith('open-stack:')) {
|
||||
const id = action.slice('open-stack:'.length);
|
||||
let stackConnections = this.connections.connections().filter((c) => c.stackId === id);
|
||||
if (!stackConnections.length) {
|
||||
await this.connections.reload();
|
||||
stackConnections = this.connections.connections().filter((c) => c.stackId === id);
|
||||
}
|
||||
if (stackConnections.length) this.panels.openStack(stackConnections);
|
||||
return;
|
||||
}
|
||||
switch (action) {
|
||||
case 'new-tab':
|
||||
this.panels.newWorkspace();
|
||||
break;
|
||||
case 'close-tab':
|
||||
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
|
||||
break;
|
||||
case 'new-folder':
|
||||
this.actions.mkdir(this.panels.active());
|
||||
break;
|
||||
case 'new-file':
|
||||
this.actions.createFile(this.panels.active());
|
||||
break;
|
||||
case 'edit-file':
|
||||
void this.actions.edit(this.panels.active());
|
||||
break;
|
||||
case 'refresh-active':
|
||||
void this.panels.active().refresh();
|
||||
break;
|
||||
case 'check-for-updates':
|
||||
void this.updater.check();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Window shortcuts — matching lives in ShortcutsService (one keymap for the
|
||||
* app); this switch only maps ids onto actions. Panel-level keys stay on
|
||||
* the panels themselves. */
|
||||
onWindowKeydown(event: KeyboardEvent): void {
|
||||
if (this.managerOpen()) return; // modal-blocked behind the manager
|
||||
const match = this.shortcuts.resolve(event, 'main');
|
||||
if (!match) return;
|
||||
// scale keys are hostile mid-typing ('-' in a rename prompt must type '-');
|
||||
// the tab shortcuts keep firing there, as they always did
|
||||
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
|
||||
event.preventDefault();
|
||||
switch (match.id) {
|
||||
case 'new-tab':
|
||||
this.panels.newWorkspace();
|
||||
break;
|
||||
case 'close-tab':
|
||||
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
|
||||
break;
|
||||
case 'cycle-tab':
|
||||
this.panels.cycleWorkspace(1);
|
||||
break;
|
||||
case 'cycle-tab-back':
|
||||
this.panels.cycleWorkspace(-1);
|
||||
break;
|
||||
case 'go-to-tab':
|
||||
// ⌘1–⌘8 jump to that tab, ⌘9 to the last, like Firefox
|
||||
this.panels.goToWorkspace(match.digit === 9 ? -1 : match.digit! - 1);
|
||||
break;
|
||||
case 'scale-up':
|
||||
this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
||||
break;
|
||||
case 'scale-down':
|
||||
this.settings.setUiScale(this.settings.uiScale() - 0.1);
|
||||
break;
|
||||
case 'scale-reset':
|
||||
this.settings.setUiScale(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Context-menu actions apply to the row/panel remembered at right-click time. */
|
||||
/**
|
||||
* Workspace-tab menu actions. The target id travels INSIDE the action string
|
||||
* because the right-clicked tab is not necessarily the active one — nothing
|
||||
* here may fall back to activeWorkspaceId.
|
||||
*/
|
||||
private handleTabAction(action: string): void {
|
||||
if (action === 'new') {
|
||||
this.panels.newWorkspace();
|
||||
return;
|
||||
}
|
||||
const parsed = /^([a-z-]+):(\d+)$/.exec(action);
|
||||
if (!parsed) return;
|
||||
const id = Number(parsed[2]);
|
||||
switch (parsed[1]) {
|
||||
case 'close':
|
||||
this.panels.closeWorkspace(id);
|
||||
break;
|
||||
case 'close-others':
|
||||
this.panels.closeOtherWorkspaces(id);
|
||||
break;
|
||||
case 'close-right':
|
||||
this.panels.closeWorkspacesToRight(id);
|
||||
break;
|
||||
case 'disconnect':
|
||||
void this.panels.disconnectWorkspace(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleContextAction(action: string): void {
|
||||
const target = this.actions.menuTarget;
|
||||
if (!target) return;
|
||||
const { panel, side, entry } = target;
|
||||
// the empty-space menu acts on the directory itself
|
||||
const path = entry?.path ?? panel.cwd();
|
||||
switch (action) {
|
||||
case 'ctx-open':
|
||||
if (entry) panel.open(entry);
|
||||
break;
|
||||
// local shell integration — only bun can reach the OS shell/clipboard
|
||||
case 'ctx-reveal':
|
||||
void this.rpc.request.shellItemAction({ action: 'reveal', path });
|
||||
break;
|
||||
case 'ctx-open-default':
|
||||
void this.rpc.request.shellItemAction({ action: 'open', path });
|
||||
break;
|
||||
case 'ctx-copy-path':
|
||||
void this.rpc.request.shellItemAction({ action: 'copyPath', path });
|
||||
break;
|
||||
case 'ctx-edit':
|
||||
void this.actions.edit(panel);
|
||||
break;
|
||||
case 'ctx-copy':
|
||||
void this.transfers.copyToOtherPanel(side);
|
||||
break;
|
||||
case 'ctx-rename':
|
||||
this.actions.rename(panel);
|
||||
break;
|
||||
case 'ctx-chmod':
|
||||
this.actions.chmod(panel);
|
||||
break;
|
||||
case 'ctx-delete':
|
||||
this.actions.delete(panel);
|
||||
break;
|
||||
case 'ctx-trash':
|
||||
if (entry) void this.actions.trash(panel, entry);
|
||||
break;
|
||||
case 'ctx-new-folder':
|
||||
this.actions.mkdir(panel);
|
||||
break;
|
||||
case 'ctx-new-file':
|
||||
this.actions.createFile(panel);
|
||||
break;
|
||||
case 'ctx-refresh':
|
||||
void panel.refresh();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
<div class="window glass w7-frame flex h-screen flex-col" [class.active]="windowFocused()">
|
||||
<!-- Frameless window. Fixed-size modal, so Close only. -->
|
||||
<app-title-bar
|
||||
[title]="i18n.t('connMgr.title')"
|
||||
[resizable]="false"
|
||||
(closeRequested)="close()"
|
||||
/>
|
||||
<!-- Toolbar strip under the title bar. h-9/pl-3 are load-bearing: without a
|
||||
height this row collapses onto the icon and the screen reads as broken. -->
|
||||
<header
|
||||
class="w7-toolbar flex h-9 shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2 pl-3"
|
||||
>
|
||||
<ng-icon name="mynaServers" size="0.95rem" class="shrink-0 text-muted-foreground" />
|
||||
<h1 class="text-sm font-semibold">{{ i18n.t('connMgr.title') }}</h1>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
(click)="importWinScp()"
|
||||
[disabled]="importing()"
|
||||
[title]="i18n.t('connMgr.importWinscpHint')"
|
||||
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
@if (importing()) {
|
||||
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
|
||||
} @else {
|
||||
<!-- <ng-icon name="mynaArchive" size="0.85rem" /> -->
|
||||
}
|
||||
{{ i18n.t('connMgr.importWinscp') }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<!-- stacks + connections list -->
|
||||
<div class="flex w-72 shrink-0 flex-col border-r border-border bg-chrome">
|
||||
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
<button
|
||||
(click)="newConnection()"
|
||||
[title]="i18n.t('connMgr.newConnectionHint')"
|
||||
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaPlus" size="0.85rem" /> {{ i18n.t('connMgr.newConnection') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="newStack()"
|
||||
[title]="i18n.t('connMgr.newStackHint')"
|
||||
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaFolderPlus" size="0.85rem" /> {{ i18n.t('connMgr.newStack') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="has-scrollbar flex-1 overflow-y-auto py-1">
|
||||
<!-- stacks -->
|
||||
@for (group of store.grouped().stacks; track group.stack.id) {
|
||||
<div
|
||||
(dragover)="onDragOver($event, group.stack.id)"
|
||||
(dragleave)="dropTarget() === group.stack.id && dropTarget.set(null)"
|
||||
(drop)="onDrop($event, group.stack.id)"
|
||||
class="rounded-md"
|
||||
[class.bg-primary/10]="dropTarget() === group.stack.id"
|
||||
[class.ring-1]="dropTarget() === group.stack.id"
|
||||
[class.ring-inset]="dropTarget() === group.stack.id"
|
||||
[class.ring-primary]="dropTarget() === group.stack.id"
|
||||
>
|
||||
<div class="group flex items-center gap-1 px-2 pt-2 pb-0.5">
|
||||
<button
|
||||
(click)="toggleStack(group.stack)"
|
||||
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground hover:text-foreground"
|
||||
[title]="group.stack.collapsed ? i18n.t('connMgr.expand') : i18n.t('connMgr.collapse')"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="group.stack.collapsed ? 'mynaChevronRight' : 'mynaChevronDown'"
|
||||
size="0.8rem"
|
||||
/>
|
||||
</button>
|
||||
<span class="truncate text-[0.6875rem] font-medium text-muted-foreground uppercase">
|
||||
{{ group.stack.name }}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1"></div>
|
||||
<!-- count and actions swap on hover: they never share the space,
|
||||
so a long stack name can't collide with the buttons -->
|
||||
<span
|
||||
class="shrink-0 text-[0.625rem] text-muted-foreground/70 tabular-nums group-hover:hidden"
|
||||
>
|
||||
{{ group.connections.length }}
|
||||
</span>
|
||||
<div class="hidden shrink-0 items-center gap-0.5 group-hover:flex">
|
||||
<button
|
||||
(click)="renameStack(group.stack)"
|
||||
[title]="i18n.t('connMgr.renameStack')"
|
||||
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaEdit" size="0.75rem" />
|
||||
</button>
|
||||
<button
|
||||
(click)="deleteStack(group.stack)"
|
||||
[title]="i18n.t('connMgr.deleteStack')"
|
||||
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<ng-icon name="mynaTrash" size="0.75rem" />
|
||||
</button>
|
||||
@if (group.connections.length) {
|
||||
<button
|
||||
(click)="openStack(group.stack.id)"
|
||||
[title]="i18n.t('connMgr.openStack')"
|
||||
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<ng-icon name="mynaArrowRight" size="0.8rem" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (!group.stack.collapsed) {
|
||||
@for (connection of group.connections; track connection.id) {
|
||||
<button
|
||||
(click)="edit(connection)"
|
||||
draggable="true"
|
||||
(dragstart)="onDragStart($event, connection)"
|
||||
(dragend)="onDragEnd()"
|
||||
class="flex w-full items-center gap-1.5 py-1.5 pr-3 pl-6 text-left text-xs hover:bg-accent"
|
||||
[class.bg-accent]="draft()?.id === connection.id"
|
||||
[class.opacity-50]="draggedId() === connection.id"
|
||||
>
|
||||
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
||||
<span class="truncate">{{ connection.name }}</span>
|
||||
</button>
|
||||
} @empty {
|
||||
<div class="py-1 pl-6 text-[0.6875rem] text-muted-foreground italic">{{ i18n.t('connMgr.empty') }}</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- loose connections -->
|
||||
<div
|
||||
(dragover)="onDragOver($event, 'loose')"
|
||||
(dragleave)="dropTarget() === 'loose' && dropTarget.set(null)"
|
||||
(drop)="onDrop($event, null)"
|
||||
class="min-h-8 rounded-md pb-1"
|
||||
[class.bg-primary/10]="dropTarget() === 'loose'"
|
||||
[class.ring-1]="dropTarget() === 'loose'"
|
||||
[class.ring-inset]="dropTarget() === 'loose'"
|
||||
[class.ring-primary]="dropTarget() === 'loose'"
|
||||
>
|
||||
@if (store.grouped().stacks.length && store.grouped().loose.length) {
|
||||
<div class="px-2 pt-2 pb-0.5 text-[0.6875rem] font-medium text-muted-foreground uppercase">
|
||||
{{ i18n.t('connMgr.connectionsHeader') }}
|
||||
</div>
|
||||
}
|
||||
@for (connection of store.grouped().loose; track connection.id) {
|
||||
<button
|
||||
(click)="edit(connection)"
|
||||
draggable="true"
|
||||
(dragstart)="onDragStart($event, connection)"
|
||||
(dragend)="onDragEnd()"
|
||||
class="flex w-full items-center gap-1.5 px-3 py-1.5 text-left text-xs hover:bg-accent"
|
||||
[class.bg-accent]="draft()?.id === connection.id"
|
||||
[class.opacity-50]="draggedId() === connection.id"
|
||||
>
|
||||
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
||||
<span class="truncate">{{ connection.name }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!store.connections().length && !store.stacks().length) {
|
||||
<p class="px-3 py-2 text-[0.6875rem] text-muted-foreground">
|
||||
{{ i18n.t('connMgr.noneTitle') }}
|
||||
</p>
|
||||
<p class="px-3 py-2 text-[0.6875rem] text-muted-foreground">
|
||||
{{ i18n.t('connMgr.noneBody') }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- edit form -->
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
@if (draft(); as d) {
|
||||
<div class="has-scrollbar grid flex-1 auto-rows-min grid-cols-2 gap-x-4 gap-y-3 overflow-y-auto p-5">
|
||||
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.name') }}</span>
|
||||
<input
|
||||
[value]="d.name"
|
||||
(input)="patch('name', $any($event.target).value)"
|
||||
[placeholder]="i18n.t('connMgr.namePlaceholder')"
|
||||
spellcheck="false"
|
||||
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.host') }}</span>
|
||||
<input
|
||||
[value]="d.host"
|
||||
(input)="patch('host', $any($event.target).value)"
|
||||
placeholder="192.168.1.10"
|
||||
spellcheck="false"
|
||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.port') }}</span>
|
||||
<input
|
||||
type="number"
|
||||
[value]="d.port"
|
||||
(input)="patch('port', +$any($event.target).value)"
|
||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.stack') }}</span>
|
||||
<select
|
||||
(change)="patch('stackId', $any($event.target).value || null)"
|
||||
class="h-7 rounded-md border border-input bg-background px-1 outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<!-- [selected] per option: binding [value] on the <select> runs
|
||||
before the options render and silently shows "(none)" -->
|
||||
<option value="" [selected]="!d.stackId">{{ i18n.t('connMgr.stackNone') }}</option>
|
||||
@for (stack of store.stacks(); track stack.id) {
|
||||
<option [value]="stack.id" [selected]="d.stackId === stack.id">
|
||||
{{ stack.name }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.username') }}</span>
|
||||
<input
|
||||
[value]="d.username"
|
||||
(input)="patch('username', $any($event.target).value)"
|
||||
spellcheck="false"
|
||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.remotePath') }}</span>
|
||||
<input
|
||||
[value]="d.initialPath"
|
||||
(input)="patch('initialPath', $any($event.target).value)"
|
||||
[placeholder]="i18n.t('connMgr.remotePathPlaceholder')"
|
||||
spellcheck="false"
|
||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="col-span-2 flex items-center gap-4 text-xs">
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.auth') }}</span>
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input
|
||||
type="radio"
|
||||
name="authKind"
|
||||
[checked]="d.authKind === 'password'"
|
||||
(change)="patch('authKind', 'password')"
|
||||
class="accent-primary"
|
||||
/>
|
||||
{{ i18n.t('connMgr.authPassword') }}
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input
|
||||
type="radio"
|
||||
name="authKind"
|
||||
[checked]="d.authKind === 'key'"
|
||||
(change)="patch('authKind', 'key')"
|
||||
class="accent-primary"
|
||||
/>
|
||||
{{ i18n.t('connMgr.authKey') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (d.authKind === 'password') {
|
||||
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.password') }}</span>
|
||||
<input
|
||||
type="password"
|
||||
[value]="d.password"
|
||||
(input)="patch('password', $any($event.target).value)"
|
||||
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
} @else {
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.privateKeyFile') }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<input
|
||||
[value]="d.privateKeyPath"
|
||||
(input)="patch('privateKeyPath', $any($event.target).value)"
|
||||
placeholder="~/.ssh/id_ed25519"
|
||||
spellcheck="false"
|
||||
class="h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
(click)="pickKeyFile()"
|
||||
class="h-7 shrink-0 rounded-md border border-border px-2 text-[0.6875rem] hover:bg-accent"
|
||||
>
|
||||
{{ i18n.t('common.choose') }}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.passphrase') }}</span>
|
||||
<input
|
||||
type="password"
|
||||
[value]="d.passphrase"
|
||||
(input)="patch('passphrase', $any($event.target).value)"
|
||||
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
|
||||
@if (moreOpen()) {
|
||||
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.localDir') }}</span>
|
||||
<input
|
||||
[value]="d.localDir"
|
||||
(input)="patch('localDir', $any($event.target).value)"
|
||||
[placeholder]="i18n.t('connMgr.localDirPlaceholder')"
|
||||
spellcheck="false"
|
||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<span class="text-[0.625rem] text-muted-foreground/80">
|
||||
{{ i18n.t('connMgr.localDirHint') }}
|
||||
</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 border-t border-border bg-chrome px-4 py-2.5">
|
||||
<button
|
||||
(click)="deleteCurrent()"
|
||||
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-destructive"
|
||||
>
|
||||
<ng-icon name="mynaTrash" size="0.85rem" /> {{ i18n.t('common.delete') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="moreOpen.set(!moreOpen())"
|
||||
class="rounded-md border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{{ moreOpen() ? i18n.t('connMgr.less') : i18n.t('connMgr.more') }}
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
(click)="save()"
|
||||
[disabled]="!valid()"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
{{ i18n.t('common.save') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="saveAndConnect()"
|
||||
[disabled]="!valid() || busy()"
|
||||
class="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
@if (busy()) {
|
||||
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
|
||||
}
|
||||
{{ i18n.t('common.connect') }}
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="grid flex-1 place-items-center">
|
||||
<div class="text-center">
|
||||
<ng-icon name="mynaServers" size="1.75rem" class="text-muted-foreground" />
|
||||
<p class="mt-2 text-xs text-muted-foreground">
|
||||
{{ i18n.t('connMgr.selectPrompt') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- in-window dialogs (safe here: they depend only on FocusService) -->
|
||||
@if (dialogs.prompt(); as req) {
|
||||
<app-prompt-dialog [request]="req" />
|
||||
}
|
||||
@if (dialogs.confirm(); as req) {
|
||||
<app-confirm-dialog [request]="req" />
|
||||
}
|
||||
|
||||
<!-- STOPGAP: the frameless window has no WM resize borders, so we draw our
|
||||
own handles. Delete with the rest of the hack — checklist in
|
||||
window-resize/window-resize.ts. -->
|
||||
<app-window-resize />
|
||||
|
||||
<app-toasts />
|
||||
</div>
|
||||
@@ -0,0 +1,291 @@
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import type { SavedConnection, Stack } from '@shared/types';
|
||||
import { ConnectionsService } from '@core/connections.service';
|
||||
import { ConfirmDialog } from '../dialogs/confirm-dialog';
|
||||
import { PromptDialog } from '../dialogs/prompt-dialog';
|
||||
import { DialogsService } from '@core/dialogs.service';
|
||||
import { blockNativeContextMenu } from '@core/platform';
|
||||
import { RpcService } from '@core/rpc.service';
|
||||
import { SettingsService } from '@core/settings.service';
|
||||
import { ShortcutsService } from '@core/shortcuts.service';
|
||||
import { TitleBar } from '../title-bar/title-bar';
|
||||
import { Toasts } from '../toasts/toasts';
|
||||
import { WindowResize } from '../window-resize/window-resize';
|
||||
|
||||
interface Draft {
|
||||
id: string;
|
||||
name: string;
|
||||
stackId: string | null;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
authKind: 'password' | 'key';
|
||||
password: string;
|
||||
privateKeyPath: string;
|
||||
passphrase: string;
|
||||
initialPath: string;
|
||||
localDir: string;
|
||||
}
|
||||
|
||||
function toDraft(c: SavedConnection): Draft {
|
||||
return {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
stackId: c.stackId,
|
||||
host: c.host,
|
||||
port: c.port,
|
||||
username: c.username,
|
||||
// agent auth only exists on ~/.ssh/config hosts, which never reach this form
|
||||
authKind: c.auth.kind === 'password' ? 'password' : 'key',
|
||||
password: c.auth.kind === 'password' ? c.auth.password : '',
|
||||
privateKeyPath: c.auth.kind === 'key' ? c.auth.privateKeyPath : '',
|
||||
passphrase: c.auth.kind === 'key' ? (c.auth.passphrase ?? '') : '',
|
||||
initialPath: c.initialPath ?? '',
|
||||
localDir: c.localDir ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function fromDraft(d: Draft): SavedConnection {
|
||||
return {
|
||||
id: d.id,
|
||||
name: d.name.trim() || `${d.username}@${d.host}`,
|
||||
stackId: d.stackId,
|
||||
host: d.host.trim(),
|
||||
port: d.port || 22,
|
||||
username: d.username.trim(),
|
||||
auth:
|
||||
d.authKind === 'password'
|
||||
? { kind: 'password', password: d.password }
|
||||
: {
|
||||
kind: 'key',
|
||||
privateKeyPath: d.privateKeyPath.trim(),
|
||||
...(d.passphrase ? { passphrase: d.passphrase } : {}),
|
||||
},
|
||||
initialPath: d.initialPath.trim() || null,
|
||||
localDir: d.localDir.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Root component of the connection-manager window (__APP_MODE === 'manager').
|
||||
* Modal to the main window (enforced bun-side); connect/open-stack requests
|
||||
* are executed by the MAIN window — this window never touches panels.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts, TitleBar, WindowResize],
|
||||
templateUrl: './connection-manager.html',
|
||||
host: {
|
||||
'(window:keydown.escape)': 'onEscape()',
|
||||
'(window:keydown)': 'onWindowKeydown($event)',
|
||||
'(window:contextmenu)': 'blockContextMenu($event)',
|
||||
// Aero desaturates an unfocused window's chrome — see .window.active.
|
||||
'(window:focus)': 'windowFocused.set(true)',
|
||||
'(window:blur)': 'windowFocused.set(false)',
|
||||
},
|
||||
})
|
||||
export class ConnectionManager {
|
||||
readonly i18n = inject(I18nService);
|
||||
|
||||
/** Drives `.window.active` on this window's Aero frame. */
|
||||
readonly windowFocused = signal(document.hasFocus());
|
||||
readonly store = inject(ConnectionsService);
|
||||
readonly dialogs = inject(DialogsService);
|
||||
private readonly rpc = inject(RpcService);
|
||||
// injected for its side effects: fetches settings and applies theme/accent/
|
||||
// text-size data attributes to this window's document root
|
||||
private readonly settings = inject(SettingsService);
|
||||
private readonly shortcuts = inject(ShortcutsService);
|
||||
|
||||
readonly draft = signal<Draft | null>(null);
|
||||
readonly busy = signal(false);
|
||||
readonly importing = signal(false);
|
||||
/** The More… section (local directory) in the edit form. */
|
||||
readonly moreOpen = signal(false);
|
||||
/** Connection id being dragged between stacks. */
|
||||
readonly draggedId = signal<string | null>(null);
|
||||
/** Highlighted drop target: a stack id, 'loose', or null. */
|
||||
readonly dropTarget = signal<string | null>(null);
|
||||
|
||||
readonly valid = computed(() => {
|
||||
const d = this.draft();
|
||||
if (!d) return false;
|
||||
if (!d.host.trim() || !d.username.trim()) return false;
|
||||
if (d.authKind === 'key' && !d.privateKeyPath.trim()) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
patch<K extends keyof Draft>(key: K, value: Draft[K]): void {
|
||||
this.draft.update((d) => (d ? { ...d, [key]: value } : d));
|
||||
}
|
||||
|
||||
newConnection(): void {
|
||||
this.moreOpen.set(false);
|
||||
this.draft.set({
|
||||
id: '',
|
||||
name: '',
|
||||
stackId: null,
|
||||
host: '',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
authKind: 'password',
|
||||
password: '',
|
||||
privateKeyPath: '',
|
||||
passphrase: '',
|
||||
initialPath: '',
|
||||
localDir: '',
|
||||
});
|
||||
}
|
||||
|
||||
edit(connection: SavedConnection): void {
|
||||
this.draft.set(toDraft(connection));
|
||||
this.moreOpen.set(!!connection.localDir);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
void this.rpc.request.managerClose({});
|
||||
}
|
||||
|
||||
onEscape(): void {
|
||||
// in-window dialogs (confirm/prompt) handle their own Escape first
|
||||
if (!this.dialogs.anyOpen) this.close();
|
||||
}
|
||||
|
||||
/** Any-window shortcuts (interface scale). Escape has its own binding above. */
|
||||
onWindowKeydown(event: KeyboardEvent): void {
|
||||
const match = this.shortcuts.resolve(event, 'manager');
|
||||
if (!match || this.shortcuts.isTypingTarget(event)) return;
|
||||
event.preventDefault();
|
||||
if (match.id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
||||
else if (match.id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1);
|
||||
else if (match.id === 'scale-reset') this.settings.setUiScale(1);
|
||||
}
|
||||
|
||||
blockContextMenu(event: Event): void {
|
||||
blockNativeContextMenu(event);
|
||||
}
|
||||
|
||||
async save(): Promise<string | null> {
|
||||
const d = this.draft();
|
||||
if (!d || !this.valid()) return null;
|
||||
const id = await this.store.upsert(fromDraft(d));
|
||||
if (id) this.patch('id', id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async saveAndConnect(): Promise<void> {
|
||||
if (!this.draft()) return;
|
||||
this.busy.set(true);
|
||||
try {
|
||||
const id = await this.save();
|
||||
if (!id) return;
|
||||
// the main window connects (and this window closes); a failed connect
|
||||
// surfaces as an error there
|
||||
await this.rpc.request.managerConnect({ id });
|
||||
} finally {
|
||||
this.busy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
deleteCurrent(): void {
|
||||
const d = this.draft();
|
||||
if (!d?.id) {
|
||||
this.draft.set(null);
|
||||
return;
|
||||
}
|
||||
this.dialogs.confirm.set({
|
||||
title: this.i18n.t('connMgr.deleteConnectionTitle'),
|
||||
body: this.i18n.t('connMgr.deleteConnectionBody', { name: d.name || d.host }),
|
||||
confirmLabel: this.i18n.t('common.delete'),
|
||||
destructive: true,
|
||||
onConfirm: async () => {
|
||||
await this.store.remove(d.id);
|
||||
this.draft.set(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async pickKeyFile(): Promise<void> {
|
||||
const res = await this.rpc.request.pickKeyFile({});
|
||||
if (res.ok && res.path) this.patch('privateKeyPath', res.path);
|
||||
}
|
||||
|
||||
async importWinScp(): Promise<void> {
|
||||
this.importing.set(true);
|
||||
try {
|
||||
await this.store.importWinScp();
|
||||
} finally {
|
||||
this.importing.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- stacks ----
|
||||
|
||||
openStack(stackId: string): void {
|
||||
// opened as tabs in the main window; bun closes this window after
|
||||
void this.rpc.request.managerOpenStack({ id: stackId });
|
||||
}
|
||||
|
||||
toggleStack(stack: Stack): void {
|
||||
void this.store.upsertStack({ ...stack, collapsed: !stack.collapsed });
|
||||
}
|
||||
|
||||
newStack(): void {
|
||||
this.dialogs.prompt.set({
|
||||
title: this.i18n.t('connMgr.newStackTitle'),
|
||||
placeholder: this.i18n.t('connMgr.newStackPrompt'),
|
||||
confirmLabel: this.i18n.t('fileActions.createConfirm'),
|
||||
onConfirm: (name) => void this.store.upsertStack({ id: '', name }),
|
||||
});
|
||||
}
|
||||
|
||||
renameStack(stack: Stack): void {
|
||||
this.dialogs.prompt.set({
|
||||
title: this.i18n.t('connMgr.renameStackTitle'),
|
||||
initial: stack.name,
|
||||
confirmLabel: this.i18n.t('connMgr.renameStack'),
|
||||
onConfirm: (name) => void this.store.upsertStack({ ...stack, name }),
|
||||
});
|
||||
}
|
||||
|
||||
deleteStack(stack: Stack): void {
|
||||
this.dialogs.confirm.set({
|
||||
title: this.i18n.t('connMgr.deleteStackTitle'),
|
||||
body: this.i18n.t('connMgr.deleteStackBody', { name: stack.name }),
|
||||
confirmLabel: this.i18n.t('common.delete'),
|
||||
destructive: true,
|
||||
onConfirm: () => void this.store.removeStack(stack.id),
|
||||
});
|
||||
}
|
||||
|
||||
// ---- drag & drop (move a connection between stacks / loose) ----
|
||||
|
||||
onDragStart(event: DragEvent, connection: SavedConnection): void {
|
||||
this.draggedId.set(connection.id);
|
||||
event.dataTransfer?.setData('text/plain', connection.id);
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
|
||||
onDragOver(event: DragEvent, target: string): void {
|
||||
if (!this.draggedId()) return;
|
||||
event.preventDefault();
|
||||
this.dropTarget.set(target);
|
||||
}
|
||||
|
||||
onDrop(event: DragEvent, stackId: string | null): void {
|
||||
event.preventDefault();
|
||||
const id = this.draggedId();
|
||||
this.draggedId.set(null);
|
||||
this.dropTarget.set(null);
|
||||
const connection = this.store.connections().find((c) => c.id === id);
|
||||
if (!connection || connection.stackId === stackId) return;
|
||||
void this.store.upsert({ ...connection, stackId });
|
||||
}
|
||||
|
||||
onDragEnd(): void {
|
||||
this.draggedId.set(null);
|
||||
this.dropTarget.set(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { Component, ElementRef, computed, effect, inject, signal, viewChild } from '@angular/core';
|
||||
import { ContextMenuService, type MenuItem } from '@core/context-menu.service';
|
||||
import { FocusService } from '@core/focus.service';
|
||||
import { remPx } from '@core/metrics';
|
||||
|
||||
/** Rough per-item metrics in REM, used to size the menu and decide whether it
|
||||
* must flip. Rem because the interface scale is a root font-size lever: the
|
||||
* old `MENU_W = 200` was pinned into the DOM via [style.width.px], so the box
|
||||
* stayed 200px while the text inside grew with the scale and overflowed.
|
||||
* Values mirror the skinfix restatement of 7.css's menu metrics in 7like.css
|
||||
* (item padding 0.25rem x2 + the ~0.9375rem line). */
|
||||
const ITEM_H_REM = 1.5; // 24px @16
|
||||
const DIVIDER_H_REM = 0.4375; // 7px @16: 2px bevel + 3px/2px margins
|
||||
const MENU_W_REM = 12.5; // 200px @16
|
||||
const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
|
||||
|
||||
/** One rendered <li>: separators collapse onto the item BEFORE them. */
|
||||
interface MenuRow {
|
||||
item: MenuItem;
|
||||
hasDivider: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel right-click menu. Replaces electrobun's native ContextMenu, which
|
||||
* is a no-op on Linux — see ContextMenuService for the why. Emits the same
|
||||
* `ctx-*` / `col:*` action strings the native menu did.
|
||||
*
|
||||
* The markup is 7.css's menu contract EXACTLY — `ul[role=menu] >
|
||||
* li[role=menuitem] > button` for actions, `> input[type=checkbox] + label`
|
||||
* for the checkable column toggles, and dividers as `.has-divider` on the
|
||||
* PRECEDING item (7.css draws them as an ::after bevel, not as a node). Get
|
||||
* any of that wrong and 7.css silently styles nothing: an earlier version
|
||||
* rendered div > button, concluded "7.css has no menu component", and
|
||||
* hand-built a lookalike. It does have one; the selectors just never matched.
|
||||
*
|
||||
* `w7-ctx` is OURS, not 7.css's: `ul[role=menu]` carries `position: relative`
|
||||
* in 7.css (for its gutter stripe and submenus), and the skin layer outranks
|
||||
* Tailwind's `.fixed` — so the menu laid out in flow, at the bottom of the
|
||||
* h-screen frame, off the viewport. The class scopes a `position: fixed`
|
||||
* restatement in 7like.css to THIS menu; see scripts/menu-probe.ts.
|
||||
*
|
||||
* Keyboard follows the platform: arrows move REAL focus between items (7.css
|
||||
* styles :focus-visible identically to :hover), Enter/Space on a button
|
||||
* activate it natively; only labels need a synthetic click.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-context-menu',
|
||||
template: `
|
||||
@if (menu.request(); as req) {
|
||||
<!-- click-outside catcher; mousedown so it beats the panel's own handlers -->
|
||||
<div class="fixed inset-0 z-50" (mousedown)="close()" (contextmenu)="onBackdropContext($event)"></div>
|
||||
<ul
|
||||
#menuEl
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
(keydown)="onKeydown($event)"
|
||||
[style.left.px]="position().x"
|
||||
[style.top.px]="position().y"
|
||||
[style.width.rem]="menuWidthRem"
|
||||
class="w7-ctx fixed z-50 outline-none"
|
||||
>
|
||||
@for (row of rows(); track $index) {
|
||||
<!-- aria-disabled on the LI, not the button: that is where 7.css
|
||||
hangs its disabled state (opacity + pointer-events: none). -->
|
||||
<li
|
||||
role="menuitem"
|
||||
[class.has-divider]="row.hasDivider"
|
||||
[attr.aria-disabled]="row.item.disabled ? true : null"
|
||||
[title]="row.item.tooltip ?? ''"
|
||||
>
|
||||
@if (row.item.checked !== undefined) {
|
||||
<input type="checkbox" [id]="'ctx-check-' + $index" [checked]="row.item.checked" tabindex="-1" />
|
||||
<label [for]="'ctx-check-' + $index" tabindex="-1" (click)="pick(row.item, $event)">
|
||||
{{ row.item.label }}
|
||||
</label>
|
||||
} @else {
|
||||
<button type="button" (click)="pick(row.item)">
|
||||
{{ row.item.label }}
|
||||
@if (row.item.accelerator) {
|
||||
<span>{{ row.item.accelerator }}</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
`,
|
||||
// Closing on scroll/blur matches native menu behavior: the anchor row moves
|
||||
// out from under a menu that would otherwise float over stale content.
|
||||
host: {
|
||||
'(window:blur)': 'close()',
|
||||
'(window:resize)': 'close()',
|
||||
'(document:scroll)': 'close()',
|
||||
},
|
||||
})
|
||||
export class ContextMenu {
|
||||
readonly menu = inject(ContextMenuService);
|
||||
private readonly focus = inject(FocusService);
|
||||
private readonly menuEl = viewChild<ElementRef<HTMLUListElement>>('menuEl');
|
||||
|
||||
/** Rendered width AND math input are the same rem value, so the flip point
|
||||
* and the box can never disagree — the shadlike menu's old bug. */
|
||||
protected readonly menuWidthRem = MENU_W_REM;
|
||||
|
||||
/** Model items → rendered rows: a separator becomes has-divider on its predecessor. */
|
||||
readonly rows = computed<MenuRow[]>(() => {
|
||||
const items = this.menu.request()?.items ?? [];
|
||||
const rows: MenuRow[] = [];
|
||||
for (const item of items) {
|
||||
if (item.type === 'separator') {
|
||||
if (rows.length) rows[rows.length - 1].hasDivider = true;
|
||||
} else {
|
||||
rows.push({ item, hasDivider: false });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
|
||||
/** Click point, flipped/clamped so the menu never overflows the viewport.
|
||||
* UI metrics convert through the live root font-size (remPx); the click
|
||||
* point and window.inner* are both viewport CSS px already. */
|
||||
readonly position = computed(() => {
|
||||
const req = this.menu.request();
|
||||
if (!req) return { x: 0, y: 0 };
|
||||
const rows = this.rows();
|
||||
const menuW = remPx(MENU_W_REM);
|
||||
const height =
|
||||
rows.length * remPx(ITEM_H_REM) +
|
||||
rows.filter((r) => r.hasDivider).length * remPx(DIVIDER_H_REM) +
|
||||
remPx(0.25); // ul padding 0.125rem x2
|
||||
const x =
|
||||
req.x + menuW > window.innerWidth - EDGE_GAP ? Math.max(EDGE_GAP, req.x - menuW) : req.x;
|
||||
const y =
|
||||
req.y + height > window.innerHeight - EDGE_GAP ? Math.max(EDGE_GAP, req.y - height) : req.y;
|
||||
return { x, y };
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// Focus the menu once it exists so arrow keys / Escape reach it. Keyed on
|
||||
// the viewChild signal, NOT on request(): under zoneless CD an effect can
|
||||
// run before the @if has rendered the element.
|
||||
effect(() => this.menuEl()?.nativeElement.focus());
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (!this.menu.request()) return;
|
||||
this.menu.close();
|
||||
this.focus.bump();
|
||||
}
|
||||
|
||||
pick(item: MenuItem, event?: Event): void {
|
||||
// labels: don't let the click also toggle the real checkbox — the fresh
|
||||
// `checked` state comes back through settings when the menu reopens
|
||||
event?.preventDefault();
|
||||
// 7.css already blocks the pointer on [aria-disabled]; this covers the
|
||||
// keyboard path, which routes through focus and never consults CSS
|
||||
if (item.disabled) return;
|
||||
if (!item.action) return;
|
||||
const action = item.action;
|
||||
this.close();
|
||||
this.menu.onPick(action);
|
||||
}
|
||||
|
||||
/** Right-clicking the backdrop closes rather than stacking a second menu. */
|
||||
onBackdropContext(event: MouseEvent): void {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
}
|
||||
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.close();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
this.step(1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
this.step(-1);
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ': {
|
||||
// buttons activate natively from their own focus; labels don't, so
|
||||
// synthesize the click (which routes through pick via the template)
|
||||
const active = document.activeElement;
|
||||
if (active instanceof HTMLLabelElement && this.menuEl()?.nativeElement.contains(active)) {
|
||||
event.preventDefault();
|
||||
active.click();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move REAL focus by one item, wrapping. 7.css styles :focus-visible the
|
||||
* same as :hover, and keyboard-initiated programmatic focus counts as
|
||||
* focus-visible, so the highlight comes from the framework for free.
|
||||
*/
|
||||
private step(delta: number): void {
|
||||
const root = this.menuEl()?.nativeElement;
|
||||
if (!root) return;
|
||||
// disabled items are skipped, not merely unclickable: pointer-events:none
|
||||
// does not make an element unfocusable, so arrows would still land on them
|
||||
const targets = Array.from(
|
||||
root.querySelectorAll<HTMLElement>(
|
||||
'li:not([aria-disabled]) > button, li:not([aria-disabled]) > label',
|
||||
),
|
||||
);
|
||||
if (!targets.length) return;
|
||||
const current = targets.indexOf(document.activeElement as HTMLElement);
|
||||
const next = current < 0 && delta < 0 ? targets.length - 1 : (current + delta + targets.length) % targets.length;
|
||||
targets[next].focus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Component, computed, inject, input, signal } from '@angular/core';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { DialogsService, type ChmodRequest } from '@core/dialogs.service';
|
||||
import { FocusService } from '@core/focus.service';
|
||||
|
||||
const ROLES = ['roleOwner', 'roleGroup', 'roleOthers'] as const;
|
||||
const PERMS = ['r', 'w', 'x'] as const;
|
||||
|
||||
@Component({
|
||||
selector: 'app-chmod-dialog',
|
||||
template: `
|
||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
|
||||
<div
|
||||
class="w-80 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
||||
(mousedown)="$event.stopPropagation()"
|
||||
(keydown.escape)="cancel()"
|
||||
>
|
||||
<h2 class="text-sm font-semibold">{{ i18n.t('dialogs.chmodTitle') }}</h2>
|
||||
<p class="mt-1 truncate text-xs text-muted-foreground">{{ targetLabel() }}</p>
|
||||
|
||||
<table class="mt-3 w-full text-xs">
|
||||
<thead>
|
||||
<tr class="text-muted-foreground">
|
||||
<th class="pb-1 text-left font-normal"></th>
|
||||
<th class="pb-1 font-normal">{{ i18n.t('dialogs.read') }}</th>
|
||||
<th class="pb-1 font-normal">{{ i18n.t('dialogs.write') }}</th>
|
||||
<th class="pb-1 font-normal">{{ i18n.t('dialogs.execute') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (role of roles; track role; let r = $index) {
|
||||
<tr>
|
||||
<td class="py-1 pr-2 text-muted-foreground">{{ i18n.t('dialogs.' + role) }}</td>
|
||||
@for (perm of perms; track perm; let p = $index) {
|
||||
<td class="py-1 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="hasBit(r, p)"
|
||||
(change)="toggleBit(r, p)"
|
||||
class="size-3.5 accent-primary"
|
||||
/>
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<label class="text-xs text-muted-foreground">{{ i18n.t('dialogs.octal') }}</label>
|
||||
<input
|
||||
[value]="octal()"
|
||||
(input)="setOctal($any($event.target).value)"
|
||||
maxlength="4"
|
||||
spellcheck="false"
|
||||
class="h-7 w-20 rounded-md border border-input bg-transparent px-2 font-mono text-xs outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
(click)="cancel()"
|
||||
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
>
|
||||
{{ i18n.t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="submit()"
|
||||
class="w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{{ i18n.t('common.apply') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ChmodDialog {
|
||||
readonly i18n = inject(I18nService);
|
||||
readonly request = input.required<ChmodRequest>();
|
||||
|
||||
readonly roles = ROLES;
|
||||
readonly perms = PERMS;
|
||||
|
||||
private readonly dialogs = inject(DialogsService);
|
||||
private readonly focus = inject(FocusService);
|
||||
|
||||
// mode is initialized lazily from the request on first read
|
||||
private readonly modeOverride = signal<number | null>(null);
|
||||
readonly mode = computed(() => this.modeOverride() ?? this.request().initialMode & 0o777);
|
||||
readonly octal = computed(() => this.mode().toString(8).padStart(3, '0'));
|
||||
|
||||
targetLabel(): string {
|
||||
const targets = this.request().targets;
|
||||
return targets.length === 1
|
||||
? targets[0]
|
||||
: this.i18n.t('dialogs.chmodItems', { count: targets.length });
|
||||
}
|
||||
|
||||
hasBit(role: number, perm: number): boolean {
|
||||
return !!(this.mode() & (0o400 >> (role * 3 + perm)));
|
||||
}
|
||||
|
||||
toggleBit(role: number, perm: number): void {
|
||||
this.modeOverride.set(this.mode() ^ (0o400 >> (role * 3 + perm)));
|
||||
}
|
||||
|
||||
setOctal(text: string): void {
|
||||
if (/^[0-7]{1,4}$/.test(text)) {
|
||||
this.modeOverride.set(parseInt(text, 8) & 0o777);
|
||||
}
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
const req = this.request();
|
||||
const mode = this.mode();
|
||||
this.close();
|
||||
req.onConfirm(mode);
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.close();
|
||||
}
|
||||
|
||||
private close(): void {
|
||||
this.dialogs.chmod.set(null);
|
||||
this.focus.bump();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { DialogsService, type ConfirmRequest } from '@core/dialogs.service';
|
||||
import { FocusService } from '@core/focus.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-confirm-dialog',
|
||||
template: `
|
||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
|
||||
<div
|
||||
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
||||
(mousedown)="$event.stopPropagation()"
|
||||
(keydown.escape)="cancel()"
|
||||
>
|
||||
<h2 class="text-sm font-semibold">{{ request().title }}</h2>
|
||||
<p class="mt-2 text-xs text-muted-foreground">{{ request().body }}</p>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
(click)="cancel()"
|
||||
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
>
|
||||
{{ i18n.t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
#confirmBtn
|
||||
(click)="submit()"
|
||||
[class]="
|
||||
request().destructive
|
||||
? 'w7-btn rounded-md bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground hover:bg-destructive/90'
|
||||
: 'w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90'
|
||||
"
|
||||
>
|
||||
{{ request().confirmLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ConfirmDialog {
|
||||
readonly i18n = inject(I18nService);
|
||||
readonly request = input.required<ConfirmRequest>();
|
||||
|
||||
private readonly confirmBtn = viewChild.required<ElementRef<HTMLButtonElement>>('confirmBtn');
|
||||
private readonly dialogs = inject(DialogsService);
|
||||
private readonly focus = inject(FocusService);
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => this.confirmBtn().nativeElement.focus());
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
const req = this.request();
|
||||
this.close();
|
||||
req.onConfirm();
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.close();
|
||||
}
|
||||
|
||||
private close(): void {
|
||||
this.dialogs.confirm.set(null);
|
||||
this.focus.bump();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
afterNextRender,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '@core/dialogs.service';
|
||||
import { FocusService } from '@core/focus.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-overwrite-dialog',
|
||||
imports: [NgIcon],
|
||||
template: `
|
||||
<div
|
||||
class="fixed inset-0 z-40 grid place-items-center bg-black/40"
|
||||
(keydown.escape)="pick('cancel')"
|
||||
>
|
||||
<div
|
||||
class="w-[26rem] rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h2 class="min-w-0 text-sm font-semibold">
|
||||
{{ isDir() ? i18n.t('dialogs.mergeFolder') : i18n.t('dialogs.replace') }}
|
||||
<span class="font-mono">"{{ request().srcEntry.name }}"</span>?
|
||||
</h2>
|
||||
@if (request().total > 1) {
|
||||
<span
|
||||
class="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[0.625rem] font-medium text-muted-foreground"
|
||||
>
|
||||
{{ i18n.t('dialogs.ofTotal', { index: request().index, total: request().total }) }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
@if (isDir()) {
|
||||
{{ i18n.t('dialogs.overwriteDirBody') }}
|
||||
} @else {
|
||||
{{ i18n.t('dialogs.overwriteFileBody') }}
|
||||
}
|
||||
</p>
|
||||
|
||||
<!-- comparison -->
|
||||
<div class="mt-3 grid grid-cols-2 gap-2">
|
||||
@for (side of comparison(); track side.label) {
|
||||
<div class="rounded-md border border-border p-2.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
|
||||
{{ i18n.t('dialogs.' + side.label) }}
|
||||
</span>
|
||||
@if (side.newer) {
|
||||
<span class="rounded-full bg-success/15 px-1.5 text-[0.625rem] font-medium text-success">
|
||||
{{ i18n.t('dialogs.newer') }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="mt-1.5 flex items-center gap-1.5 text-xs">
|
||||
<ng-icon
|
||||
[name]="isDir() ? 'mynaFolder' : 'mynaFile'"
|
||||
size="0.9rem"
|
||||
class="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span class="font-mono tabular-nums">
|
||||
{{ isDir() ? '—' : i18n.size(side.entry.size) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 font-mono text-[0.6875rem] tabular-nums text-muted-foreground">
|
||||
{{ i18n.date(side.entry.mtime) }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- actions -->
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
(click)="pick('cancel')"
|
||||
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{{ i18n.t('common.cancel') }}
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
@if (request().total > 1) {
|
||||
<button
|
||||
(click)="pick('skipAll')"
|
||||
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{{ i18n.t('dialogs.skipAll') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="pick('overwriteAll')"
|
||||
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{{ isDir() ? i18n.t('dialogs.mergeAll') : i18n.t('dialogs.replaceAll') }}
|
||||
</button>
|
||||
<div class="h-4 w-px bg-border"></div>
|
||||
}
|
||||
<button
|
||||
(click)="pick('skip')"
|
||||
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
>
|
||||
{{ i18n.t('dialogs.skip') }}
|
||||
</button>
|
||||
<button
|
||||
#primaryBtn
|
||||
(click)="pick('overwrite')"
|
||||
class="w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{{ isDir() ? i18n.t('dialogs.merge') : i18n.t('dialogs.replace') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class OverwriteDialog {
|
||||
readonly i18n = inject(I18nService);
|
||||
readonly request = input.required<OverwriteRequest>();
|
||||
|
||||
|
||||
private readonly dialogs = inject(DialogsService);
|
||||
private readonly focus = inject(FocusService);
|
||||
private readonly primaryBtn = viewChild.required<ElementRef<HTMLButtonElement>>('primaryBtn');
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => this.primaryBtn().nativeElement.focus());
|
||||
}
|
||||
|
||||
readonly isDir = computed(() => this.request().srcEntry.type === 'dir');
|
||||
|
||||
readonly comparison = computed(() => {
|
||||
const { srcEntry, dstEntry } = this.request();
|
||||
return [
|
||||
{ label: 'source', entry: srcEntry, newer: srcEntry.mtime > dstEntry.mtime },
|
||||
{ label: 'destination', entry: dstEntry, newer: dstEntry.mtime > srcEntry.mtime },
|
||||
];
|
||||
});
|
||||
|
||||
pick(choice: OverwriteChoice): void {
|
||||
const req = this.request();
|
||||
this.dialogs.overwrite.set(null);
|
||||
this.focus.bump();
|
||||
req.resolve(choice);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
afterNextRender,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { DialogsService, type PromptRequest } from '@core/dialogs.service';
|
||||
import { FocusService } from '@core/focus.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-prompt-dialog',
|
||||
template: `
|
||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
|
||||
<div
|
||||
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
||||
(mousedown)="$event.stopPropagation()"
|
||||
>
|
||||
<h2 class="text-sm font-semibold">{{ request().title }}</h2>
|
||||
<input
|
||||
#box
|
||||
[value]="value()"
|
||||
(input)="value.set($any($event.target).value)"
|
||||
(keydown.enter)="submit()"
|
||||
(keydown.escape)="cancel()"
|
||||
[placeholder]="request().placeholder ?? ''"
|
||||
spellcheck="false"
|
||||
class="mt-3 h-8 w-full rounded-md border border-input bg-transparent px-2 text-sm outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
(click)="cancel()"
|
||||
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
>
|
||||
{{ i18n.t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="submit()"
|
||||
[disabled]="!value().trim()"
|
||||
class="w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{{ request().confirmLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class PromptDialog {
|
||||
readonly i18n = inject(I18nService);
|
||||
readonly request = input.required<PromptRequest>();
|
||||
readonly value = signal('');
|
||||
|
||||
private readonly box = viewChild.required<ElementRef<HTMLInputElement>>('box');
|
||||
private readonly dialogs = inject(DialogsService);
|
||||
private readonly focus = inject(FocusService);
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
this.value.set(this.request().initial ?? '');
|
||||
const el = this.box().nativeElement;
|
||||
el.focus();
|
||||
el.select();
|
||||
});
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
const value = this.value().trim();
|
||||
if (!value) return;
|
||||
const req = this.request();
|
||||
this.close();
|
||||
req.onConfirm(value);
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.close();
|
||||
}
|
||||
|
||||
private close(): void {
|
||||
this.dialogs.prompt.set(null);
|
||||
this.focus.bump();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
<div class="window glass w7-frame flex h-screen flex-col" [class.active]="windowFocused()">
|
||||
@if (!ctx) {
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm">
|
||||
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
|
||||
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.noFileTitle') }}</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ i18n.t('editor.noFileBody') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- The window is frameless. The X runs the SAME dirty prompt as ⌘W and
|
||||
the Close button — never a raw window close. -->
|
||||
<app-title-bar [title]="ctx.name + (dirty() ? ' *' : '')" (closeRequested)="requestClose()" />
|
||||
<!-- Toolbar strip under the title bar. h-9/pl-3 are load-bearing: without a
|
||||
height this row collapses onto the icon and the screen reads as broken. -->
|
||||
<header
|
||||
class="w7-toolbar flex h-9 shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2 pl-3"
|
||||
>
|
||||
<ng-icon name="mynaFileText" size="0.95rem" class="shrink-0 text-muted-foreground" />
|
||||
<h1 class="min-w-0 truncate font-mono text-sm font-semibold">
|
||||
{{ ctx.name }}{{ dirty() ? ' •' : '' }}
|
||||
</h1>
|
||||
<span class="shrink-0 text-[0.6875rem] text-muted-foreground">{{ ctx.sessionTitle }}</span>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
(click)="toggleWrap()"
|
||||
[title]="i18n.t('editor.wrapHint', { alt: altLabel })"
|
||||
class="rounded-md px-2 py-1 text-[0.6875rem] electrobun-webkit-app-region-no-drag hover:bg-accent"
|
||||
[class.bg-accent]="wrap()"
|
||||
[class.text-foreground]="wrap()"
|
||||
[class.text-muted-foreground]="!wrap()"
|
||||
>
|
||||
{{ i18n.t('editor.wrap') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="openFind()"
|
||||
[title]="i18n.t('editor.findHint', { mod: modLabel })"
|
||||
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaSearch" size="0.85rem" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@if (findOpen()) {
|
||||
<div class="flex shrink-0 items-center gap-2 border-b border-border bg-chrome px-3 py-1.5">
|
||||
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
#findBox
|
||||
[value]="findQuery()"
|
||||
(input)="onFindInput($any($event.target).value)"
|
||||
(keydown)="onFindKeydown($event)"
|
||||
[placeholder]="i18n.t('editor.findPlaceholder')"
|
||||
spellcheck="false"
|
||||
class="h-6 w-56 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<span
|
||||
class="text-[0.6875rem] tabular-nums"
|
||||
[class.text-destructive]="findQuery() && !matches().length"
|
||||
[class.text-muted-foreground]="!findQuery() || matches().length"
|
||||
>
|
||||
{{ countLabel() }}
|
||||
</span>
|
||||
<button
|
||||
(click)="findStep(-1)"
|
||||
[title]="i18n.t('editor.prevMatch')"
|
||||
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaChevronUp" size="0.8rem" />
|
||||
</button>
|
||||
<button
|
||||
(click)="findStep(1)"
|
||||
[title]="i18n.t('editor.nextMatch')"
|
||||
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaChevronDown" size="0.8rem" />
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
(click)="closeFind()"
|
||||
[title]="i18n.t('editor.closeHint')"
|
||||
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaX" size="0.8rem" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (sessionDead()) {
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-2 border-b border-border bg-destructive/10 px-3 py-1.5 text-[0.6875rem] text-destructive"
|
||||
>
|
||||
<ng-icon name="mynaXCircle" size="0.85rem" class="shrink-0" />
|
||||
{{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<ng-icon name="mynaSpinner" size="1.25rem" class="animate-spin text-primary" />
|
||||
</div>
|
||||
} @else if (loadError(); as error) {
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<div class="max-w-md rounded-lg border border-border bg-card p-6 text-center shadow-sm">
|
||||
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
|
||||
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.cantOpen', { name: ctx.name }) }}</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{{ error }}</p>
|
||||
<button
|
||||
(click)="discardAndClose()"
|
||||
class="mt-4 rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
>
|
||||
{{ i18n.t('editor.closeWindow') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<!-- gutter and textarea share font/leading/vertical padding — that's
|
||||
the whole alignment contract; keep the classes in lockstep -->
|
||||
@if (!wrap()) {
|
||||
<div
|
||||
#gutter
|
||||
aria-hidden="true"
|
||||
[style.width.ch]="gutterDigits() + 3"
|
||||
class="shrink-0 overflow-hidden border-r border-border py-4 pr-2 text-right font-mono text-xs leading-relaxed whitespace-pre text-muted-foreground select-none"
|
||||
>{{ gutterText() }}</div>
|
||||
}
|
||||
<textarea
|
||||
#box
|
||||
[value]="text()"
|
||||
(input)="onInput($any($event.target).value)"
|
||||
(scroll)="onScroll()"
|
||||
spellcheck="false"
|
||||
[attr.wrap]="wrap() ? 'soft' : 'off'"
|
||||
class="min-h-0 flex-1 resize-none overflow-auto bg-transparent px-4 py-4 font-mono text-xs leading-relaxed outline-none"
|
||||
[class.whitespace-pre]="!wrap()"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<footer class="flex shrink-0 items-center gap-2 border-t border-border bg-chrome px-4 py-2">
|
||||
<span class="text-[0.6875rem] text-muted-foreground">{{ i18n.t('editor.shortcuts', { mod: modLabel }) }}</span>
|
||||
<span class="text-[0.6875rem] text-muted-foreground">·</span>
|
||||
<span class="text-[0.6875rem] tabular-nums text-muted-foreground">
|
||||
{{ i18n.t('editor.lines', { count: lineCount() }) }}
|
||||
</span>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
(click)="requestClose()"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
>
|
||||
{{ i18n.t('editor.close') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="save()"
|
||||
[disabled]="saving() || !dirty()"
|
||||
class="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
@if (saving()) {
|
||||
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
|
||||
}
|
||||
{{ i18n.t('common.save') }}
|
||||
</button>
|
||||
</footer>
|
||||
}
|
||||
|
||||
<!-- dirty-close prompt. Component-local on purpose: the shared confirm
|
||||
dialog pulls in PanelsService, which must not boot in this window. -->
|
||||
@if (closePrompt()) {
|
||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancelClose()">
|
||||
<div
|
||||
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
||||
(mousedown)="$event.stopPropagation()"
|
||||
>
|
||||
<h2 class="text-sm font-semibold">{{ i18n.t('editor.saveChangesTitle', { name: ctx.name }) }}</h2>
|
||||
<p class="mt-1.5 text-xs text-muted-foreground">
|
||||
{{ i18n.t('editor.saveChangesBody') }}
|
||||
</p>
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
(click)="discardAndClose()"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium text-destructive hover:bg-accent"
|
||||
>
|
||||
{{ i18n.t('editor.dontSave') }}
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
(click)="cancelClose()"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
>
|
||||
{{ i18n.t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="saveAndClose()"
|
||||
[disabled]="saving()"
|
||||
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{{ i18n.t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- STOPGAP: the frameless window has no WM resize borders, so we draw our
|
||||
own handles. Delete with the rest of the hack — checklist in
|
||||
window-resize/window-resize.ts. -->
|
||||
<app-window-resize />
|
||||
|
||||
<app-toasts />
|
||||
</div>
|
||||
@@ -0,0 +1,389 @@
|
||||
import {
|
||||
Component,
|
||||
DOCUMENT,
|
||||
ElementRef,
|
||||
afterNextRender,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, MOD_LABEL } from '@core/platform';
|
||||
import { RpcService } from '@core/rpc.service';
|
||||
import { SettingsService } from '@core/settings.service';
|
||||
import { ShortcutsService } from '@core/shortcuts.service';
|
||||
import { ToastService } from '@core/toast.service';
|
||||
import { TitleBar } from '../title-bar/title-bar';
|
||||
import { Toasts } from '../toasts/toasts';
|
||||
import { WindowResize } from '../window-resize/window-resize';
|
||||
|
||||
/** Debounce for mirroring the draft to bun (the traffic-light-close safety net). */
|
||||
const DRAFT_DEBOUNCE_MS = 400;
|
||||
|
||||
/**
|
||||
* Root component of an editor window (window.__APP_MODE === 'editor').
|
||||
*
|
||||
* Deliberately injects only Rpc/Toast/Settings: main-window root services
|
||||
* (PanelsService, ConnectionsService, TransfersService — and the dialogs that
|
||||
* inject them) have constructor side effects like creating workspaces and
|
||||
* loading connections that must not run here.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [NgIcon, Toasts, TitleBar, WindowResize],
|
||||
templateUrl: './editor-window.html',
|
||||
host: {
|
||||
'(window:keydown)': 'onKeydown($event)',
|
||||
'(window:contextmenu)': 'blockContextMenu($event)',
|
||||
// Aero desaturates an unfocused window's chrome — see .window.active.
|
||||
// blur already had to flush the draft; both run from the one binding
|
||||
// (a duplicated key is a TS1117 build error, not a merge).
|
||||
'(window:focus)': 'windowFocused.set(true)',
|
||||
'(window:blur)': 'flushDraft(); windowFocused.set(false)',
|
||||
},
|
||||
})
|
||||
export class EditorWindow {
|
||||
readonly i18n = inject(I18nService);
|
||||
|
||||
/** Drives `.window.active` on this window's Aero frame. */
|
||||
readonly windowFocused = signal(document.hasFocus());
|
||||
readonly ctx = EDITOR_CONTEXT;
|
||||
readonly modLabel = MOD_LABEL;
|
||||
readonly altLabel = ALT_LABEL;
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly loadError = signal<string | null>(null);
|
||||
readonly text = signal('');
|
||||
readonly savedContent = signal('');
|
||||
readonly dirty = computed(() => this.text() !== this.savedContent());
|
||||
readonly saving = signal(false);
|
||||
readonly closePrompt = signal(false);
|
||||
readonly sessionDead = signal(false);
|
||||
/** Soft-wrap long lines. Hides the gutter: correct wrapped line numbers
|
||||
* would need per-edit height measurement — not worth it without an editor lib. */
|
||||
readonly wrap = signal(false);
|
||||
readonly findOpen = signal(false);
|
||||
readonly findQuery = signal('');
|
||||
readonly activeMatch = signal(0);
|
||||
|
||||
/** Line count without allocating a split array on every keystroke. */
|
||||
readonly lineCount = computed(() => {
|
||||
const t = this.text();
|
||||
let n = 1;
|
||||
let i = -1;
|
||||
while ((i = t.indexOf('\n', i + 1)) !== -1) n++;
|
||||
return n;
|
||||
});
|
||||
readonly gutterText = computed(() =>
|
||||
Array.from({ length: this.lineCount() }, (_, i) => i + 1).join('\n'),
|
||||
);
|
||||
readonly gutterDigits = computed(() => String(this.lineCount()).length);
|
||||
|
||||
/** Case-insensitive match start offsets for the find query. */
|
||||
readonly matches = computed(() => {
|
||||
const query = this.findQuery().toLowerCase();
|
||||
if (!query) return [];
|
||||
const hay = this.text().toLowerCase();
|
||||
const out: number[] = [];
|
||||
let i = hay.indexOf(query);
|
||||
while (i !== -1) {
|
||||
out.push(i);
|
||||
i = hay.indexOf(query, i + query.length);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
readonly countLabel = computed(() =>
|
||||
this.matches().length ? `${this.activeMatch() + 1}/${this.matches().length}` : '0/0',
|
||||
);
|
||||
|
||||
private readonly box = viewChild<ElementRef<HTMLTextAreaElement>>('box');
|
||||
private readonly gutter = viewChild<ElementRef<HTMLElement>>('gutter');
|
||||
private readonly findBox = viewChild<ElementRef<HTMLInputElement>>('findBox');
|
||||
private cachedCharWidth = 0;
|
||||
private cachedCharFont = '';
|
||||
private readonly rpc = inject(RpcService);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
// injected for its side effects: fetches settings and applies theme/accent/
|
||||
// text-size data attributes to this window's document root
|
||||
private readonly settings = inject(SettingsService);
|
||||
private readonly shortcuts = inject(ShortcutsService);
|
||||
|
||||
private draftTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private lastSentDirty = false;
|
||||
|
||||
constructor() {
|
||||
const ctx = this.ctx;
|
||||
if (!ctx) return; // opened outside the app (plain browser) — error card renders
|
||||
|
||||
const baseTitle = `${ctx.name} — ${ctx.sessionTitle}`;
|
||||
effect(() => {
|
||||
this.document.title = `${this.dirty() ? '• ' : ''}${baseTitle}`;
|
||||
});
|
||||
|
||||
this.rpc.onEditorCloseRequest(() => this.requestClose());
|
||||
this.rpc.onSessionClosed((event) => {
|
||||
if (event.sessionId === ctx.sessionId) this.sessionDead.set(true);
|
||||
});
|
||||
|
||||
afterNextRender(() => void this.load(ctx.sessionId, ctx.path));
|
||||
}
|
||||
|
||||
private async load(sessionId: string, path: string): Promise<void> {
|
||||
const res = await this.rpc.request.readFile({ sessionId, path });
|
||||
if (!res.ok) {
|
||||
this.loadError.set(res.error);
|
||||
this.loading.set(false);
|
||||
return;
|
||||
}
|
||||
this.savedContent.set(res.content);
|
||||
this.text.set(res.content);
|
||||
this.loading.set(false);
|
||||
queueMicrotask(() => this.box()?.nativeElement.focus());
|
||||
}
|
||||
|
||||
blockContextMenu(event: Event): void {
|
||||
blockNativeContextMenu(event);
|
||||
}
|
||||
|
||||
onInput(value: string): void {
|
||||
this.text.set(value);
|
||||
this.scheduleDraft();
|
||||
}
|
||||
|
||||
/** Keep the line-number gutter locked to the textarea's scroll position. */
|
||||
onScroll(): void {
|
||||
const gutter = this.gutter()?.nativeElement;
|
||||
if (gutter) gutter.scrollTop = this.box()!.nativeElement.scrollTop;
|
||||
}
|
||||
|
||||
toggleWrap(): void {
|
||||
this.wrap.update((w) => !w);
|
||||
queueMicrotask(() => this.onScroll());
|
||||
}
|
||||
|
||||
// ---- find ----
|
||||
|
||||
openFind(): void {
|
||||
const el = this.box()?.nativeElement;
|
||||
// prefill from the current selection when it's a sane single-line snippet
|
||||
if (el && el.selectionStart !== el.selectionEnd) {
|
||||
const selected = el.value.slice(el.selectionStart, el.selectionEnd);
|
||||
if (selected.length < 200 && !selected.includes('\n')) this.findQuery.set(selected);
|
||||
}
|
||||
this.findOpen.set(true);
|
||||
this.activeMatch.set(0);
|
||||
queueMicrotask(() => {
|
||||
const input = this.findBox()?.nativeElement;
|
||||
input?.focus();
|
||||
input?.select();
|
||||
});
|
||||
}
|
||||
|
||||
closeFind(): void {
|
||||
this.findOpen.set(false);
|
||||
// selection was left on the match — the caret lands there
|
||||
this.box()?.nativeElement.focus();
|
||||
}
|
||||
|
||||
onFindInput(value: string): void {
|
||||
this.findQuery.set(value);
|
||||
const list = this.matches();
|
||||
if (!list.length) return;
|
||||
// jump to the first match at/after the caret, else wrap to the first
|
||||
const from = this.box()?.nativeElement.selectionStart ?? 0;
|
||||
const at = list.findIndex((start) => start >= from);
|
||||
this.goTo(at >= 0 ? at : 0);
|
||||
}
|
||||
|
||||
findStep(delta: 1 | -1): void {
|
||||
const count = this.matches().length;
|
||||
if (!count) return;
|
||||
this.goTo((this.activeMatch() + delta + count) % count);
|
||||
}
|
||||
|
||||
onFindKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
this.findStep(event.shiftKey ? -1 : 1);
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.closeFind();
|
||||
}
|
||||
}
|
||||
|
||||
/** Select match `index` and scroll it into the middle of the viewport. */
|
||||
private goTo(index: number): void {
|
||||
const el = this.box()?.nativeElement;
|
||||
const start = this.matches()[index];
|
||||
if (!el || start === undefined) return;
|
||||
this.activeMatch.set(index);
|
||||
el.setSelectionRange(start, start + this.findQuery().length);
|
||||
|
||||
const text = this.text();
|
||||
// Fallback derives from the element's own font size, not a hardcoded 20 —
|
||||
// the interface scale moves the root font-size, so any px literal here
|
||||
// would be a 16px-root assumption that miscentres at every other scale.
|
||||
const style = getComputedStyle(el);
|
||||
const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.625;
|
||||
let line = 0;
|
||||
let i = text.indexOf('\n');
|
||||
while (i !== -1 && i < start) {
|
||||
line++;
|
||||
i = text.indexOf('\n', i + 1);
|
||||
}
|
||||
el.scrollTop = Math.max(0, line * lineHeight - el.clientHeight / 2);
|
||||
if (!this.wrap()) {
|
||||
const column = start - (text.lastIndexOf('\n', start - 1) + 1);
|
||||
el.scrollLeft = Math.max(0, column * this.charWidth(el) - el.clientWidth / 2);
|
||||
}
|
||||
this.onScroll();
|
||||
}
|
||||
|
||||
/** Monospace advance width, cached per computed FONT rather than per window:
|
||||
* the interface scale changes the root font-size live, and a cache measured
|
||||
* once would keep positioning finds with the old advance width. Keying on
|
||||
* the font string makes it self-invalidating under any font change. */
|
||||
private charWidth(el: HTMLTextAreaElement): number {
|
||||
const font = getComputedStyle(el).font;
|
||||
if (font !== this.cachedCharFont) {
|
||||
const canvas = this.document.createElement('canvas').getContext('2d')!;
|
||||
canvas.font = font;
|
||||
this.cachedCharWidth = canvas.measureText('0').width || parseFloat(getComputedStyle(el).fontSize) * 0.6;
|
||||
this.cachedCharFont = font;
|
||||
}
|
||||
return this.cachedCharWidth;
|
||||
}
|
||||
|
||||
// ---- draft mirror: bun holds the latest content for the native-close prompt ----
|
||||
|
||||
private scheduleDraft(): void {
|
||||
if (this.dirty() !== this.lastSentDirty) {
|
||||
// dirty flag flipped — tell bun immediately so the native title updates
|
||||
this.flushDraft();
|
||||
return;
|
||||
}
|
||||
if (this.draftTimer) clearTimeout(this.draftTimer);
|
||||
this.draftTimer = setTimeout(() => this.flushDraft(), DRAFT_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
flushDraft(): void {
|
||||
if (!this.ctx || this.loading()) return;
|
||||
if (this.draftTimer) {
|
||||
clearTimeout(this.draftTimer);
|
||||
this.draftTimer = null;
|
||||
}
|
||||
this.lastSentDirty = this.dirty();
|
||||
this.rpc.send.editorState({ dirty: this.dirty(), content: this.text() });
|
||||
}
|
||||
|
||||
// ---- save / close ----
|
||||
|
||||
async save(): Promise<boolean> {
|
||||
if (!this.dirty() || this.saving()) return true;
|
||||
const content = this.text();
|
||||
this.saving.set(true);
|
||||
try {
|
||||
const res = await this.rpc.request.editorSave({ content });
|
||||
if (!res.ok) {
|
||||
this.toast.error(res.error);
|
||||
return false;
|
||||
}
|
||||
this.savedContent.set(content);
|
||||
this.lastSentDirty = false; // bun record already cleaned by the handler
|
||||
this.toast.success(this.i18n.t('toasts.saved', { name: this.ctx!.name }));
|
||||
return true;
|
||||
} finally {
|
||||
this.saving.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** ⌘W / Close button / bun's editorCloseRequest: prompt if dirty, else close. */
|
||||
requestClose(): void {
|
||||
if (this.dirty()) {
|
||||
this.closePrompt.set(true);
|
||||
} else {
|
||||
void this.rpc.request.editorClose({});
|
||||
}
|
||||
}
|
||||
|
||||
async saveAndClose(): Promise<void> {
|
||||
if (await this.save()) void this.rpc.request.editorClose({});
|
||||
}
|
||||
|
||||
discardAndClose(): void {
|
||||
void this.rpc.request.editorClose({});
|
||||
}
|
||||
|
||||
cancelClose(): void {
|
||||
this.closePrompt.set(false);
|
||||
queueMicrotask(() => this.box()?.nativeElement.focus());
|
||||
}
|
||||
|
||||
// ---- keyboard ----
|
||||
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
// Chord shortcuts resolve through the shared keymap (ShortcutsService);
|
||||
// Escape and Tab stay below because their meaning is THIS window's state
|
||||
// (close prompt up? find bar open? caret in the textarea?), not a binding.
|
||||
const match = this.shortcuts.resolve(event, 'editor');
|
||||
if (match) {
|
||||
// scale keys must not fire while typing — but the editor's own chords
|
||||
// (save/find/wrap) are WANTED mid-typing, so the guard is per-shortcut
|
||||
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
|
||||
event.preventDefault();
|
||||
switch (match.id) {
|
||||
case 'save':
|
||||
void this.save();
|
||||
break;
|
||||
case 'find':
|
||||
this.openFind();
|
||||
break;
|
||||
case 'close-window':
|
||||
// reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W
|
||||
// and bun routes it back as editorCloseRequest
|
||||
this.requestClose();
|
||||
break;
|
||||
case 'toggle-wrap':
|
||||
this.toggleWrap();
|
||||
break;
|
||||
case 'scale-up':
|
||||
this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
||||
break;
|
||||
case 'scale-down':
|
||||
this.settings.setUiScale(this.settings.uiScale() - 0.1);
|
||||
break;
|
||||
case 'scale-reset':
|
||||
this.settings.setUiScale(1);
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape' && this.closePrompt()) {
|
||||
event.preventDefault();
|
||||
this.cancelClose();
|
||||
} else if (event.key === 'Escape' && this.findOpen()) {
|
||||
event.preventDefault();
|
||||
this.closeFind();
|
||||
} else if (event.key === 'Tab' && event.target === this.box()?.nativeElement) {
|
||||
// keep Tab as an input character inside the editor
|
||||
event.preventDefault();
|
||||
this.insertTab();
|
||||
}
|
||||
}
|
||||
|
||||
private insertTab(): void {
|
||||
const el = this.box()!.nativeElement;
|
||||
// execCommand keeps the native undo stack intact where supported
|
||||
if (!this.document.execCommand('insertText', false, '\t')) {
|
||||
const { selectionStart, selectionEnd, value } = el;
|
||||
el.value = `${value.slice(0, selectionStart)}\t${value.slice(selectionEnd)}`;
|
||||
el.selectionStart = el.selectionEnd = selectionStart + 1;
|
||||
}
|
||||
this.onInput(el.value);
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Component, inject, input, output, signal } from '@angular/core';
|
||||
import { RpcService } from '@core/rpc.service';
|
||||
|
||||
/**
|
||||
* The Aero title bar. Rendered by all three window kinds. The native frame is
|
||||
* gone on every platform (titleBarStyle 'hidden'), so this row IS the window's
|
||||
* chrome.
|
||||
*
|
||||
* The markup is 7.css's contract, not ours: `.title-bar` > `.title-bar-text` +
|
||||
* `.title-bar-controls` > buttons keyed by `aria-label` (Minimize / Maximize /
|
||||
* Restore / Close each get their own glyph). Nothing here is styled with
|
||||
* Tailwind on purpose — the whole row comes from @layer skin.
|
||||
*
|
||||
* `.title-bar` lives on the HOST element, not on an inner div. 7.css couples
|
||||
* the glass treatment with a DIRECT-child selector — `.window.glass >
|
||||
* .title-bar` — and an inner div puts <app-title-bar> in between, so the rule
|
||||
* never matched: the title bar rendered as the opaque fallback with its own
|
||||
* border, visibly disconnected from the frame, instead of the translucent
|
||||
* streaked glass that fuses with it.
|
||||
*
|
||||
* Dragging rides on electrobun's preload drag-region classes. Two things
|
||||
* electrobun does NOT provide and this component implements:
|
||||
* - double-click to maximize (it only wires mousedown/mouseup)
|
||||
* - the maximize/restore glyph state, which has no event; bun derives it
|
||||
* from the native resize and pushes `windowState` (see trackMaximizeState).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-title-bar',
|
||||
host: {
|
||||
class: 'title-bar electrobun-webkit-app-region-drag',
|
||||
// Published so the FRAME can react: a maximized Win7 window has square
|
||||
// corners. The window root reads this off its own child via :has() rather
|
||||
// than subscribing to windowState a second time — see styles.css.
|
||||
'[class.is-maximized]': 'maximized()',
|
||||
'(dblclick)': 'toggle()',
|
||||
},
|
||||
template: `
|
||||
<div class="title-bar-text">{{ title() }}</div>
|
||||
<div class="title-bar-controls electrobun-webkit-app-region-no-drag">
|
||||
@if (resizable()) {
|
||||
<button aria-label="Minimize" (click)="minimize()"></button>
|
||||
<button
|
||||
[attr.aria-label]="maximized() ? 'Restore' : 'Maximize'"
|
||||
(click)="toggle()"
|
||||
></button>
|
||||
}
|
||||
<button aria-label="Close" (click)="closeRequested.emit()"></button>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class TitleBar {
|
||||
readonly title = input('');
|
||||
/** The manager window is a fixed-size modal — it gets Close only. */
|
||||
readonly resizable = input(true);
|
||||
/** Hosts decide what closing means (quit / dirty prompt / managerClose). */
|
||||
readonly closeRequested = output<void>();
|
||||
|
||||
protected readonly maximized = signal(false);
|
||||
|
||||
private readonly rpc = inject(RpcService);
|
||||
|
||||
constructor() {
|
||||
if (!this.rpc.available) return;
|
||||
// No maximize event exists, so seed from a one-shot read and then follow
|
||||
// the resize-derived push (which also covers Aero Snap and Win+Up).
|
||||
void this.rpc.request.windowIsMaximized({}).then((res) => {
|
||||
if (res.ok) this.maximized.set(res.maximized);
|
||||
});
|
||||
this.rpc.onWindowState((maximized) => this.maximized.set(maximized));
|
||||
}
|
||||
|
||||
protected minimize(): void {
|
||||
void this.rpc.request.windowMinimize({});
|
||||
}
|
||||
|
||||
protected toggle(): void {
|
||||
void this.rpc.request.windowToggleMaximize({}).then((res) => {
|
||||
if (res.ok) this.maximized.set(res.maximized);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, inject } from '@angular/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. */
|
||||
@Component({
|
||||
@@ -0,0 +1,59 @@
|
||||
@if (transfers.expanded() && transfers.hasJobs()) {
|
||||
<div class="flex items-center justify-between border-t border-border bg-chrome px-3 py-1 text-[0.6875rem] text-muted-foreground">
|
||||
<span>
|
||||
{{ i18n.t('transfer.summary', { active: transfers.activeJobs().length, finished: transfers.finishedJobs().length }) }}
|
||||
</span>
|
||||
<button
|
||||
(click)="transfers.clearFinished()"
|
||||
[disabled]="!transfers.finishedJobs().length"
|
||||
class="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
>
|
||||
<ng-icon name="mynaTrash" size="0.75rem" /> {{ i18n.t('transfer.clearFinished') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="has-scrollbar max-h-40 overflow-y-auto border-t border-border bg-chrome">
|
||||
@for (job of transfers.jobs(); track job.id) {
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 text-xs">
|
||||
<ng-icon [name]="stateIcon(job)" size="0.95rem" [class]="stateClass(job)" />
|
||||
<span class="w-40 shrink-0 truncate font-medium" [title]="job.srcPath">{{ job.name }}</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-[0.6875rem] text-muted-foreground">{{ progressLabel(job) }}</div>
|
||||
@if (isActive(job)) {
|
||||
<!-- role+aria are 7.css's hook for the Win7 progress bar
|
||||
([role=progressbar] > div); under the modern skin they are
|
||||
inert and the Tailwind classes below still draw the bar. -->
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
[attr.aria-valuenow]="jobPercent(job)"
|
||||
class="mt-0.5 h-1 overflow-hidden rounded-full bg-muted"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full bg-primary transition-[width] duration-100"
|
||||
[style.width.%]="jobPercent(job)"
|
||||
></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (isActive(job)) {
|
||||
<button
|
||||
(click)="transfers.cancel(job.id)"
|
||||
[title]="i18n.t('transfer.cancel')"
|
||||
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-destructive"
|
||||
>
|
||||
<ng-icon name="mynaX" size="0.9rem" />
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
(click)="transfers.dismiss(job.id)"
|
||||
[title]="i18n.t('transfer.dismiss')"
|
||||
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaX" size="0.9rem" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import type { TransferSnapshot } from '@shared/types';
|
||||
import { formatEta } from '../format';
|
||||
import { I18nService } from '../i18n.service';
|
||||
import { SettingsService } from '../settings.service';
|
||||
import { TransfersService } from '../transfers.service';
|
||||
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',
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Frame arithmetic for the synthesized window resize. Deliberately a separate,
|
||||
* DEPENDENCY-FREE module: `scripts/resize-math-probe.ts` imports it directly to
|
||||
* assert all eight edges without booting Angular or opening a window, which is
|
||||
* the only automated coverage this feature can have (pointer drags cannot be
|
||||
* synthesized headlessly on this machine).
|
||||
*
|
||||
* Part of the resize stopgap — see the removal checklist in window-resize.ts.
|
||||
*/
|
||||
|
||||
export interface Frame {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Compass point of the grabbed edge or corner. */
|
||||
export type ResizeEdge = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
|
||||
|
||||
/**
|
||||
* Every edge the arithmetic supports — NOT what the UI mounts. The window
|
||||
* currently offers only the bottom-right grip (`window-resize.ts` explains why
|
||||
* the others were removed: an invisible handle over a caption button steals its
|
||||
* hover). This stays complete because the origin-moving cases are the subtle
|
||||
* ones and the probe is where they are proven; restoring a side is then one
|
||||
* entry in the component, against already-tested math.
|
||||
*/
|
||||
export const RESIZE_EDGES: readonly ResizeEdge[] = ['n', 's', 'w', 'e', 'nw', 'ne', 'sw', 'se'];
|
||||
|
||||
/**
|
||||
* Floor for the synthesized resize. electrobun has no min-size window option,
|
||||
* so this is enforced here or not at all. Chosen to keep both panels usable
|
||||
* with their toolbars intact; the interface scale can still make the CONTENT
|
||||
* too big for it, which is a separate (pre-existing) problem.
|
||||
*/
|
||||
export const MIN_WIDTH = 640;
|
||||
export const MIN_HEIGHT = 420;
|
||||
|
||||
/**
|
||||
* The frame a drag should produce.
|
||||
*
|
||||
* `dx`/`dy` are SCREEN-space deltas from where the drag began (see the note on
|
||||
* screenX in window-resize.ts). North and west edges move the window's origin
|
||||
* as well as its size — that is the whole reason this returns a full frame
|
||||
* rather than a size, and why `setSize` alone would be wrong.
|
||||
*
|
||||
* The clamp holds the ANCHORED edge still: dragging the west edge east past the
|
||||
* minimum must park `x` where the window is exactly MIN_WIDTH wide, not let it
|
||||
* keep travelling while the width sits pinned — that would walk the window
|
||||
* across the screen while it appears frozen.
|
||||
*
|
||||
* Results are rounded: fractional frames make the native setter jitter by a
|
||||
* pixel between ticks.
|
||||
*/
|
||||
export function resizeFrame(
|
||||
edge: ResizeEdge,
|
||||
start: Frame,
|
||||
dx: number,
|
||||
dy: number,
|
||||
min: { width: number; height: number } = { width: MIN_WIDTH, height: MIN_HEIGHT },
|
||||
): Frame {
|
||||
let { x, y, width, height } = start;
|
||||
|
||||
if (edge.includes('e')) width = start.width + dx;
|
||||
if (edge.includes('w')) {
|
||||
width = start.width - dx;
|
||||
x = start.x + dx;
|
||||
}
|
||||
if (edge.includes('s')) height = start.height + dy;
|
||||
if (edge.includes('n')) {
|
||||
height = start.height - dy;
|
||||
y = start.y + dy;
|
||||
}
|
||||
|
||||
if (width < min.width) {
|
||||
// west drag: the east edge is the anchor, so x is derived from it
|
||||
if (edge.includes('w')) x = start.x + start.width - min.width;
|
||||
width = min.width;
|
||||
}
|
||||
if (height < min.height) {
|
||||
if (edge.includes('n')) y = start.y + start.height - min.height;
|
||||
height = min.height;
|
||||
}
|
||||
|
||||
return {
|
||||
x: Math.round(x),
|
||||
y: Math.round(y),
|
||||
width: Math.round(width),
|
||||
height: Math.round(height),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RpcService } from '@core/rpc.service';
|
||||
import { type Frame, type ResizeEdge, resizeFrame } from './resize-math';
|
||||
|
||||
/* ============================================================================
|
||||
* ⚠️ STOPGAP — DELETE THIS WHOLE FEATURE WHEN ELECTROBUN SHIPS WINDOW RESIZE
|
||||
* ============================================================================
|
||||
*
|
||||
* WHY IT EXISTS
|
||||
*
|
||||
* The win7 skin is frameless: `chromeOptions()` (src/bun/windows.ts) passes
|
||||
* `titleBarStyle: "hidden"` so 7.css can draw the Aero caption itself, and on
|
||||
* Linux the GTK wrapper honours that with `gtk_window_set_decorated(false)`.
|
||||
* Undecorating the window also removes the window manager's resize borders and
|
||||
* corner grips — so all three win7 windows became completely unresizable, on
|
||||
* every platform. (The modern skin keeps the native frame and is unaffected;
|
||||
* it must never mount this component.)
|
||||
*
|
||||
* Electrobun 1.18.1 offers nothing to re-enable. Verified against the SHIPPED
|
||||
* binary rather than assumed:
|
||||
*
|
||||
* - libNativeWrapper.so exports `startWindowMove` / `stopWindowMove` and no
|
||||
* resize counterpart whatsoever. Its only `resize*` exports are
|
||||
* `resizeWebview` / `resizeAutoSizingWebviewsInWindow`, internal webview
|
||||
* plumbing unrelated to the window frame.
|
||||
* - It imports `gtk_window_begin_move_drag` but NOT
|
||||
* `gtk_window_begin_resize_drag`, and not `gtk_window_set_resizable`.
|
||||
* - `dist/api/bun/preload/dragRegions.ts` implements moving only: there is no
|
||||
* `app-region: resize`, no per-edge variant.
|
||||
* - `BrowserWindow` has no `setResizable`, and its constructor options carry
|
||||
* no `resizable` / `minWidth` / `minHeight`.
|
||||
*
|
||||
* The window is still PROGRAMMATICALLY resizable — nothing ever calls
|
||||
* `gtk_window_set_resizable(false)`, and `setFrame`/`getFrame` work. So only
|
||||
* the input affordance is missing, and that is exactly what this synthesizes:
|
||||
* a bottom-right grip → pointer drag → `windowSetFrame` RPC →
|
||||
* `BrowserWindow.setFrame`. One corner, not eight handles — see GRIP_BOX for
|
||||
* why the others had to go.
|
||||
*
|
||||
* WHAT IT COSTS (i.e. why it should die as soon as it can)
|
||||
*
|
||||
* Every drag tick is an RPC hop plus a synchronous FFI call, where a real WM
|
||||
* grab is a compositor-side operation — so it is jerkier than a native resize
|
||||
* and always will be. Minimum size is enforced in JS because electrobun has no
|
||||
* min-size option. The grip occupies real estate INSIDE the window that a
|
||||
* native frame would have provided outside it — which is also why there is only
|
||||
* one of them: an invisible handle over a control steals that control's hover
|
||||
* (see GRIP_BOX). A real WM resize border has neither problem.
|
||||
*
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* REMOVAL CHECKLIST — when upstream ships a resize gesture, delete ALL of:
|
||||
*
|
||||
* 1. this directory (window-resize.ts + resize-math.ts)
|
||||
* 2. its three mount points: `<app-window-resize />` and the `WindowResize`
|
||||
* entry in `imports:` for skins/7like/{app, connection-manager/
|
||||
* connection-manager, editor-window/editor-window}
|
||||
* 3. the `.w7-resize` rule in skins/7like/7like.css (@layer skinfix)
|
||||
* 4. `windowGetFrame` / `windowSetFrame` in src/shared/rpc.ts
|
||||
* 5. their handlers in `windowControlHandlers` (src/bun/rpc-handlers.ts)
|
||||
* 6. scripts/check-electrobun-resize.ts and scripts/resize-math-probe.ts
|
||||
* 7. the check-script call in package.json's `postinstall`
|
||||
* 8. the resize paragraphs in docs/{gotchas,platform-notes,architecture}.md
|
||||
*
|
||||
* Then adopt whatever upstream provides — most likely a `no-drag`-style CSS
|
||||
* class or a `startWindowResize(edge)` preload call, which needs handles for
|
||||
* hit-testing but none of the frame arithmetic below.
|
||||
*
|
||||
* HOW WE FIND OUT: `scripts/check-electrobun-resize.ts` inspects the installed
|
||||
* electrobun for exactly those symbols and prints this checklist when they
|
||||
* appear. It runs from `postinstall`, so an electrobun bump surfaces it without
|
||||
* anyone remembering to look.
|
||||
* ============================================================================
|
||||
*
|
||||
* IMPLEMENTATION NOTES
|
||||
*
|
||||
* - `electrobun-webkit-app-region-no-drag` on every handle is LOAD-BEARING, not
|
||||
* decoration. `dragRegions.ts` starts a window MOVE from a `mousedown`
|
||||
* listener on `document` in the bubble phase; it never checks
|
||||
* `defaultPrevented`, so `preventDefault()` does not stop it, and a
|
||||
* `pointerdown` handler does not either (the compatibility `mousedown` still
|
||||
* fires afterwards, and stopping a POINTER event does nothing to a MOUSE
|
||||
* event). `isAppRegionDrag` tests for the no-drag class first and
|
||||
* short-circuits, so the class is the only reliable suppressor. Without it
|
||||
* every resize drag would also drag the window.
|
||||
* - Deltas come from `screenX/screenY`, never `clientX/clientY`. Resizing from
|
||||
* the north or west edge moves the window's origin, which moves the webview's
|
||||
* client origin under a stationary pointer — client coords would feed that
|
||||
* movement back into the next delta and the window would run away.
|
||||
* - The frame is read once per drag, asynchronously, but the pointer origin is
|
||||
* recorded synchronously in the same handler: a move that arrives before the
|
||||
* frame does is simply ignored, and no delta is measured from a stale origin.
|
||||
* - At most one setFrame is in flight, with the newest target coalesced behind
|
||||
* it. Firing one RPC per pointermove would queue faster than it drains and
|
||||
* the window would keep resizing after the mouse stopped.
|
||||
* - Handles are hidden when maximized by CSS alone (7like.css reads the title
|
||||
* bar's own `is-maximized` through `:has()`), so this component never
|
||||
* subscribes to `windowState`. That matters: RpcService's push registries
|
||||
* have no `off()`, so every subscription is permanent.
|
||||
*/
|
||||
|
||||
/**
|
||||
* ONE handle: the bottom-right grip, exactly where Win7 put its resize gripper.
|
||||
*
|
||||
* A full eight-handle set was tried and reverted. The handles are invisible and
|
||||
* sit ON TOP of the frame (the overlay is above the title bar in paint order),
|
||||
* so the north strip ran the full width across the caption buttons and the
|
||||
* north-east square sat on the close button's own top-right corner. Crossing
|
||||
* one stole `:hover` from the button underneath, and since 7.css cross-fades
|
||||
* the caption glow, a stolen hover reads as the glow flickering or sticking —
|
||||
* "weird artifacts on the buttons", with no visual bug anywhere in the CSS.
|
||||
*
|
||||
* The bottom-right corner is the one place that overlaps nothing: it lands in
|
||||
* the 6px gutter that `.window-body { margin: 0 6px 6px }` leaves below and
|
||||
* right of the content. `resizeFrame` stays general (and the probe still covers
|
||||
* all eight edges) so restoring a side is one entry here — but any new handle
|
||||
* must be checked against what it covers first. South, east and west are the
|
||||
* innocent ones; north and both top corners are not.
|
||||
*
|
||||
* 1rem, not the 0.75rem the corners had: this is now the only way to resize the
|
||||
* window, so the target earns the extra pixels. Rem, so it scales with the
|
||||
* interface scale like everything else.
|
||||
*/
|
||||
const GRIP_EDGE: ResizeEdge = 'se';
|
||||
const GRIP_BOX = 'right-0 bottom-0 size-4 cursor-nwse-resize';
|
||||
|
||||
@Component({
|
||||
selector: 'app-window-resize',
|
||||
template: `
|
||||
<div
|
||||
class="pointer-events-auto absolute touch-none electrobun-webkit-app-region-no-drag {{ gripBox }}"
|
||||
(pointerdown)="onPointerDown($event)"
|
||||
(pointermove)="onPointerMove($event)"
|
||||
(pointerup)="onPointerEnd()"
|
||||
(pointercancel)="onPointerEnd()"
|
||||
></div>
|
||||
`,
|
||||
// The overlay spans the frame but is transparent to the pointer; only the
|
||||
// handles inside it take hits. `w7-resize` is what the maximized rule hides.
|
||||
host: { class: 'w7-resize pointer-events-none absolute inset-0 z-10' },
|
||||
})
|
||||
export class WindowResize {
|
||||
protected readonly gripBox = GRIP_BOX;
|
||||
|
||||
private readonly rpc = inject(RpcService);
|
||||
|
||||
/** Null except during a drag. Set only once the frame read resolves. */
|
||||
private drag: { screenX: number; screenY: number; frame: Frame } | null = null;
|
||||
/** Newest target frame not yet sent, if a send is already in flight. */
|
||||
private pending: Frame | null = null;
|
||||
private sending = false;
|
||||
|
||||
onPointerDown(event: PointerEvent): void {
|
||||
if (!this.rpc.available || event.button !== 0) return;
|
||||
(event.target as HTMLElement).setPointerCapture(event.pointerId);
|
||||
// captured synchronously — the frame read below is a round trip
|
||||
const screenX = event.screenX;
|
||||
const screenY = event.screenY;
|
||||
void this.rpc.request.windowGetFrame({}).then((res) => {
|
||||
if (!res.ok) return;
|
||||
this.drag = {
|
||||
screenX,
|
||||
screenY,
|
||||
frame: { x: res.x, y: res.y, width: res.width, height: res.height },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
onPointerMove(event: PointerEvent): void {
|
||||
const drag = this.drag;
|
||||
if (!drag) return; // moved before the frame arrived, or not dragging
|
||||
this.pending = resizeFrame(
|
||||
GRIP_EDGE,
|
||||
drag.frame,
|
||||
event.screenX - drag.screenX,
|
||||
event.screenY - drag.screenY,
|
||||
);
|
||||
void this.flush();
|
||||
}
|
||||
|
||||
onPointerEnd(): void {
|
||||
this.drag = null; // capture releases itself on pointerup/cancel
|
||||
}
|
||||
|
||||
/** Drain `pending`, one request at a time. Re-entrant calls return
|
||||
* immediately; the running loop picks up whatever they left behind. */
|
||||
private async flush(): Promise<void> {
|
||||
if (this.sending) return;
|
||||
this.sending = true;
|
||||
try {
|
||||
while (this.pending) {
|
||||
const frame = this.pending;
|
||||
this.pending = null;
|
||||
await this.rpc.request.windowSetFrame(frame);
|
||||
}
|
||||
} finally {
|
||||
this.sending = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,5 +53,6 @@
|
||||
<app-overwrite-dialog [request]="req" />
|
||||
}
|
||||
|
||||
<app-context-menu />
|
||||
<app-toasts />
|
||||
</div>
|
||||
@@ -1,27 +1,29 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { I18nService } from './i18n.service';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { BRANDING } from '@shared/branding';
|
||||
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 { ConfirmDialog } from './dialogs/confirm-dialog';
|
||||
import { OverwriteDialog } from './dialogs/overwrite-dialog';
|
||||
import { PromptDialog } from './dialogs/prompt-dialog';
|
||||
import { DialogsService } from './dialogs.service';
|
||||
import { FileActionsService } from './file-actions.service';
|
||||
import { DialogsService } from '@core/dialogs.service';
|
||||
import { FileActionsService } from '@core/file-actions.service';
|
||||
import { FilePanel } from './file-panel/file-panel';
|
||||
import { PanelsService } from './panels.service';
|
||||
import { blockNativeContextMenu } from './platform';
|
||||
import { RpcService } from './rpc.service';
|
||||
import { SettingsService } from './settings.service';
|
||||
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 { ToastService } from './toast.service';
|
||||
import { ToastService } from '@core/toast.service';
|
||||
import { Toasts } from './toasts/toasts';
|
||||
import { TransferBar } from './transfer-bar/transfer-bar';
|
||||
import { TransfersService } from './transfers.service';
|
||||
import { UpdaterService } from './updater.service';
|
||||
import { TransfersService } from '@core/transfers.service';
|
||||
import { UpdaterService } from '@core/updater.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -36,6 +38,7 @@ import { UpdaterService } from './updater.service';
|
||||
OverwriteDialog,
|
||||
Toasts,
|
||||
TransferBar,
|
||||
ContextMenu,
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
host: {
|
||||
@@ -53,6 +56,7 @@ export class App {
|
||||
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. */
|
||||
@@ -117,6 +121,10 @@ export class App {
|
||||
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);
|
||||
@@ -152,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 {
|
||||
if (this.managerOpen()) return; // modal-blocked behind the manager
|
||||
const mod = event.metaKey || event.ctrlKey;
|
||||
if (mod && event.key.toLowerCase() === 't') {
|
||||
event.preventDefault();
|
||||
this.panels.newWorkspace();
|
||||
} else if (mod && event.key.toLowerCase() === 'w') {
|
||||
event.preventDefault();
|
||||
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
|
||||
} else if (event.ctrlKey && event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
this.panels.cycleWorkspace(event.shiftKey ? -1 : 1);
|
||||
} else if (mod && !event.shiftKey && !event.altKey) {
|
||||
// ⌘1–⌘8 jump to that tab, ⌘9 to the last (Ctrl+digit elsewhere), like
|
||||
// Firefox. Matched on event.code so the digit row works on any layout.
|
||||
const digit = /^(?:Digit|Numpad)([1-9])$/.exec(event.code)?.[1];
|
||||
if (!digit) return;
|
||||
event.preventDefault();
|
||||
this.panels.goToWorkspace(digit === '9' ? -1 : Number(digit) - 1);
|
||||
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;
|
||||
+9
-9
@@ -16,7 +16,7 @@
|
||||
(click)="importWinScp()"
|
||||
[disabled]="importing()"
|
||||
[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()) {
|
||||
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
|
||||
@@ -71,14 +71,14 @@
|
||||
size="0.8rem"
|
||||
/>
|
||||
</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 }}
|
||||
</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-[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 }}
|
||||
</span>
|
||||
@@ -123,7 +123,7 @@
|
||||
<span class="truncate">{{ connection.name }}</span>
|
||||
</button>
|
||||
} @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>
|
||||
@@ -141,7 +141,7 @@
|
||||
[class.ring-primary]="dropTarget() === 'loose'"
|
||||
>
|
||||
@if (store.grouped().stacks.length && store.grouped().loose.length) {
|
||||
<div class="px-2 pt-2 pb-0.5 text-[11px] font-medium text-muted-foreground uppercase">
|
||||
<div class="px-2 pt-2 pb-0.5 text-[0.6875rem] font-medium text-muted-foreground uppercase">
|
||||
{{ i18n.t('connMgr.connectionsHeader') }}
|
||||
</div>
|
||||
}
|
||||
@@ -162,10 +162,10 @@
|
||||
</div>
|
||||
|
||||
@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') }}
|
||||
</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') }}
|
||||
</p>
|
||||
}
|
||||
@@ -293,7 +293,7 @@
|
||||
<button
|
||||
type="button"
|
||||
(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') }}
|
||||
</button>
|
||||
@@ -320,7 +320,7 @@
|
||||
spellcheck="false"
|
||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<span class="text-[10px] text-muted-foreground/80">
|
||||
<span class="text-[0.625rem] text-muted-foreground/80">
|
||||
{{ i18n.t('connMgr.localDirHint') }}
|
||||
</span>
|
||||
</label>
|
||||
+19
-6
@@ -1,14 +1,15 @@
|
||||
import { Component, computed, inject, signal } from '@angular/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 { ConnectionsService } from '../connections.service';
|
||||
import { ConnectionsService } from '@core/connections.service';
|
||||
import { ConfirmDialog } from '../dialogs/confirm-dialog';
|
||||
import { PromptDialog } from '../dialogs/prompt-dialog';
|
||||
import { DialogsService } from '../dialogs.service';
|
||||
import { blockNativeContextMenu, IS_MAC_CHROME } from '../platform';
|
||||
import { RpcService } from '../rpc.service';
|
||||
import { SettingsService } from '../settings.service';
|
||||
import { DialogsService } from '@core/dialogs.service';
|
||||
import { blockNativeContextMenu, IS_MAC_CHROME } from '@core/platform';
|
||||
import { RpcService } from '@core/rpc.service';
|
||||
import { SettingsService } from '@core/settings.service';
|
||||
import { ShortcutsService } from '@core/shortcuts.service';
|
||||
import { Toasts } from '../toasts/toasts';
|
||||
|
||||
interface Draft {
|
||||
@@ -76,6 +77,7 @@ function fromDraft(d: Draft): SavedConnection {
|
||||
templateUrl: './connection-manager.html',
|
||||
host: {
|
||||
'(window:keydown.escape)': 'onEscape()',
|
||||
'(window:keydown)': 'onWindowKeydown($event)',
|
||||
'(window:contextmenu)': 'blockContextMenu($event)',
|
||||
},
|
||||
})
|
||||
@@ -88,6 +90,7 @@ export class ConnectionManager {
|
||||
// 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);
|
||||
@@ -143,6 +146,16 @@ export class ConnectionManager {
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { Component, computed, inject, input, signal } from '@angular/core';
|
||||
import { I18nService } from '../i18n.service';
|
||||
import { DialogsService, type ChmodRequest } from '../dialogs.service';
|
||||
import { FocusService } from '../focus.service';
|
||||
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;
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core';
|
||||
import { I18nService } from '../i18n.service';
|
||||
import { DialogsService, type ConfirmRequest } from '../dialogs.service';
|
||||
import { FocusService } from '../focus.service';
|
||||
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',
|
||||
+7
-7
@@ -8,9 +8,9 @@ import {
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { I18nService } from '../i18n.service';
|
||||
import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '../dialogs.service';
|
||||
import { FocusService } from '../focus.service';
|
||||
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',
|
||||
@@ -30,7 +30,7 @@ import { FocusService } from '../focus.service';
|
||||
</h2>
|
||||
@if (request().total > 1) {
|
||||
<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 }) }}
|
||||
</span>
|
||||
@@ -49,11 +49,11 @@ import { FocusService } from '../focus.service';
|
||||
@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-[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) }}
|
||||
</span>
|
||||
@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') }}
|
||||
</span>
|
||||
}
|
||||
@@ -68,7 +68,7 @@ import { FocusService } from '../focus.service';
|
||||
{{ isDir() ? '—' : i18n.size(side.entry.size) }}
|
||||
</span>
|
||||
</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) }}
|
||||
</div>
|
||||
</div>
|
||||
+3
-3
@@ -7,9 +7,9 @@ import {
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { I18nService } from '../i18n.service';
|
||||
import { DialogsService, type PromptRequest } from '../dialogs.service';
|
||||
import { FocusService } from '../focus.service';
|
||||
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',
|
||||
+7
-7
@@ -24,12 +24,12 @@
|
||||
<h1 class="min-w-0 truncate font-mono text-sm font-semibold">
|
||||
{{ ctx.name }}{{ dirty() ? ' •' : '' }}
|
||||
</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>
|
||||
<button
|
||||
(click)="toggleWrap()"
|
||||
[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.text-foreground]="wrap()"
|
||||
[class.text-muted-foreground]="!wrap()"
|
||||
@@ -58,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"
|
||||
/>
|
||||
<span
|
||||
class="text-[11px] tabular-nums"
|
||||
class="text-[0.6875rem] tabular-nums"
|
||||
[class.text-destructive]="findQuery() && !matches().length"
|
||||
[class.text-muted-foreground]="!findQuery() || matches().length"
|
||||
>
|
||||
@@ -91,7 +91,7 @@
|
||||
|
||||
@if (sessionDead()) {
|
||||
<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" />
|
||||
{{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }}
|
||||
@@ -141,9 +141,9 @@
|
||||
</div>
|
||||
|
||||
<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-[11px] text-muted-foreground">·</span>
|
||||
<span class="text-[11px] tabular-nums text-muted-foreground">
|
||||
<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>
|
||||
+58
-25
@@ -10,11 +10,12 @@ import {
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { I18nService } from '../i18n.service';
|
||||
import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME, MOD_LABEL } from '../platform';
|
||||
import { RpcService } from '../rpc.service';
|
||||
import { SettingsService } from '../settings.service';
|
||||
import { ToastService } from '../toast.service';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME, 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 { Toasts } from '../toasts/toasts';
|
||||
|
||||
/** Debounce for mirroring the draft to bun (the traffic-light-close safety net). */
|
||||
@@ -94,12 +95,14 @@ export class EditorWindow {
|
||||
private readonly gutter = viewChild<ElementRef<HTMLElement>>('gutter');
|
||||
private readonly 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;
|
||||
@@ -214,7 +217,11 @@ export class EditorWindow {
|
||||
el.setSelectionRange(start, start + this.findQuery().length);
|
||||
|
||||
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 i = text.indexOf('\n');
|
||||
while (i !== -1 && i < start) {
|
||||
@@ -229,12 +236,17 @@ export class EditorWindow {
|
||||
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 {
|
||||
if (!this.cachedCharWidth) {
|
||||
const font = getComputedStyle(el).font;
|
||||
if (font !== this.cachedCharFont) {
|
||||
const canvas = this.document.createElement('canvas').getContext('2d')!;
|
||||
canvas.font = getComputedStyle(el).font;
|
||||
this.cachedCharWidth = canvas.measureText('0').width || 7;
|
||||
canvas.font = font;
|
||||
this.cachedCharWidth = canvas.measureText('0').width || parseFloat(getComputedStyle(el).fontSize) * 0.6;
|
||||
this.cachedCharFont = font;
|
||||
}
|
||||
return this.cachedCharWidth;
|
||||
}
|
||||
@@ -307,22 +319,43 @@ export class EditorWindow {
|
||||
// ---- keyboard ----
|
||||
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
const mod = event.metaKey || event.ctrlKey;
|
||||
if (mod && event.key.toLowerCase() === 's') {
|
||||
// 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();
|
||||
void this.save();
|
||||
} else if (mod && event.key.toLowerCase() === 'f') {
|
||||
event.preventDefault();
|
||||
this.openFind();
|
||||
} else if (mod && event.key.toLowerCase() === 'w') {
|
||||
// reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W and
|
||||
// bun routes it back as editorCloseRequest
|
||||
event.preventDefault();
|
||||
this.requestClose();
|
||||
} else if (event.altKey && event.code === 'KeyZ') {
|
||||
event.preventDefault();
|
||||
this.toggleWrap();
|
||||
} else if (event.key === 'Escape' && this.closePrompt()) {
|
||||
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()) {
|
||||
+4
-4
@@ -121,7 +121,7 @@
|
||||
</button>
|
||||
}
|
||||
@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 }}
|
||||
</div>
|
||||
@for (connection of group.connections; track connection.id) {
|
||||
@@ -137,7 +137,7 @@
|
||||
|
||||
@if (connections.sshHosts().length) {
|
||||
<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
|
||||
</div>
|
||||
@for (host of connections.sshHosts(); track host.name) {
|
||||
@@ -195,7 +195,7 @@
|
||||
<div
|
||||
(contextmenu)="headerContextMenu($event)"
|
||||
[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')">
|
||||
{{ 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">
|
||||
<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-[11px] text-muted-foreground">
|
||||
<span class="shrink-0 text-[0.6875rem] text-muted-foreground">
|
||||
{{ i18n.t('panel.matches', { count: filterMatches() }) }}
|
||||
</span>
|
||||
<button
|
||||
+20
-16
@@ -9,18 +9,20 @@ import {
|
||||
viewChild,
|
||||
} from '@angular/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 { ConnectionsService } from '../connections.service';
|
||||
import { DialogsService } from '../dialogs.service';
|
||||
import { FileActionsService } from '../file-actions.service';
|
||||
import { fileGlyph, fileIconClass, fileKindKey } from '../file-icons';
|
||||
import { formatMode } from '../format';
|
||||
import { PanelsService, type Side, type SortKey } from '../panels.service';
|
||||
import { IS_WINDOWS } from '../platform';
|
||||
import { RpcService } from '../rpc.service';
|
||||
import { SettingsService } from '../settings.service';
|
||||
import { TransfersService } from '../transfers.service';
|
||||
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 }[] = [
|
||||
@@ -47,7 +49,9 @@ export class FilePanel {
|
||||
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());
|
||||
@@ -88,7 +92,7 @@ export class FilePanel {
|
||||
|
||||
headerContextMenu(event: MouseEvent): void {
|
||||
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 {
|
||||
@@ -218,7 +222,7 @@ export class FilePanel {
|
||||
const panel = this.panel();
|
||||
if (event.shiftKey) {
|
||||
panel.selectRange(this.lastClickedIndex, index);
|
||||
} else if (event.metaKey || event.ctrlKey) {
|
||||
} else if (this.shortcuts.isMod(event)) {
|
||||
const entry = panel.sorted()[index];
|
||||
if (entry) panel.toggleSelect(entry.name);
|
||||
} else {
|
||||
@@ -238,14 +242,14 @@ export class FilePanel {
|
||||
// standard behavior: keep the selection when right-clicking inside it
|
||||
if (entry && !panel.selected().has(entry.name)) panel.clearSelection();
|
||||
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 {
|
||||
event.preventDefault();
|
||||
if (this.blocked()) return;
|
||||
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 {
|
||||
@@ -264,7 +268,7 @@ export class FilePanel {
|
||||
this.panels.setActiveSide(this.panels.otherSide(this.side()));
|
||||
return;
|
||||
}
|
||||
if ((event.metaKey || event.ctrlKey) && key.toLowerCase() === 'a') {
|
||||
if (this.shortcuts.isMod(event) && key.toLowerCase() === 'a') {
|
||||
event.preventDefault();
|
||||
panel.selectAll();
|
||||
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 { NgIcon } from '@ng-icons/core';
|
||||
import { I18nService } from '../i18n.service';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import type { TransferSnapshot } from '@shared/types';
|
||||
import { formatSpeed } from '../format';
|
||||
import { PanelsService } from '../panels.service';
|
||||
import { SettingsService } from '../settings.service';
|
||||
import { TransfersService } from '../transfers.service';
|
||||
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-[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 -->
|
||||
<button
|
||||
@@ -1,15 +1,17 @@
|
||||
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 { BRANDING } from '@shared/branding';
|
||||
import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
|
||||
import type { AccentTone } from '@shared/types';
|
||||
import { I18nService } from '../i18n.service';
|
||||
import { PanelsService } from '../panels.service';
|
||||
import { IS_MAC_CHROME, MOD_LABEL } from '../platform';
|
||||
import { RpcService } from '../rpc.service';
|
||||
import { SettingsService } from '../settings.service';
|
||||
import { UpdaterService } from '../updater.service';
|
||||
import { ContextMenuService } from '@core/context-menu.service';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { PanelsService, type Workspace } from '@core/panels.service';
|
||||
import { APP_SKIN, IS_MAC_CHROME, 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
|
||||
@@ -29,7 +31,6 @@ const SPEED_UNITS = [
|
||||
{ value: 'MBps', label: 'MB/s' },
|
||||
{ value: 'Mbps', label: 'Mbps' },
|
||||
] as const;
|
||||
const TEXT_SIZES = ['compact', 'default', 'large'] as const;
|
||||
const ICON_COLOR_MODES = ['plain', 'multi'] as const;
|
||||
const TRANSFER_MODES = ['single', 'multi'] as const;
|
||||
const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
@@ -58,6 +59,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
[class.bg-accent]="ws.id === panels.activeWorkspaceId()"
|
||||
[class.opacity-50]="draggedId() === ws.id"
|
||||
[style.box-shadow]="dropIndicatorShadow(i)"
|
||||
(contextmenu)="onTabContextMenu($event, ws, i)"
|
||||
draggable="true"
|
||||
(dragstart)="onDragStart($event, ws.id)"
|
||||
(dragover)="onDragOver($event, i)"
|
||||
@@ -113,7 +115,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
(click)="onUpdateClick()"
|
||||
[disabled]="updater.state() === 'downloading'"
|
||||
[title]="i18n.t('tabBar.updateTooltip', { version: updater.version() })"
|
||||
class="relative mr-1 grid h-6 place-items-center overflow-hidden rounded-md px-2.5 text-[11px] font-medium bg-primary text-primary-foreground electrobun-webkit-app-region-no-drag hover:opacity-90 disabled:cursor-default"
|
||||
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
|
||||
@@ -141,7 +143,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
<button
|
||||
(click)="openManager()"
|
||||
[title]="i18n.t('tabBar.manageConnections')"
|
||||
class="grid h-6 px-2 py-1 place-items-center rounded-md text-[11px] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||
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') }}
|
||||
@@ -167,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>
|
||||
<select
|
||||
(change)="settings.setLocale($any($event.target).value)"
|
||||
class="h-7 rounded-md border border-input bg-background px-1.5 text-[11px] outline-none focus:ring-2 focus:ring-ring"
|
||||
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') }}
|
||||
@@ -180,13 +182,43 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
</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-[11px]"
|
||||
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"
|
||||
@@ -204,7 +236,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
@for (tone of darkTones; track tone) {
|
||||
<button
|
||||
(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.text-foreground]="settings.darkTone() === tone"
|
||||
[class.text-muted-foreground]="settings.darkTone() !== tone"
|
||||
@@ -240,7 +272,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
@for (mode of iconColorModes; track mode) {
|
||||
<button
|
||||
(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.text-foreground]="settings.iconColors() === mode"
|
||||
[class.text-muted-foreground]="settings.iconColors() !== mode"
|
||||
@@ -251,20 +283,35 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
</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 rounded-md border border-border p-0.5">
|
||||
@for (size of textSizes; track size) {
|
||||
<button
|
||||
(click)="settings.setTextSize(size)"
|
||||
class="rounded px-2 py-0.5 text-[11px]"
|
||||
[class.bg-accent]="settings.textSize() === size"
|
||||
[class.text-foreground]="settings.textSize() === size"
|
||||
[class.text-muted-foreground]="settings.textSize() !== size"
|
||||
>
|
||||
{{ i18n.t('options.interface.' + size) }}
|
||||
</button>
|
||||
}
|
||||
<div 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>
|
||||
|
||||
@@ -274,7 +321,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
@for (unit of speedUnits; track unit.value) {
|
||||
<button
|
||||
(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.text-foreground]="settings.speedUnit() === unit.value"
|
||||
[class.text-muted-foreground]="settings.speedUnit() !== unit.value"
|
||||
@@ -291,7 +338,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
@for (mode of transferModes; track mode) {
|
||||
<button
|
||||
(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.text-foreground]="settings.transferMode() === mode"
|
||||
[class.text-muted-foreground]="settings.transferMode() !== mode"
|
||||
@@ -309,7 +356,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
@for (count of concurrencyChoices; track count) {
|
||||
<button
|
||||
(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.text-foreground]="settings.transferConcurrency() === count"
|
||||
[class.text-muted-foreground]="settings.transferConcurrency() !== count"
|
||||
@@ -326,7 +373,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
<button
|
||||
(click)="settings.setEditor('builtin')"
|
||||
class="rounded px-2 py-0.5 text-[11px]"
|
||||
class="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'"
|
||||
@@ -335,7 +382,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
</button>
|
||||
<button
|
||||
(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.text-foreground]="settings.editor() === 'custom'"
|
||||
[class.text-muted-foreground]="settings.editor() !== 'custom'"
|
||||
@@ -353,7 +400,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
(click)="pickEditorApp()"
|
||||
class="rounded-md border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
|
||||
class="rounded-md border border-border px-2 py-0.5 text-[0.6875rem] hover:bg-accent"
|
||||
>
|
||||
{{ i18n.t('common.choose') }}
|
||||
</button>
|
||||
@@ -375,7 +422,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
<button
|
||||
(click)="settings.setAutoClearCompleted(true)"
|
||||
class="rounded px-2 py-0.5 text-[11px]"
|
||||
class="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()"
|
||||
@@ -384,7 +431,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
</button>
|
||||
<button
|
||||
(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.text-foreground]="!settings.autoClearCompleted()"
|
||||
[class.text-muted-foreground]="settings.autoClearCompleted()"
|
||||
@@ -399,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">
|
||||
<span class="text-xs text-muted-foreground">Auto-refresh</span>
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
<button (click)="settings.setAutoRefresh(true)" class="rounded px-2 py-0.5 text-[11px]"
|
||||
<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-[11px]"
|
||||
<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>
|
||||
@@ -414,7 +461,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
<!-- about footer -->
|
||||
<button
|
||||
(click)="showAbout()"
|
||||
class="mt-3 w-full border-t border-border pt-2 text-center text-[11px] text-muted-foreground hover:text-foreground"
|
||||
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>
|
||||
@@ -428,6 +475,7 @@ export class TabBar {
|
||||
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;
|
||||
@@ -436,12 +484,20 @@ export class TabBar {
|
||||
readonly modLabel = MOD_LABEL;
|
||||
readonly appName = BRANDING.appName;
|
||||
readonly buildLabel = BRANDING.buildLabel;
|
||||
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;
|
||||
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 concurrencyChoices = CONCURRENCY_CHOICES;
|
||||
|
||||
@@ -466,6 +522,24 @@ export class TabBar {
|
||||
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. */
|
||||
editorAppName(): string {
|
||||
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';
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
@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>
|
||||
{{ i18n.t('transfer.summary', { active: transfers.activeJobs().length, finished: transfers.finishedJobs().length }) }}
|
||||
</span>
|
||||
@@ -17,7 +17,7 @@
|
||||
<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-[11px] text-muted-foreground">{{ progressLabel(job) }}</div>
|
||||
<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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user