Remove the Windows 7 (7.css) skin
The win7 skin is dropped. Everything else the branch built stays: Linux support and the HTML context menus, the workspace-tab menu, the continuous interface scale (rem everywhere, stepper, Ctrl/Cmd +/-/0) and the core/ service layer. The modern tree already carried all of them, and nothing outside skins/7like/ imported the win7 tree except main.ts and styles.css. Removed: the skins/7like/ tree (5.1k lines, fonts), the 7.css dependency, the skin setting and its options row + strings in all 7 locales, the frameless-window plumbing (window-control RPCs, windowState push, trackMaximizeState, the synthesized resize grip and its postinstall watchdog), the session-skin pinning in buildPreload/chromeOptions, the skin/skinfix cascade layers, and the win7-only probes and screenshot harness (the harness cannot render the modern tree: plain Firefox has no RPC bridge). chromeOptions() stays as the one shared frame decision. Fixed on the way out: - index.html's per-theme pre-paint background is restored. The branch had replaced it with a win7-only transparent rule, so modern dark windows had silently lost one of the three flash guards. - editor-windows.ts wrote its window-key separator as a literal NUL byte, which made git and grep treat the file as binary; it is `\0` now (same string at runtime). No settings migration: a stale "skin" key is carried harmlessly by the store's spread-merge and dropped on the next save. UI bundle: 616.7 kB -> 405.4 kB, back under the 500 kB budget. Docs: architecture, gotchas and platform-notes lose the win7 material but keep what stays true (construction-time preloads/frames, drag regions ignoring preventDefault, electrobun's missing resize/maximize primitives); flash-prevention, dev-URL and Bun-panic notes move back under Electrobun; stale context-menu/tab-bar links fixed; README documents the scale keys. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,3 @@ ui/dist/
|
|||||||
ui/.angular/
|
ui/.angular/
|
||||||
src/shared/version.gen.ts
|
src/shared/version.gen.ts
|
||||||
release/
|
release/
|
||||||
|
|
||||||
# headless screenshot workspace (scripts/shot.ts)
|
|
||||||
.shot/
|
|
||||||
|
|||||||
@@ -181,7 +181,8 @@ Saved connections live in a plain JSON file in the OS app-data directory
|
|||||||
`Space` select · `Enter` open folder · `Backspace` parent ·
|
`Space` select · `Enter` open folder · `Backspace` parent ·
|
||||||
`Ctrl/Cmd+A` select all · `F5` send to other panel · `F2` rename ·
|
`Ctrl/Cmd+A` select all · `F5` send to other panel · `F2` rename ·
|
||||||
`F7` new folder · `F8`/`Delete` delete · `Cmd/Ctrl+T` new tab ·
|
`F7` new folder · `F8`/`Delete` delete · `Cmd/Ctrl+T` new tab ·
|
||||||
`Cmd/Ctrl+W` close tab · `Ctrl+Tab` next tab.
|
`Cmd/Ctrl+W` close tab · `Ctrl+Tab` next tab ·
|
||||||
|
`Cmd/Ctrl+=`/`-`/`0` interface bigger / smaller / 100%.
|
||||||
|
|
||||||
(On Macs, function keys may need `fn` — every action also has a toolbar button.)
|
(On Macs, function keys may need `fn` — every action also has a toolbar button.)
|
||||||
|
|
||||||
@@ -220,7 +221,7 @@ application menu ([src/bun/menu.ts](src/bun/menu.ts)) is macOS-only for now;
|
|||||||
the in-app UI covers every menu action.
|
the in-app UI covers every menu action.
|
||||||
|
|
||||||
Right-click menus are **HTML, on every platform**
|
Right-click menus are **HTML, on every platform**
|
||||||
([ui/src/app/context-menu.ts](ui/src/app/context-menu.ts)) rather than native —
|
([ui/src/app/core/context-menu.service.ts](ui/src/app/core/context-menu.service.ts)) rather than native —
|
||||||
electrobun's native context menu is a documented no-op on Linux, and one menu
|
electrobun's native context menu is a documented no-op on Linux, and one menu
|
||||||
implementation beats two that drift. Only reveal / open-with-default / copy-path
|
implementation beats two that drift. Only reveal / open-with-default / copy-path
|
||||||
cross to the backend, through the `shellItemAction` RPC.
|
cross to the backend, through the `shellItemAction` RPC.
|
||||||
|
|||||||
@@ -21,7 +21,6 @@
|
|||||||
"name": "ui",
|
"name": "ui",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7.css": "^0.21.1",
|
|
||||||
"@angular/common": "^22.0.0",
|
"@angular/common": "^22.0.0",
|
||||||
"@angular/compiler": "^22.0.0",
|
"@angular/compiler": "^22.0.0",
|
||||||
"@angular/core": "^22.0.0",
|
"@angular/core": "^22.0.0",
|
||||||
@@ -46,8 +45,6 @@
|
|||||||
"@parcel/watcher",
|
"@parcel/watcher",
|
||||||
],
|
],
|
||||||
"packages": {
|
"packages": {
|
||||||
"7.css": ["7.css@0.21.1", "", {}, "sha512-Q+/aFUpGemoR/Zltj4a7yvHonyxfb+9cgiwBL+j/Lm+H28EOoLzWoJki7MdGPV+3mLCvKSCVqr3M2x2uNYyeWw=="],
|
|
||||||
|
|
||||||
"@algolia/abtesting": ["@algolia/abtesting@1.18.0", "", { "dependencies": { "@algolia/client-common": "5.52.0", "@algolia/requester-browser-xhr": "5.52.0", "@algolia/requester-fetch": "5.52.0", "@algolia/requester-node-http": "5.52.0" } }, "sha512-8siuLG+FIns1AjZ/g2SDVwHz9S+ObacDQISEJvS8XsNei1zl3FXqfqQrBpmrG7ACWCyesXHbicMJtvRbg00FEw=="],
|
"@algolia/abtesting": ["@algolia/abtesting@1.18.0", "", { "dependencies": { "@algolia/client-common": "5.52.0", "@algolia/requester-browser-xhr": "5.52.0", "@algolia/requester-fetch": "5.52.0", "@algolia/requester-node-http": "5.52.0" } }, "sha512-8siuLG+FIns1AjZ/g2SDVwHz9S+ObacDQISEJvS8XsNei1zl3FXqfqQrBpmrG7ACWCyesXHbicMJtvRbg00FEw=="],
|
||||||
|
|
||||||
"@algolia/client-abtesting": ["@algolia/client-abtesting@5.52.0", "", { "dependencies": { "@algolia/client-common": "5.52.0", "@algolia/requester-browser-xhr": "5.52.0", "@algolia/requester-fetch": "5.52.0", "@algolia/requester-node-http": "5.52.0" } }, "sha512-wtwPgyPmO7b7sQPVgoK29c1VpfS08DnnJCmxX/oU1pV2DlMRJCzQcLN7JSloYpodyKHwM8+9wOzlAM0co3TDmA=="],
|
"@algolia/client-abtesting": ["@algolia/client-abtesting@5.52.0", "", { "dependencies": { "@algolia/client-common": "5.52.0", "@algolia/requester-browser-xhr": "5.52.0", "@algolia/requester-fetch": "5.52.0", "@algolia/requester-node-http": "5.52.0" } }, "sha512-wtwPgyPmO7b7sQPVgoK29c1VpfS08DnnJCmxX/oU1pV2DlMRJCzQcLN7JSloYpodyKHwM8+9wOzlAM0co3TDmA=="],
|
||||||
|
|||||||
+30
-234
@@ -44,17 +44,15 @@ no-op functions, reassigned in `src/bun/index.ts` after everything exists:
|
|||||||
## Window kinds
|
## Window kinds
|
||||||
|
|
||||||
`src/bun/windows.ts` — `buildPreload(mode, settings, ctx?)` injects
|
`src/bun/windows.ts` — `buildPreload(mode, settings, ctx?)` injects
|
||||||
`__APP_PLATFORM`, `__APP_MODE` (`main|editor|manager`), `__APP_SKIN`
|
`__APP_PLATFORM`, `__APP_MODE` (`main|editor|manager`), `__APP_CTX` before any
|
||||||
(`modern|win7`), `__APP_CTX` before any page script. `ui/src/main.ts` selects on
|
page script, plus the persisted theme attributes and interface scale so the
|
||||||
BOTH axes: the skin picks a component tree, `APP_MODE` picks a root within it
|
first paint is already right. `ui/src/main.ts` picks the root component from
|
||||||
(see "UI layout" below). All roots use selector `app-root` (index.html hosts
|
`APP_MODE`; all three roots use selector `app-root` (index.html hosts only that
|
||||||
only that tag). Both globals are preload-baked because both are needed before
|
tag). `chromeOptions()` is the ONLY place the native frame is decided, spread by
|
||||||
first paint — settings arrive over an async RPC, far too late to choose a tree
|
the main window and `openSecondaryWindow`: macOS `hiddenInset` +
|
||||||
without flashing the wrong UI. `chromeOptions(skin)` is the ONLY place the
|
`trafficLightOffset {x:12,y:13}` (the UI renders an `h-10 pl-24` drag-region
|
||||||
native frame is decided: win7 is frameless + transparent everywhere, modern
|
header via `IS_MAC_CHROME`); other platforms keep the native titlebar and a
|
||||||
keeps the native titlebar (macOS `hiddenInset` + `trafficLightOffset
|
compact `h-9` non-drag header.
|
||||||
{x:12,y:13}`, where the shadlike tree renders an `h-10 pl-24` drag-region header
|
|
||||||
via `IS_MAC_CHROME`).
|
|
||||||
|
|
||||||
`WindowRegistry<Rec>` (same file): keyed window map with dedupe/activate,
|
`WindowRegistry<Rec>` (same file): keyed window map with dedupe/activate,
|
||||||
cascade placement off an anchor frame, focus tracking. electrobun's emitter
|
cascade placement off an anchor frame, focus tracking. electrobun's emitter
|
||||||
@@ -62,7 +60,7 @@ has **no `off()`** — `track()` closures capture only `(this, key)` so closed
|
|||||||
records can be garbage collected. A future "window follows window" grouping
|
records can be garbage collected. A future "window follows window" grouping
|
||||||
belongs here (`anchor.on('move')` + `setPosition`).
|
belongs here (`anchor.on('move')` + `setPosition`).
|
||||||
|
|
||||||
### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/skins/*/editor-window/`)
|
### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/skins/shadlike/editor-window/`)
|
||||||
|
|
||||||
One window per file, key `sessionId+path` (sessionId, not fsKey: saves route
|
One window per file, key `sessionId+path` (sessionId, not fsKey: saves route
|
||||||
through a concrete session, and `local` is one shared session anyway).
|
through a concrete session, and `local` is one shared session anyway).
|
||||||
@@ -171,39 +169,23 @@ would float over editor windows).
|
|||||||
rpc; manager/editors constructed after win; quit guard; focus tracking for
|
rpc; manager/editors constructed after win; quit guard; focus tracking for
|
||||||
notification suppression across all windows).
|
notification suppression across all windows).
|
||||||
|
|
||||||
## UI layout: one core, two skin trees
|
## UI layout
|
||||||
|
|
||||||
```
|
```
|
||||||
ui/src/app/
|
ui/src/app/
|
||||||
core/ app.config.ts, icons.ts, platform.ts, format.ts, file-icons.ts,
|
core/ app.config.ts, icons.ts, platform.ts, format.ts, file-icons.ts,
|
||||||
and all 12 *.service.ts — everything skin-agnostic
|
metrics.ts and every *.service.ts — the non-visual layer
|
||||||
skins/
|
skins/shadlike/ the component tree: three window roots (SHAD_ROOTS barrel,
|
||||||
shadlike/ the modern (shadcn-style) tree + shadlike.css
|
picked by main.ts), dialogs, context menu
|
||||||
7like/ the Windows 7 tree + 7like.css + fonts/ + title-bar/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Each skin owns a **complete** component tree, including its own three window
|
Components reach `core/` through the **`@core/*`** tsconfig alias (alongside
|
||||||
roots, and exports a barrel (`SHAD_ROOTS` / `SEVEN_ROOTS`) with the same
|
`@shared/*` → `src/shared`), which is deliberate: components sit at varying
|
||||||
`{ main, manager, editor }` shape. `main.ts` therefore knows nothing about
|
depths and an alias keeps their imports identical wherever a file moves. Do not
|
||||||
either tree's internals: `APP_SKIN` picks the barrel, `APP_MODE` picks the root.
|
replace it with relative paths.
|
||||||
Both trees are imported statically into one bundle — the inactive skin costs
|
|
||||||
bundle size but no runtime work, which keeps bootstrap synchronous.
|
|
||||||
|
|
||||||
Duplicate selectors across trees (`app-file-panel` exists in both) are safe:
|
**Where a new rule goes**: behaviour → `core/`; markup and appearance → the
|
||||||
standalone components resolve through their importer's `imports:` array, never
|
component; tokens → `ui/src/styles.css`.
|
||||||
a global registry, and the two trees are never mounted together.
|
|
||||||
|
|
||||||
Only `7like` has a `title-bar/`. The modern skin defers to the native frame,
|
|
||||||
which is exactly what `IS_MAC_CHROME` accounts for.
|
|
||||||
|
|
||||||
**Why this split was cheap**: the service layer was already skin-agnostic —
|
|
||||||
`git diff main...feat/7css-skin` touched none of it. Components reach it through
|
|
||||||
the **`@core/*`** tsconfig alias (alongside `@shared/*` → `src/shared`), which
|
|
||||||
is deliberate: skin components sit at varying depths and an alias keeps their
|
|
||||||
imports identical wherever a file moves. Do not replace it with relative paths.
|
|
||||||
|
|
||||||
**Where a new rule goes**: shared behaviour → `core/`; markup and appearance for
|
|
||||||
one look → that skin's folder; tokens both skins consume → `ui/src/styles.css`.
|
|
||||||
|
|
||||||
## UI services (`ui/src/app/core/`)
|
## UI services (`ui/src/app/core/`)
|
||||||
|
|
||||||
@@ -235,8 +217,8 @@ one look → that skin's folder; tokens both skins consume → `ui/src/styles.cs
|
|||||||
enqueue and runs the collision flow (same-path auto-skip, type-mismatch
|
enqueue and runs the collision flow (same-path auto-skip, type-mismatch
|
||||||
skip, overwrite dialog with all/none batch modes).
|
skip, overwrite dialog with all/none batch modes).
|
||||||
- `settings.service.ts` — one signal per setting; **imperative apply pattern**
|
- `settings.service.ts` — one signal per setting; **imperative apply pattern**
|
||||||
(`applyTheme/DarkTone/Accent/TextSize` set `data-*` attributes on
|
(`applyTheme/DarkTone/Accent` set `data-*` attributes on `<html>`;
|
||||||
`<html>`); `applyAll` also serves the `settingsChanged` broadcast so editor
|
`applyUiScale` sets its inline `font-size`); `applyAll` also serves the `settingsChanged` broadcast so editor
|
||||||
and manager windows restyle live (they render no settings UI, so no
|
and manager windows restyle live (they render no settings UI, so no
|
||||||
persist echo). New setting = five touches here + types.ts (backend merges
|
persist echo). New setting = five touches here + types.ts (backend merges
|
||||||
automatically).
|
automatically).
|
||||||
@@ -256,7 +238,7 @@ one look → that skin's folder; tokens both skins consume → `ui/src/styles.cs
|
|||||||
boot and on the `settingsChanged` broadcast (so editor/manager windows follow).
|
boot and on the `settingsChanged` broadcast (so editor/manager windows follow).
|
||||||
- `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always
|
- `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always
|
||||||
against an explicit panel; `menuTarget` remembers the right-clicked row.
|
against an explicit panel; `menuTarget` remembers the right-clicked row.
|
||||||
- `context-menu.service.ts` + `context-menu.ts` — the panel and tab right-click
|
- `context-menu.service.ts` + `skins/shadlike/context-menu.ts` — the panel and tab right-click
|
||||||
menus, in HTML. Electrobun's native `showContextMenu` is a no-op on Linux, so it was
|
menus, in HTML. Electrobun's native `showContextMenu` is a no-op on Linux, so it was
|
||||||
retired on ALL platforms rather than kept as a second code path (see
|
retired on ALL platforms rather than kept as a second code path (see
|
||||||
platform-notes.md). The service builds the item list (row / empty-space menu,
|
platform-notes.md). The service builds the item list (row / empty-space menu,
|
||||||
@@ -274,11 +256,10 @@ one look → that skin's folder; tokens both skins consume → `ui/src/styles.cs
|
|||||||
`disconnectWorkspace`. **The target id travels inside the action string**: the
|
`disconnectWorkspace`. **The target id travels inside the action string**: the
|
||||||
right-clicked tab is not necessarily the active one, so nothing on that path
|
right-clicked tab is not necessarily the active one, so nothing on that path
|
||||||
may fall back to `activeWorkspaceId`. Illegal entries are `disabled`, not
|
may fall back to `activeWorkspaceId`. Illegal entries are `disabled`, not
|
||||||
omitted — Win7 greys out, and a menu whose items move between right-clicks is
|
omitted — a menu whose items move between right-clicks is worse than one with
|
||||||
worse than one with a dead row. 7.css hangs that state on
|
a dead row. The disabled styling (`pointer-events: none`) only stops the
|
||||||
`li[role=menuitem][aria-disabled]` (opacity + `pointer-events: none`), which
|
pointer; the keyboard path never consults it — so `pick()` and the arrow-key
|
||||||
the keyboard path never consults — so `pick()` and the arrow-key walker check
|
walker check the flag themselves.
|
||||||
the flag themselves, in both skins.
|
|
||||||
- `file-icons.ts` — extension → category map driving glyphs, colors
|
- `file-icons.ts` — extension → category map driving glyphs, colors
|
||||||
(fixed `--icon-*` tokens, never accent-tinted) and the Kind column.
|
(fixed `--icon-*` tokens, never accent-tinted) and the Kind column.
|
||||||
- `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`,
|
- `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`,
|
||||||
@@ -319,194 +300,9 @@ toolbars deliberately stay on `--background`. Accent tones: one
|
|||||||
pixel-identical to the old neutral hover. **Interface scale** (`settings.uiScale`,
|
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
|
0.5–4 in 0.1 steps): one root font-size lever — `applyUiScale` sets
|
||||||
`html { font-size: 16px * scale }`, and since EVERY size in the app is rem
|
`html { font-size: 16px * scale }`, and since EVERY size in the app is rem
|
||||||
(panel tokens like `--row-h`, all Tailwind utilities, ng-icon sizes, `--w7-fs`
|
(panel tokens like `--row-h`, all Tailwind utilities, ng-icon sizes), the
|
||||||
and the 7.css bridge in 7like.css), both skins scale whole, chrome included.
|
whole UI scales, chrome included.
|
||||||
Driven by the tab-bar stepper, Ctrl/Cmd +/−/0, and preload-baked for a
|
Driven by the 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
|
flash-free first paint. It replaced the 3-tier `data-text` axis; the store
|
||||||
migrates old `textSize` values. TS that measures rem-sized CSS converts through
|
migrates old `textSize` values. TS that measures rem-sized CSS converts through
|
||||||
`@core/metrics` `remPx()` — never an assumed 16.
|
`@core/metrics` `remPx()` — never an assumed 16.
|
||||||
|
|
||||||
### Skins (`settings.skin`: `modern | win7`)
|
|
||||||
|
|
||||||
A fifth theming axis, and the only one that is a **class** (`.win7` on
|
|
||||||
`<html>`) rather than a `data-*` attribute — `7.css/dist/7.scoped.css` is
|
|
||||||
precompiled against a literal `.win7`, so the name is not ours to pick.
|
|
||||||
Default `modern` = class absent, matching the delete-when-default rule.
|
|
||||||
`buildPreload` is the **only** writer of that class, ever — there is
|
|
||||||
deliberately no `SettingsService.applySkin`.
|
|
||||||
|
|
||||||
**Unlike the other four axes, this one is launch-time only.** It selects a
|
|
||||||
whole component tree (see "UI layout") as well as CSS, and neither half can
|
|
||||||
change on a live window: the tree is bootstrapped once from the preload-baked
|
|
||||||
`__APP_SKIN`, and `titleBarStyle`/`transparent` are `BrowserWindow` constructor
|
|
||||||
options with no setters. Clicking a skin therefore only persists it; the
|
|
||||||
options row shows the saved value as pending (`skinPending()` compares
|
|
||||||
`settings.skin()` against `APP_SKIN`) and the running UI is untouched until the
|
|
||||||
next app start.
|
|
||||||
|
|
||||||
The bun side pins this: `initSessionSkin` freezes the skin at startup, before
|
|
||||||
any window exists, and both `buildPreload` and `chromeOptions` read that pinned
|
|
||||||
value rather than the live settings snapshot. Otherwise an editor or manager
|
|
||||||
opened after a pending change would boot the *new* tree and frame while the
|
|
||||||
main window still ran the old one.
|
|
||||||
|
|
||||||
**Stylesheet split.** `ui/src/styles.css` holds only what BOTH skins consume —
|
|
||||||
the `@layer` statement, the Tailwind import, OKLCH tokens, `@theme inline`,
|
|
||||||
panel-chrome tokens, animations, the accent axis, and the `html, body` base. Each
|
|
||||||
skin's own rules live in its folder (`skins/7like/7like.css`,
|
|
||||||
`skins/shadlike/shadlike.css`) and are `@import`ed from there. `shadlike.css` is
|
|
||||||
near-empty by nature: that skin IS the shared token system plus inline Tailwind.
|
|
||||||
Two constraints: the `@layer` statement must stay the FIRST statement in
|
|
||||||
`styles.css` (it is one of only two rule types allowed above `@import`), and
|
|
||||||
`@font-face` `url()`s resolve relative to the file that declares them — which is
|
|
||||||
why 7like's Noto Sans lives at `skins/7like/fonts/`, inside the skin.
|
|
||||||
|
|
||||||
**Cascade** (`ui/src/styles.css` first lines + `skins/7like/7like.css`): the
|
|
||||||
explicit `@layer theme, base, components, utilities, skin, skinfix;` statement
|
|
||||||
precedes every import. 7.css is imported with `layer(skin)`, i.e. **above
|
|
||||||
`utilities`**,
|
|
||||||
so a bare `button` rule in 7.css outranks `rounded-md` on that same button —
|
|
||||||
that inversion is what reskins ~1900 inline utility classes with no template
|
|
||||||
edits. Consequences: (1) any utility later added to a `button`/`input`/
|
|
||||||
`select`/`textarea`/`table` silently loses under the skin; (2) neutralizers
|
|
||||||
that want the Tailwind value back must sit **inside `@layer skin`**, not
|
|
||||||
`skinfix` — `revert-layer` rolls back one layer, so from `skin` it lands on
|
|
||||||
`utilities`, but from `skinfix` it lands back on 7.css and does nothing.
|
|
||||||
`skinfix` is for rules that state an explicit value (`.w7-btn`).
|
|
||||||
|
|
||||||
**Token bridge** (`skins/7like/7like.css`): `:root.win7` re-points the existing
|
|
||||||
semantic tokens at Win7 values, so utilities Tailwind still controls come out
|
|
||||||
right too. It adds NO new tokens on purpose — a new colour would need another
|
|
||||||
`@theme inline` line. The Win7 surface ramp **inverts** the modern one:
|
|
||||||
`--background` (content panes) is Explorer white and `--chrome` is the darker
|
|
||||||
`#f0f0f0` window surface. `--radius: 5px` so `rounded-md`
|
|
||||||
(`calc(--radius - 2px)`, 74 uses) lands exactly on 7.css's 3px `--w7-el-bdr`.
|
|
||||||
`--primary`/`--ring` are deliberately left alone so the accent axis stays live;
|
|
||||||
`:root.win7[data-accent]` feeds it to `--w7-w-bg`, which is authentic (Win7
|
|
||||||
shipped "Window Color"). Dark Win7 is hand-authored (Microsoft never shipped
|
|
||||||
one) as ~20 `--w7-*` overrides, duplicated forced + system like every other
|
|
||||||
dark block. `--w7-font` is restated as a rem `font` shorthand so the interface
|
|
||||||
scale reaches it and 7.css's `pt` shorthand stops resetting weight/line-height;
|
|
||||||
the rest of 7.css's px metrics are re-expressed in rem by the scale bridge in
|
|
||||||
7like.css's `skinfix`.
|
|
||||||
|
|
||||||
**Markup hooks**: `role="progressbar"` (+aria) on the transfer bars,
|
|
||||||
`has-scrollbar` on scroll containers (7.css only styles `::-webkit-scrollbar`
|
|
||||||
under that class), `w7-toolbar` on every command row — the three window header
|
|
||||||
rows plus each file panel's session strip and path bar (their buttons go flat
|
|
||||||
with a hover glow — Explorer's command bar was flat, unlike dialog push
|
|
||||||
buttons; it only targets `button`, so the path `<input>` keeps its 7.css
|
|
||||||
textbox treatment), `w7-flat`
|
|
||||||
as the per-button escape hatch for the same treatment outside a toolbar (the
|
|
||||||
status-bar transfers summary is a text toggle, not a push button), `w7-btn`
|
|
||||||
on the 10 dialog action buttons. The flat treatment is split across two
|
|
||||||
layers on purpose: `background: revert-layer` must sit in `skin` to reach
|
|
||||||
`utilities`, while `border-color`/`box-shadow` sit in `skinfix` because 7.css
|
|
||||||
restates `border-color` at (0,3,1) and no sane `.w7-flat` selector outranks
|
|
||||||
that.
|
|
||||||
|
|
||||||
**Tabs**: the workspace strip is 7.css's real tab component —
|
|
||||||
`menu[role=tablist]` + one `button[role=tab][aria-selected]` per workspace, so
|
|
||||||
the framework draws the joined inactive tabs and the raised active one. The
|
|
||||||
selector is a DESCENDANT (`menu[role=tablist] button`), so the `+` button, the
|
|
||||||
update pill and the right-hand tools deliberately sit outside the `<menu>`.
|
|
||||||
Tabs are exempted from the `w7-toolbar` flattening via `:not([role='tab'])` —
|
|
||||||
they want the full Aero slab their neighbours are stripped of. There is no
|
|
||||||
per-tab ✕ (Win7 tab controls had none, and a nested button is invalid HTML):
|
|
||||||
closing is `Ctrl/Cmd+W` or middle-click. `role="tabpanel"` on the panel area
|
|
||||||
was considered and rejected — its `padding: 14px`, `margin-bottom: 9px` and
|
|
||||||
border fight the split-pane layout for a join that cannot read anyway with the
|
|
||||||
panel toolbar row in between.
|
|
||||||
|
|
||||||
**Frame transparency**: the rounded frame only reads as rounded because
|
|
||||||
nothing paints an opaque full-bleed ground behind it. That is not free —
|
|
||||||
`html, body` paint `var(--background)` **unlayered**, which beats every
|
|
||||||
`@layer`, so `.win7` resets them to transparent in an equally unlayered rule
|
|
||||||
next to the `font-family` one. A maximized window drops the radius (7.css
|
|
||||||
declares it three times: `.window`, `::before`, `::after` — all three must be
|
|
||||||
zeroed); the frame reads the state off its own child with
|
|
||||||
`:has(> .title-bar.is-maximized)`, which `title-bar.ts` publishes from its
|
|
||||||
existing `maximized` signal rather than subscribing to `windowState` twice.
|
|
||||||
|
|
||||||
**Selection band**: the file list's selected row is a FLAT Win7-Explorer blue
|
|
||||||
(`--w7-sel-bg`, `#cbe8f6`) with **no border** — not 7.css's `--w7-el-grad-h`,
|
|
||||||
which is its Aero element-hover ramp and stays reserved for buttons and tabs
|
|
||||||
that still want the gloss, and not its `#aaddfa` row outline, which stacked a
|
|
||||||
second edge inside the fill and, with the cursor row's dotted rect, drew three
|
|
||||||
concentric outlines on one row. Every row still reserves `border: 1px solid
|
|
||||||
transparent` for geometry; the fill paints under it (`background-clip`
|
|
||||||
defaults to `border-box`). The unfocused pane gets `--w7-sel-bg-idle`,
|
|
||||||
Explorer's grey inactive selection — the band never disappears with focus,
|
|
||||||
only weakens (the commander convention above). Both are dark-overridden in
|
|
||||||
BOTH dark blocks. The context menu's item highlight uses the same
|
|
||||||
`--w7-sel-bg`, so selection reads identically wherever it appears.
|
|
||||||
|
|
||||||
**Context menu**: pure 7.css via its real menu contract —
|
|
||||||
`ul[role=menu] > li[role=menuitem] > button` for actions,
|
|
||||||
`input[type=checkbox] + label` for the checkable column toggles, dividers as
|
|
||||||
`.has-divider` on the PRECEDING item (an `::after` bevel, not a node). The
|
|
||||||
contract is exact: an earlier iteration rendered `div > button`, matched
|
|
||||||
nothing, and wrongly concluded "7.css has no menu component" — it has a full
|
|
||||||
one (`dist/gui/menu.css`), including the gutter stripe, disabled state and
|
|
||||||
submenu arrows. Keyboard moves REAL focus between items (7.css styles
|
|
||||||
`:focus-visible` like `:hover`); Enter/Space activate buttons natively. Only
|
|
||||||
menu.css's hardcoded light literals are restated for dark, in `skinfix` — plus
|
|
||||||
three overrides keyed on the app's own `w7-ctx` marker: `position: fixed`
|
|
||||||
(7.css's `position: relative` on `ul[role=menu]` had been silently winning),
|
|
||||||
the flat `--w7-sel-bg` highlight in place of its gradient wash, and
|
|
||||||
`content: none` on the item buttons' pseudo-elements, which kills both the
|
|
||||||
1s Aero afterglow and the infinite `pulse-anim` on a focused item (see
|
|
||||||
gotchas.md). `scripts/menu-probe.ts` regression-tests placement AND highlight
|
|
||||||
flatness, each with a control that proves the check can fail.
|
|
||||||
|
|
||||||
**Font**: Segoe UI is absent off Windows and not redistributable. Noto Sans —
|
|
||||||
the substitute 7.css itself names — is vendored (OFL-1.1, from
|
|
||||||
`@fontsource/noto-sans`) into `ui/src/fonts/`; under `src/` and not `public/`
|
|
||||||
so esbuild emits it hashed into `media/` and rewrites the `url()`.
|
|
||||||
|
|
||||||
**Window chrome**: `chromeOptions(skin)` in `src/bun/windows.ts` is the single
|
|
||||||
place either skin's frame is decided, spread by both the main window and
|
|
||||||
`openSecondaryWindow`. `win7` uses `titleBarStyle: "hidden"` on **every**
|
|
||||||
platform — that clears `Titled` from the Cocoa style mask, which is also the
|
|
||||||
only way to remove the macOS traffic lights. **Linux honours it too**: the GTK
|
|
||||||
wrapper calls `gtk_window_set_decorated(false)` and logs "GTK: Created window
|
|
||||||
without decorations (custom titlebar)" (verified on GNOME/Wayland), so the
|
|
||||||
frameless Aero chrome is not a mac/win-only feature. The webview then draws the whole
|
|
||||||
frame via `ui/src/app/skins/7like/title-bar/title-bar.ts` (7.css's `.title-bar` >
|
|
||||||
`.title-bar-text` + `.title-bar-controls`, buttons keyed by `aria-label`),
|
|
||||||
mounted by all three of that skin's window roots. Because `titleBarStyle` is
|
|
||||||
fixed at construction, **a skin change only reaches the frame on the next app
|
|
||||||
start** — and nothing about the skin flips live, deliberately. `IS_WIN7_CHROME` in
|
|
||||||
`platform.ts` reads the preload-baked `__APP_SKIN` (settings resolve too late
|
|
||||||
for layout) and forces `IS_MAC_CHROME` false, since there are no traffic
|
|
||||||
lights to reserve `pl-24` for.
|
|
||||||
|
|
||||||
**Window resize (STOPGAP)**: undecorating the window also removes the WM's
|
|
||||||
resize borders, and electrobun 1.18.1 has no native resize gesture — so
|
|
||||||
`skins/7like/window-resize/` synthesizes one: a bottom-right grip drags the
|
|
||||||
frame through `windowGetFrame`/`windowSetFrame` → `BrowserWindow.setFrame`.
|
|
||||||
Mounted by all three win7 roots, hidden while maximized by a CSS `:has()` rule
|
|
||||||
(no second `windowState` subscription), and the grip carries
|
|
||||||
`electrobun-webkit-app-region-no-drag` or the drag region would move the window
|
|
||||||
instead. **One corner, not eight handles**: the handles are invisible and paint
|
|
||||||
above the frame, so a north strip or top corner overlays the caption buttons and
|
|
||||||
steals their `:hover`, which reads as the Aero glow flickering on those buttons.
|
|
||||||
The bottom-right corner is the only one that overlaps nothing (it sits in the
|
|
||||||
6px gutter `.window-body`'s margin leaves). The frame math is a dependency-free module so
|
|
||||||
`scripts/resize-math-probe.ts` can assert all eight edges without a window.
|
|
||||||
**This is meant to be deleted**: the component header carries the removal
|
|
||||||
checklist and `scripts/check-electrobun-resize.ts` (wired into `postinstall`)
|
|
||||||
prints it when upstream ships the primitive. See platform-notes.md.
|
|
||||||
|
|
||||||
**Window controls**: new `windowMinimize` / `windowToggleMaximize` /
|
|
||||||
`windowClose` / `windowIsMaximized` requests, built by
|
|
||||||
`windowControlHandlers(getWindow, onClose?)` in `rpc-handlers.ts` and served by
|
|
||||||
all three window kinds (each passing a late-bound getter for its own window —
|
|
||||||
the window takes the rpc in its constructor, same reason `events` is
|
|
||||||
late-bound). `onClose` is overridden per window kind so the X reuses the
|
|
||||||
existing close path: editors run their dirty prompt, the manager runs
|
|
||||||
`managerClose`, main falls through to the cancellable `before-quit` guard.
|
|
||||||
electrobun emits **no maximize event**, so `trackMaximizeState` derives it from
|
|
||||||
`resize` + the synchronous `isMaximized()` and pushes `windowState` — which
|
|
||||||
also covers Aero Snap and Win+Up. Double-click-to-maximize is likewise absent
|
|
||||||
from electrobun's drag regions and is implemented in the title-bar component.
|
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ Wired on top of the download host above. See the "Auto-updater" section in
|
|||||||
`applyUpdate` (handlers in [rpc-handlers.ts](../src/bun/rpc-handlers.ts) call the electrobun `Updater`);
|
`applyUpdate` (handlers in [rpc-handlers.ts](../src/bun/rpc-handlers.ts) call the electrobun `Updater`);
|
||||||
push message `updateStatus`.
|
push message `updateStatus`.
|
||||||
- **UI**: `UpdaterService` ([ui/src/app/core/updater.service.ts](../ui/src/app/core/updater.service.ts)) holds signals
|
- **UI**: `UpdaterService` ([ui/src/app/core/updater.service.ts](../ui/src/app/core/updater.service.ts)) holds signals
|
||||||
(`state`/`version`/`progress`); the tab-bar "update pill" ([tab-bar.ts](../ui/src/app/skins/7like/tab-bar/tab-bar.ts))
|
(`state`/`version`/`progress`); the tab-bar "update pill" ([tab-bar.ts](../ui/src/app/skins/shadlike/tab-bar/tab-bar.ts))
|
||||||
is one accent (`bg-primary`) control that announces → fills as a progress bar while downloading →
|
is one accent (`bg-primary`) control that announces → fills as a progress bar while downloading →
|
||||||
"Restart to apply". `state` is `available → downloading → ready` (ready derived from the
|
"Restart to apply". `state` is `available → downloading → ready` (ready derived from the
|
||||||
`downloadUpdate` response). App-menu "Check for Updates…" ([menu.ts](../src/bun/menu.ts)) routes a
|
`downloadUpdate` response). App-menu "Check for Updates…" ([menu.ts](../src/bun/menu.ts)) routes a
|
||||||
|
|||||||
+60
-228
@@ -28,17 +28,6 @@ touching the adjacent code.
|
|||||||
- Tailwind v4 canonical class names: `wrap-break-word` not `break-words`,
|
- Tailwind v4 canonical class names: `wrap-break-word` not `break-words`,
|
||||||
`accent-primary` not `accent-[var(--primary)]`, `z-60` not `z-[60]`.
|
`accent-primary` not `accent-[var(--primary)]`, `z-60` not `z-[60]`.
|
||||||
Every color used in a template must resolve through `@theme inline`.
|
Every color used in a template must resolve through `@theme inline`.
|
||||||
- **The win7 skin layer outranks Tailwind utilities.** 7.css is imported
|
|
||||||
`layer(skin)`, ordered above `utilities`, so any utility you add to a
|
|
||||||
`button`/`input`/`select`/`textarea`/`table` is silently dead under that
|
|
||||||
skin. If it matters, neutralize in the `@layer skin` block in
|
|
||||||
`app/skins/7like/7like.css` — and note `revert-layer` only reaches
|
|
||||||
`utilities` from **inside `skin`**; used from `skinfix` it falls back onto
|
|
||||||
7.css and is a no-op. This bit once already.
|
|
||||||
- `@import … layer(name)` works through `@tailwindcss/postcss` (it wraps the
|
|
||||||
imported AST), but the **deep path is mandatory**: 7.css ships no `exports`
|
|
||||||
map and Tailwind's resolver reads only `style`/`mainFields`, so a bare
|
|
||||||
`@import "7.css"` resolves to nothing. Use `7.css/dist/7.scoped.css`.
|
|
||||||
- Stylesheet `url()` must resolve at BUILD time: assets referenced from
|
- Stylesheet `url()` must resolve at BUILD time: assets referenced from
|
||||||
`styles.css` live under `ui/src/` (esbuild emits them hashed into `media/`
|
`styles.css` live under `ui/src/` (esbuild emits them hashed into `media/`
|
||||||
and rewrites the url), NOT `ui/public/` — a public path fails the build with
|
and rewrites the url), NOT `ui/public/` — a public path fails the build with
|
||||||
@@ -53,6 +42,18 @@ touching the adjacent code.
|
|||||||
- **A component `template:` is a TS template literal, so no backticks inside
|
- **A component `template:` is a TS template literal, so no backticks inside
|
||||||
it** — not even in an HTML comment. A backtick in a comment terminates the
|
it** — not even in an HTML comment. A backtick in a comment terminates the
|
||||||
literal and the errors point at the prose, not at the quoting.
|
literal and the errors point at the prose, not at the quoting.
|
||||||
|
- **Reach `core/` through the `@core/*` alias, never a relative path.**
|
||||||
|
Components sit at varying depths and get moved; the alias is what let 67
|
||||||
|
imports survive two relocations untouched. A `../../core/...` path is a
|
||||||
|
future merge conflict.
|
||||||
|
- **The build DOES type-check components nothing imports.** ngtsc compiles the
|
||||||
|
whole program, so an unreferenced component still fails `bun run build:ui` on
|
||||||
|
a type or template error (verified by injecting an error into an
|
||||||
|
unreferenced file).
|
||||||
|
- **`overflow-x: auto` also clips vertically.** CSS refuses to pair `visible`
|
||||||
|
with a non-visible axis, so the computed `overflow-y` becomes `auto` too:
|
||||||
|
anything poking out of a horizontally scrolling strip (a raised tab, an
|
||||||
|
underline at `-bottom-px`) is silently clipped. Reserve padding instead.
|
||||||
|
|
||||||
## The interface scale (root font-size lever)
|
## The interface scale (root font-size lever)
|
||||||
|
|
||||||
@@ -66,137 +67,16 @@ touching the adjacent code.
|
|||||||
JSDoc's "(WebKit only)" means macOS WKWebView. This is why the scale is CSS.
|
JSDoc's "(WebKit only)" means macOS WKWebView. This is why the scale is CSS.
|
||||||
- **Everything the UI sizes with must be rem.** The scale works by setting the
|
- **Everything the UI sizes with must be rem.** The scale works by setting the
|
||||||
root font-size, so a px value freezes while everything around it grows. The
|
root font-size, so a px value freezes while everything around it grows. The
|
||||||
three px reservoirs were: the panel-chrome tokens (`--row-h` etc.), 93
|
px reservoirs were the panel-chrome tokens (`--row-h` etc.) and 93
|
||||||
`text-[11px]` arbitraries, and 7.css itself — 370 px values, zero rem, with
|
`text-[11px]` arbitraries. 1px hairlines and focus rects stay px deliberately
|
||||||
`--w7-font` reaching only 15 rules' typography. 7like.css's `skinfix` carries
|
(crisp at any scale).
|
||||||
a scale bridge re-expressing 7.css's vars and hardcoded box metrics in rem.
|
|
||||||
1px hairlines and focus rects stay px deliberately (crisp at any scale; the
|
|
||||||
`tr` border exists to stop selection shifting rows).
|
|
||||||
- **Restating a 7.css rule in `skinfix` also outranks 7.css's VARIANTS of that
|
|
||||||
rule — by layer, regardless of specificity.** The title-bar-controls
|
|
||||||
restatement silently shrank the close button from 48px to 29px (7.css widens
|
|
||||||
it via `button[aria-label=Close]`), and the tablist padding flattened the
|
|
||||||
active tab's 4px raise. Caught only by the harness pixel diff. When bridging
|
|
||||||
a rule, grep 7.scoped.css for every variant that overrides the same
|
|
||||||
properties and restate those too.
|
|
||||||
- **px constants in TS that mirror rem-sized CSS are latent scale bugs.** The
|
- **px constants in TS that mirror rem-sized CSS are latent scale bugs.** The
|
||||||
shadlike context menu's `MENU_W = 224` was a hardcoded mirror of `w-56`
|
context menu's `MENU_W = 224` was a hardcoded mirror of `w-56`
|
||||||
(14rem): box grew, math didn't, flip fired at the wrong edge. Convert through
|
(14rem): box grew, math didn't, flip fired at the wrong edge. Convert through
|
||||||
`@core/metrics` `remPx()` at use time — it reads the LIVE root font-size,
|
`@core/metrics` `remPx()` at use time — it reads the LIVE root font-size,
|
||||||
and per call, since the scale changes mid-session. The editor's canvas-based
|
and per call, since the scale changes mid-session. The editor's canvas-based
|
||||||
`charWidth` is cached keyed on the computed font string for the same reason.
|
`charWidth` is cached keyed on the computed font string for the same reason.
|
||||||
|
|
||||||
## Hover states and the frame edge
|
|
||||||
|
|
||||||
- **A child's box-shadow is NOT clipped by its parent's border or radius.** The
|
|
||||||
close button's hover glow (7.css: `0 0 7px 3px #e68e75` on a `::before`)
|
|
||||||
painted across the window's top border and onto the desktop beyond it — the
|
|
||||||
caption buttons sit flush against the top edge (`border-top: 0`), and the
|
|
||||||
frame's rounded transparent corners clip nothing. Fixed with
|
|
||||||
`clip-path: inset(0 0 -1rem 0)` on `.w7-frame > .title-bar`: glow stops at
|
|
||||||
the frame like the real DWM, bottom bleed (authentic) stays open. Note
|
|
||||||
clip-path also clips hit-testing — safe here because the drag region is the
|
|
||||||
title-bar's own box, entirely inside the clip.
|
|
||||||
- **Hover states CAN be tested headlessly**: `scripts/hover-probe.ts` copies
|
|
||||||
the built stylesheet with `:hover` rewritten to `.simhover`, renders the
|
|
||||||
frame markup with the state forced, and pixel-scans artifact regions,
|
|
||||||
exiting non-zero on hover-introduced red outside the frame. Firefox
|
|
||||||
`--screenshot` has no pointer and `import -window` captures the real
|
|
||||||
pointer's state, so the class rewrite is the only programmatic hover on this
|
|
||||||
machine. Re-run it after touching title-bar or hover styling.
|
|
||||||
|
|
||||||
## The two skin trees (`ui/src/app/skins/`)
|
|
||||||
|
|
||||||
- **`.win7` is not a name we may change.** `7.scoped.css` is precompiled with
|
|
||||||
every selector prefixed by that literal class, so the skin id in
|
|
||||||
`AppSettings` stays `"win7"` even though its folder is `7like/`. The folder
|
|
||||||
names are cosmetic; the class and the persisted setting value are not.
|
|
||||||
- **Reach `core/` through the `@core/*` alias, never a relative path.** Skin
|
|
||||||
components sit at varying depths and get moved; the alias is what let 67
|
|
||||||
imports survive two relocations untouched. A `../../core/...` path is a
|
|
||||||
future merge conflict.
|
|
||||||
- **`@font-face` `url()` resolves relative to the file that declares it.** When
|
|
||||||
the win7 rules moved three directories deeper, `./fonts/...` would silently
|
|
||||||
have 404'd — build still green, app just renders a fallback font. The fonts
|
|
||||||
therefore live inside the skin that uses them.
|
|
||||||
- **The `@layer` statement must stay the first statement in `ui/src/styles.css`.**
|
|
||||||
It is one of only two rule types allowed above `@import`, and it is what sorts
|
|
||||||
`skin` above `utilities`. Every `@import` (Tailwind and both skin sheets) must
|
|
||||||
also precede all other rules.
|
|
||||||
- **Duplicate selectors across skins are safe.** `app-file-panel` exists in both
|
|
||||||
trees; standalone components resolve through their importer's `imports:`
|
|
||||||
array, never a global registry, and the trees are never mounted together.
|
|
||||||
- **The build DOES type-check components nothing imports.** ngtsc compiles the
|
|
||||||
whole program, so a skin that is currently unreachable still fails
|
|
||||||
`bun run build:ui` on a type or template error. Verified deliberately by
|
|
||||||
injecting an error into an unreferenced file — worth knowing before assuming
|
|
||||||
a green build proves nothing about the inactive skin.
|
|
||||||
|
|
||||||
## The win7 skin (7.css)
|
|
||||||
|
|
||||||
- **Unlayered declarations beat every `@layer`, regardless of specificity.**
|
|
||||||
`html, body { background: … }` in styles.css is unlayered, so no `skinfix`
|
|
||||||
rule can override it — the win7 transparent-corner reset has to be unlayered
|
|
||||||
too. The frame's rounded corners only read as rounded because nothing paints
|
|
||||||
an opaque full-bleed ground behind them; anything that does fills the corners
|
|
||||||
and the window becomes a rectangle with a curve drawn on it.
|
|
||||||
- **7.css restates `border-color` at (0,3,1)** via
|
|
||||||
`.win7 button:not(:disabled):not(:hover)` and its `:hover` twin. A two-class
|
|
||||||
selector like `.win7 .w7-flat` (0,2,0) loses to it, so paint suppression for
|
|
||||||
flat buttons lives in `skinfix` where layer order settles it. The
|
|
||||||
`.w7-toolbar` arm only ever won by tying on specificity and landing later in
|
|
||||||
the same layer — an accident `w7-flat` did not share, which is why it
|
|
||||||
rendered a bordered slab while its neighbours went flat.
|
|
||||||
- **`overflow-x: auto` also clips vertically.** CSS refuses to pair `visible`
|
|
||||||
with a non-visible axis, so the computed `overflow-y` becomes `auto` too.
|
|
||||||
7.css raises the active tab with `margin-top: -2px`; inside the horizontally
|
|
||||||
scrolling tab strip that put its top border outside the scroll box and
|
|
||||||
deleted it silently. The strip reserves `padding-top: 2px` to compensate —
|
|
||||||
mirroring the `padding-left: 3px` 7.css already reserves for the same tab's
|
|
||||||
`-3px` left margin.
|
|
||||||
- **7.css hardcodes light-mode literals outside its own var system** —
|
|
||||||
`color: #222` on tablist buttons, black/white bevels in the ListView and
|
|
||||||
menu. Each one has to be routed through a `--w7-*` token declared in
|
|
||||||
`:root.win7` and overridden in **both** dark blocks (the `[data-theme]`
|
|
||||||
attribute one *and* the `prefers-color-scheme` one; system dark sets no
|
|
||||||
attribute).
|
|
||||||
- **The skin layer can also override `position`, and that breaks LAYOUT, not
|
|
||||||
just paint.** `ul[role=menu]` carries `position: relative` in 7.css (for the
|
|
||||||
gutter stripe's `::before` and submenu anchoring), which outranked Tailwind's
|
|
||||||
`.fixed` on the app's own context menu. `[style.left/top.px]` then became
|
|
||||||
*flow* offsets from the menu's static position — the last child of the
|
|
||||||
`h-screen` frame — so every right-click menu rendered below the viewport,
|
|
||||||
unreachable behind `overflow: hidden`. The backdrop is a plain `<div>`, which
|
|
||||||
7.css does not match, so it stayed genuinely fixed and ate the next click:
|
|
||||||
the symptom was "right-click does nothing, then the app ignores me once".
|
|
||||||
Fixed with a `position: fixed` restatement in `skinfix`, scoped to a `w7-ctx`
|
|
||||||
marker class so 7.css's own menus keep theirs. Regressed in `0ae2a62`, when
|
|
||||||
the menu root became a real `ul[role=menu]` — i.e. adopting the framework's
|
|
||||||
markup is what exposed it. **A resting pixel diff cannot catch this class of
|
|
||||||
bug**: nothing about the idle window changes. `scripts/menu-probe.ts` renders
|
|
||||||
the menu at declared coordinates and scans whether it landed there, with a
|
|
||||||
marker-less control menu as its own discriminator.
|
|
||||||
- **7.css's button effects reach anything that IS a button — including menu
|
|
||||||
items — and one of them fades out over a FULL SECOND.** `button::before` is
|
|
||||||
an Aero glow overlay with `opacity: 0`, raised to 1 on hover, and
|
|
||||||
`button:not(:hover):before` carries `transition: opacity 1s linear`.
|
|
||||||
menu.css suppresses it with `content: none` on the **`:hover` arm only**, so
|
|
||||||
while an item is hovered nothing paints, but the moment the pointer leaves,
|
|
||||||
the pseudo returns with opacity animating 1 → 0: a blue ghost trailing the
|
|
||||||
cursor down the menu. Killing `content` in *every* state is the fix —
|
|
||||||
suppressing the transition alone leaves a glow that simply snaps. Two more
|
|
||||||
from the same family: `button:focus` runs `pulse-anim` *infinitely*
|
|
||||||
(`1s ease infinite alternate`), which matters because the context menu moves
|
|
||||||
REAL focus between items for keyboard nav, and the hover paint is 7.css's
|
|
||||||
translucent gradient wash — the same one the ListView selection dropped.
|
|
||||||
All three are overridden together, scoped to `w7-ctx`.
|
|
||||||
- **Its selectors are exact, and a near-miss looks exactly like "7.css doesn't
|
|
||||||
support this".** `menu[role=tablist] button` is a DESCENDANT selector, so
|
|
||||||
every button inside the strip becomes a tab — the `+` and the right-hand
|
|
||||||
tools must live outside it. `.window.glass > .title-bar` is direct-child, so
|
|
||||||
an inner wrapper breaks the glass coupling. A wrong guess here already
|
|
||||||
produced one hand-written lookalike that was deleted.
|
|
||||||
|
|
||||||
## Electrobun
|
## Electrobun
|
||||||
|
|
||||||
- **Window `close` is not cancellable** (response type `{}`; only the
|
- **Window `close` is not cancellable** (response type `{}`; only the
|
||||||
@@ -221,9 +101,48 @@ touching the adjacent code.
|
|||||||
`dist/api/bun/proc/linux.md` says so; the app also prints "Application menus are
|
`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
|
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,
|
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
|
nothing happens. The menu is plain HTML now (`ui/src/app/skins/shadlike/context-menu.ts`) on
|
||||||
every platform; don't reintroduce the native one for "nativeness" on mac/win
|
every platform; don't reintroduce the native one for "nativeness" on mac/win
|
||||||
without keeping a Linux path.
|
without keeping a Linux path.
|
||||||
|
- **Window-open flash prevention is three-layer** — remove any one and the
|
||||||
|
white/inverted blink returns: (1) windows are created `hidden: true` and
|
||||||
|
revealed by `showWhenReady` on the webview's first `dom-ready` (800 ms
|
||||||
|
fallback), (2) the bun preload bakes the persisted `data-theme`/tone/accent
|
||||||
|
attributes and the interface scale before first paint (`buildPreload`),
|
||||||
|
(3) `ui/src/index.html` carries an inline `<style>` with the html background
|
||||||
|
per theme — its oklch values MUST stay in sync with the `--background` tokens
|
||||||
|
in styles.css. (The win7 branch once replaced (3) with a transparent rule and
|
||||||
|
the modern skin silently lost it — the other two layers do not cover it.)
|
||||||
|
- Dev vs prod URL: `getMainViewUrl()` resolves the ng-serve dev server only
|
||||||
|
when `DEV_SERVER_URL` is set AND the updater channel is `dev` AND the
|
||||||
|
server answers a HEAD request; secondary windows must reuse the resolved
|
||||||
|
URL, never hardcode `views://`.
|
||||||
|
- Upstream Bun panic (`FFI_Callback_threadsafe_call` → `web_worker.spin`):
|
||||||
|
rare segfault under long sessions; upstream electrobun×Bun issue, restart
|
||||||
|
recovers, nothing app-side to fix.
|
||||||
|
- **Preloads and frames are construction-time only.** The preload is a
|
||||||
|
persistent `WebKitUserScript` at DOCUMENT_START, frozen at webview
|
||||||
|
construction — `webview.loadURL()` re-runs the OLD one
|
||||||
|
(`updatePreloadScriptToWebView` is only reachable from electrobun's internal
|
||||||
|
`<electrobun-webview>` handler). `titleBarStyle`/`transparent` have no
|
||||||
|
setters. Anything `buildPreload` bakes is a first-paint snapshot the UI must
|
||||||
|
correct live over RPC. Recreating the main window is not an escape hatch:
|
||||||
|
`exitOnLastWindowClosed`, push closures and the tray capture it, and there
|
||||||
|
is no `off()`.
|
||||||
|
- **Drag regions ignore `preventDefault`.** The preload starts a window move
|
||||||
|
from a bubble-phase `mousedown` on `document` and never reads
|
||||||
|
`defaultPrevented`; a `pointerdown` handler doesn't stop it either (the
|
||||||
|
compat `mousedown` still fires). `isAppRegionDrag` checks
|
||||||
|
`.electrobun-webkit-app-region-no-drag` first — that class on the element is
|
||||||
|
the only reliable suppressor (`dist/api/bun/preload/dragRegions.ts`).
|
||||||
|
- **No resize, maximize-event or double-click-maximize primitives (1.18.1).**
|
||||||
|
`titleBarStyle: "hidden"` drops the whole frame (Cocoa `Titled` — the only
|
||||||
|
way to remove the traffic lights; `gtk_window_set_decorated(false)` on
|
||||||
|
Linux) and with it the WM resize borders; nothing restores them (no
|
||||||
|
`startWindowResize`, no `gtk_window_begin_resize_drag`, no
|
||||||
|
`setResizable`/min-size options — only `setFrame`/`getFrame`). No maximize
|
||||||
|
event exists (derive it from `resize` + `isMaximized()`), and drag regions
|
||||||
|
don't double-click-maximize. A custom-chrome window has to build all three.
|
||||||
|
|
||||||
## Linux
|
## Linux
|
||||||
|
|
||||||
@@ -266,100 +185,13 @@ touching the adjacent code.
|
|||||||
pattern appears in the killing command's OWN argv. Break the literal with a
|
pattern appears in the killing command's OWN argv. Break the literal with a
|
||||||
character class — `pkill -f 'dev-linux-x[6]4'` — the way `ps | grep [x]yz`
|
character class — `pkill -f 'dev-linux-x[6]4'` — the way `ps | grep [x]yz`
|
||||||
has always worked.
|
has always worked.
|
||||||
- **A frameless window cannot be resized, and `preventDefault` will not save
|
- **Launching the app steals focus — ask first.** `bun run start` opens a real
|
||||||
you from the drag region.** Undecorating the GTK window (what
|
window that takes focus, which interrupts whatever the user is doing; there
|
||||||
`titleBarStyle: "hidden"` does) takes the WM's resize borders with it, and
|
is no Xvfb/xvfb-run on this machine and Wayland offers no
|
||||||
electrobun exposes no resize gesture — hence the synthesized grip in
|
launch-without-focus. Batch every live check into a single launch.
|
||||||
`ui/src/app/skins/7like/window-resize/` (a STOPGAP; that file carries the
|
|
||||||
removal checklist, and `scripts/check-electrobun-resize.ts` fires from
|
|
||||||
`postinstall` when upstream makes it deletable).
|
|
||||||
**An invisible resize handle over a control steals that control's hover.** A
|
|
||||||
full eight-handle set shipped briefly and was reverted: the north strip ran
|
|
||||||
the whole width across the caption buttons and the north-east square sat on
|
|
||||||
the close button's corner, so crossing one dropped the button's `:hover`
|
|
||||||
mid-glow and looked like a rendering artifact in 7.css — nothing in the CSS
|
|
||||||
was wrong. Only the bottom-right corner overlaps nothing (it lands in the 6px
|
|
||||||
gutter `.window-body { margin: 0 6px 6px }` leaves). Any new handle must be
|
|
||||||
checked against what sits under it; south/east/west are innocent, north and
|
|
||||||
both top corners are not.
|
|
||||||
Three more traps live in that code, all verified against
|
|
||||||
`dist/api/bun/preload/dragRegions.ts`:
|
|
||||||
- It starts a window **move** from a `mousedown` listener on `document`, in
|
|
||||||
the **bubble** phase. It never reads `defaultPrevented`, so
|
|
||||||
`event.preventDefault()` does **nothing** to stop it.
|
|
||||||
- A `pointerdown` handler does not stop it either: the compatibility
|
|
||||||
`mousedown` still fires afterwards, and stopping propagation on a *pointer*
|
|
||||||
event has no effect on a *mouse* event. This is the trap that looks fixed
|
|
||||||
in every test until the window jumps.
|
|
||||||
- `isAppRegionDrag` checks `.electrobun-webkit-app-region-no-drag` **first**
|
|
||||||
and short-circuits, so that class — on the handle itself — is the only
|
|
||||||
reliable suppressor. Without it, every resize drag also drags the window.
|
|
||||||
Also: deltas must come from `screenX/screenY`. North/west resizes move the
|
|
||||||
window's origin, which moves the webview's client origin under a stationary
|
|
||||||
pointer, so `clientX` feeds that movement back into the next delta and the
|
|
||||||
window runs away. And the minimum-size clamp must re-anchor the opposite
|
|
||||||
edge — clamping the width alone lets `x` keep tracking the pointer, walking
|
|
||||||
the window off-screen while it looks frozen (`scripts/resize-math-probe.ts`
|
|
||||||
asserts exactly that, and was verified to catch it).
|
|
||||||
- **Prefer the headless probes over launching the app.** `bun run start` opens a
|
|
||||||
real window that takes focus, which interrupts whatever the user is doing;
|
|
||||||
there is no Xvfb/xvfb-run on this machine and Wayland offers no
|
|
||||||
launch-without-focus. `scripts/shot.ts` (+ `--dark`), `hover-probe.ts` and
|
|
||||||
`menu-probe.ts` all render through headless Firefox and open nothing. Launch
|
|
||||||
the real app only for what Gecko genuinely cannot answer — WebKitGTK
|
|
||||||
behaviour, the native frame, transparency, live RPC — ask first, and batch
|
|
||||||
every live check into a single launch.
|
|
||||||
- **X11 capture cannot answer "is this pixel transparent?"** for the frameless
|
|
||||||
window. `import -window` grabs the window's own pixels with no compositing,
|
|
||||||
so a transparent corner and an opaque black one both come out black;
|
|
||||||
`png32:` just fills alpha with 1 and proves nothing. XWayland's root window
|
|
||||||
is not the composited desktop, so `import -window root` fails too. Frame
|
|
||||||
transparency and the maximized corners need a human looking at the screen —
|
|
||||||
say so rather than reporting a screenshot as confirmation.
|
|
||||||
- Benign startup noise on Linux — non-fatal, the app runs normally:
|
- Benign startup noise on Linux — non-fatal, the app runs normally:
|
||||||
`libayatana-appindicator is deprecated. Please use libayatana-appindicator-glib`
|
`libayatana-appindicator is deprecated. Please use libayatana-appindicator-glib`
|
||||||
(upstream electrobun's choice, not ours).
|
(upstream electrobun's choice, not ours).
|
||||||
- **Window-open flash prevention is three-layer** — remove any one and the
|
|
||||||
white/inverted blink returns: (1) windows are created `hidden: true` and
|
|
||||||
revealed by `showWhenReady` on the webview's first `dom-ready` (800 ms
|
|
||||||
fallback), (2) the bun preload bakes the persisted `data-theme`/tone/accent
|
|
||||||
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
|
|
||||||
URL, never hardcode `views://`.
|
|
||||||
- Upstream Bun panic (`FFI_Callback_threadsafe_call` → `web_worker.spin`):
|
|
||||||
rare segfault under long sessions; upstream electrobun×Bun issue, restart
|
|
||||||
recovers, nothing app-side to fix.
|
|
||||||
|
|
||||||
## Domain logic
|
## Domain logic
|
||||||
|
|
||||||
|
|||||||
+2
-27
@@ -8,9 +8,7 @@ macOS is the primary target; Windows 11+ x64 and Linux x64 are **beta** targets
|
|||||||
|
|
||||||
| Concern | File | mac | win | linux |
|
| Concern | File | mac | win | linux |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Window chrome (modern skin) | `src/bun/windows.ts`, `index.ts` | hiddenInset + traffic-light offset, UI drag header | native titlebar, compact header | same as win |
|
| Window chrome | `src/bun/windows.ts chromeOptions` | hiddenInset + traffic-light offset, UI drag header | native titlebar, compact header | same as win |
|
||||||
| Window chrome (win7 skin) | `src/bun/windows.ts chromeOptions` | `hidden` (clears Titled → no traffic lights) | frameless | `gtk_window_set_decorated(false)` |
|
|
||||||
| Window **resize** | `ui/.../7like/window-resize/` | native frame resizes; win7 frameless uses the synthesized handles | as mac | as mac |
|
|
||||||
| External editor launch | `src/bun/edits.ts launchEditor` | `open [-a app]` | `cmd /c start "" [app] file` | app directly / `xdg-open` |
|
| External editor launch | `src/bun/edits.ts launchEditor` | `open [-a app]` | `cmd /c start "" [app] file` | app directly / `xdg-open` |
|
||||||
| Editor app picker | `rpc-handlers.ts pickEditorApp` | /Applications, `.app` | %ProgramFiles%, `.exe` | home, any |
|
| Editor app picker | `rpc-handlers.ts pickEditorApp` | /Applications, `.app` | %ProgramFiles%, `.exe` | home, any |
|
||||||
| Owner/group names | `fs/local.ts resolveId` | `id -nu` + `dscacheutil` | undefined → "—" | `id -nu` + `getent` |
|
| Owner/group names | `fs/local.ts resolveId` | `id -nu` + `dscacheutil` | undefined → "—" | `id -nu` + `getent` |
|
||||||
@@ -25,7 +23,7 @@ macOS is the primary target; Windows 11+ x64 and Linux x64 are **beta** targets
|
|||||||
| ssh-agent | `fs/sftp.ts` | $SSH_AUTH_SOCK | `\\.\pipe\openssh-ssh-agent` fallback | $SSH_AUTH_SOCK |
|
| ssh-agent | `fs/sftp.ts` | $SSH_AUTH_SOCK | `\\.\pipe\openssh-ssh-agent` fallback | $SSH_AUTH_SOCK |
|
||||||
| Paths in UI | `ui format.ts joinPath/isRootPath` | "/" | separator detected from the dir string; drive-root regex | "/" |
|
| Paths in UI | `ui format.ts joinPath/isRootPath` | "/" | separator detected from the dir string; drive-root regex | "/" |
|
||||||
|
|
||||||
The **context menu is HTML on every platform** (`ui/src/app/skins/*/context-menu.ts` +
|
The **context menu is HTML on every platform** (`ui/src/app/skins/shadlike/context-menu.ts` +
|
||||||
`context-menu.service.ts`), not a native electrobun menu — see the Linux section
|
`context-menu.service.ts`), not a native electrobun menu — see the Linux section
|
||||||
below for why. Only the three shell actions it can't do itself (reveal, open with
|
below for why. Only the three shell actions it can't do itself (reveal, open with
|
||||||
the default app, copy path) cross to bun, via the `shellItemAction` RPC.
|
the default app, copy path) cross to bun, via the `shellItemAction` RPC.
|
||||||
@@ -33,29 +31,6 @@ the default app, copy path) cross to bun, via the `shellItemAction` RPC.
|
|||||||
Remote (SFTP) paths are ALWAYS POSIX regardless of host or server OS
|
Remote (SFTP) paths are ALWAYS POSIX regardless of host or server OS
|
||||||
(`path.posix` throughout `fs/sftp.ts`).
|
(`path.posix` throughout `fs/sftp.ts`).
|
||||||
|
|
||||||
### Frameless windows lose the WM's resize borders (win7 skin, every platform)
|
|
||||||
|
|
||||||
`titleBarStyle: "hidden"` is what lets 7.css draw the Aero caption — on Linux the
|
|
||||||
GTK wrapper honours it with `gtk_window_set_decorated(false)`. Undecorating also
|
|
||||||
removes the window manager's resize borders and corner grips, and electrobun
|
|
||||||
1.18.1 offers **no** native resize gesture to put back: the shipped
|
|
||||||
`libNativeWrapper` exports `startWindowMove`/`stopWindowMove` with no resize
|
|
||||||
counterpart, does not link `gtk_window_begin_resize_drag`, and `BrowserWindow`
|
|
||||||
has neither `setResizable` nor min-size constructor options. The window is still
|
|
||||||
*programmatically* resizable (`setFrame`/`getFrame` work, and
|
|
||||||
`gtk_window_set_resizable(false)` is never called), so the app **synthesizes the
|
|
||||||
input affordance**: a bottom-right grip in the webview drags the native frame
|
|
||||||
over RPC (`ui/src/app/skins/7like/window-resize/`). Only that one corner — an
|
|
||||||
invisible handle anywhere else overlays a control and steals its hover, which is
|
|
||||||
what a full edge set did to the caption buttons.
|
|
||||||
|
|
||||||
That is a **stopgap** — each drag tick is an RPC hop plus an FFI call, where a
|
|
||||||
real WM grab is compositor-side. `scripts/check-electrobun-resize.ts` watches the
|
|
||||||
installed electrobun for the missing primitives and prints the removal checklist
|
|
||||||
when they appear; it runs from `postinstall`, so an electrobun bump surfaces it
|
|
||||||
without anyone remembering to look. The modern skin keeps the native frame and
|
|
||||||
needs none of this.
|
|
||||||
|
|
||||||
## Memory footprint (expected, not a bug)
|
## Memory footprint (expected, not a bug)
|
||||||
|
|
||||||
Investigated 2026-07 — packaged build. Roughly **~200 MB macOS / ~300 MB
|
Investigated 2026-07 — packaged build. Roughly **~200 MB macOS / ~300 MB
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
"build:ui": "bun run --cwd ui build",
|
"build:ui": "bun run --cwd ui build",
|
||||||
"build": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun build",
|
"build": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun build",
|
||||||
"start": "bun scripts/gen-version.ts && bun run build:ui && bun scripts/start.ts",
|
"start": "bun scripts/gen-version.ts && bun run build:ui && bun scripts/start.ts",
|
||||||
"postinstall": "bun scripts/gen-version.ts && bun scripts/check-electrobun-resize.ts",
|
"postinstall": "bun scripts/gen-version.ts",
|
||||||
"release": "bun scripts/release.ts"
|
"release": "bun scripts/release.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
// Watches for electrobun growing a real window-resize gesture, so the
|
|
||||||
// synthesized one can be deleted the moment it becomes dead weight.
|
|
||||||
//
|
|
||||||
// bun scripts/check-electrobun-resize.ts (also runs from `postinstall`)
|
|
||||||
//
|
|
||||||
// WHY THIS EXISTS
|
|
||||||
//
|
|
||||||
// The win7 skin is frameless, which on GTK means undecorated, which means the
|
|
||||||
// window manager supplies no resize borders. electrobun 1.18.1 exposes no
|
|
||||||
// native resize gesture at all, so the app synthesizes one: handles in the
|
|
||||||
// webview drag the frame via RPC. That is a genuine stopgap — jerkier than a
|
|
||||||
// compositor-side grab and always will be — and the thing most likely to
|
|
||||||
// happen to a stopgap is that it outlives its reason and nobody notices.
|
|
||||||
//
|
|
||||||
// So the trigger is automated rather than a comment somebody has to re-read: an
|
|
||||||
// electrobun bump runs `postinstall`, which runs this, which prints the removal
|
|
||||||
// checklist the moment upstream ships the primitive we are waiting for.
|
|
||||||
//
|
|
||||||
// It NEVER fails the install (always exit 0) and is silent when there is
|
|
||||||
// nothing to say — a check that cries wolf gets muted, and a muted check is
|
|
||||||
// worse than none.
|
|
||||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
/** The version the synthesized resize was written against and verified on. If
|
|
||||||
* the installed version differs, this script says so once — that is the cue to
|
|
||||||
* re-check upstream's changelog and then bump this constant. */
|
|
||||||
const VALIDATED_VERSION = "1.18.1";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Symbols that would mean "upstream can resize now". Verified ABSENT in 1.18.1:
|
|
||||||
* the shipped library exports `startWindowMove`/`stopWindowMove` with no resize
|
|
||||||
* counterpart, and imports `gtk_window_begin_move_drag` but not the resize
|
|
||||||
* twin. Any of these appearing is the signal.
|
|
||||||
*
|
|
||||||
* Searched as raw ASCII in the binary rather than via `nm`/`strings`: only the
|
|
||||||
* host platform's library is installed (`dist-linux-x64/…so`, `.dylib`, `.dll`
|
|
||||||
* elsewhere), and `nm` does not exist on Windows. Symbol names live in the
|
|
||||||
* binary as plain text either way.
|
|
||||||
*/
|
|
||||||
const NATIVE_NEEDLES = [
|
|
||||||
"gtk_window_begin_resize_drag", // the GTK primitive, if they ever link it
|
|
||||||
"gtk_window_set_resizable",
|
|
||||||
"startWindowResize", // the obvious sibling of startWindowMove
|
|
||||||
"setWindowResizable",
|
|
||||||
"beginWindowResize",
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Source-level signals in the shipped TS API. */
|
|
||||||
const SOURCE_NEEDLES: { file: string; needle: string; means: string }[] = [
|
|
||||||
{
|
|
||||||
file: "dist/api/bun/core/BrowserWindow.ts",
|
|
||||||
needle: "setResizable",
|
|
||||||
means: "BrowserWindow gained a resizability setter",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "dist/api/bun/core/BrowserWindow.ts",
|
|
||||||
needle: "minWidth",
|
|
||||||
means: "the window constructor gained min-size options (our JS clamp may be redundant)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "dist/api/bun/preload/dragRegions.ts",
|
|
||||||
needle: "resize",
|
|
||||||
means: "the drag-region preload now knows about resizing",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const CHECKLIST = `
|
|
||||||
┌────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ electrobun may now support window resizing natively. │
|
|
||||||
│ The synthesized resize is a STOPGAP and should be deleted. │
|
|
||||||
│ Full checklist: │
|
|
||||||
│ ui/src/app/skins/7like/window-resize/window-resize.ts │
|
|
||||||
└────────────────────────────────────────────────────────────────────────┘`;
|
|
||||||
|
|
||||||
function main(): void {
|
|
||||||
const pkgDir = path.join(import.meta.dir, "..", "node_modules", "electrobun");
|
|
||||||
if (!existsSync(pkgDir)) return; // not installed yet (fresh clone mid-install)
|
|
||||||
|
|
||||||
const hits: string[] = [];
|
|
||||||
|
|
||||||
// --- the shipped native library, whichever platform's is installed ---
|
|
||||||
for (const entry of readdirSync(pkgDir)) {
|
|
||||||
if (!entry.startsWith("dist-")) continue;
|
|
||||||
const dir = path.join(pkgDir, entry);
|
|
||||||
for (const file of readdirSync(dir)) {
|
|
||||||
if (!/^libNativeWrapper.*\.(so|dylib|dll)$/.test(file)) continue;
|
|
||||||
// latin1: we are searching for ASCII symbol names inside a binary, and
|
|
||||||
// utf-8 decoding would mangle bytes >0x7f (and can drop invalid
|
|
||||||
// sequences), which could hide a needle sitting next to one.
|
|
||||||
const text = readFileSync(path.join(dir, file), "latin1");
|
|
||||||
for (const needle of NATIVE_NEEDLES) {
|
|
||||||
if (text.includes(needle)) hits.push(`${entry}/${file} contains ${needle}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- the shipped TypeScript API ---
|
|
||||||
for (const { file, needle, means } of SOURCE_NEEDLES) {
|
|
||||||
const full = path.join(pkgDir, file);
|
|
||||||
if (!existsSync(full)) continue;
|
|
||||||
if (readFileSync(full, "utf8").includes(needle)) hits.push(`${file} mentions ${needle} — ${means}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const version = installedVersion(pkgDir);
|
|
||||||
|
|
||||||
if (hits.length) {
|
|
||||||
console.log(CHECKLIST);
|
|
||||||
console.log(` electrobun ${version ?? "?"} — what changed:`);
|
|
||||||
for (const hit of hits) console.log(` · ${hit}`);
|
|
||||||
console.log("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No resize support. Stay quiet unless the version moved out from under the
|
|
||||||
// hack, in which case one line is the reminder to look.
|
|
||||||
if (version && version !== VALIDATED_VERSION) {
|
|
||||||
console.log(
|
|
||||||
`[electrobun] ${VALIDATED_VERSION} → ${version}: no window-resize API found, so the ` +
|
|
||||||
`synthesized resize stays. Re-check, then bump VALIDATED_VERSION in ` +
|
|
||||||
`scripts/check-electrobun-resize.ts to silence this.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function installedVersion(pkgDir: string): string | null {
|
|
||||||
try {
|
|
||||||
return JSON.parse(readFileSync(path.join(pkgDir, "package.json"), "utf8")).version ?? null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A broken check must never break an install: swallow everything, exit 0.
|
|
||||||
try {
|
|
||||||
main();
|
|
||||||
} catch (error) {
|
|
||||||
// Deliberately quiet — this is advisory. Surface it only when run by hand.
|
|
||||||
if (process.env["DEBUG"]) console.error("[electrobun] resize check failed:", error);
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
// Programmatic HOVER test for the win7 title-bar close button.
|
|
||||||
//
|
|
||||||
// bun scripts/hover-probe.ts
|
|
||||||
//
|
|
||||||
// Why this exists: hovering the X paints 7.css's red glow ::before
|
|
||||||
// (box-shadow: 0 0 7px 3px #e68e75), and a stray red mark was reported on the
|
|
||||||
// window border's far edge. Hover cannot be captured by any tool on this
|
|
||||||
// machine — Firefox --screenshot has no pointer, `import -window` grabs
|
|
||||||
// whatever the real pointer is (not) doing — so this script makes hover a
|
|
||||||
// CLASS: it copies the built stylesheet with every `:hover` rewritten to
|
|
||||||
// `.simhover`, renders the app's exact frame markup with the X force-hovered,
|
|
||||||
// and then PIXEL-SCANS the regions where the artifact could live, printing a
|
|
||||||
// verdict table. Re-run after any change to the title-bar/hover styling; the
|
|
||||||
// artifact regions must stay at zero.
|
|
||||||
//
|
|
||||||
// Inherits shot.ts's constraints: dedicated Firefox profile (a running Firefox
|
|
||||||
// holds the default profile's lock), and the page must be written INTO the
|
|
||||||
// dist folder or strict_origin_policy silently unstyles it. Gecko vs WebKitGTK
|
|
||||||
// is fine here — box-shadow/border-radius compositing is engine-agnostic.
|
|
||||||
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
const ROOT = path.join(import.meta.dir, "..");
|
|
||||||
const DIST = path.join(ROOT, "ui", "dist", "ui", "browser");
|
|
||||||
const WORK = path.join(ROOT, ".shot");
|
|
||||||
|
|
||||||
function fail(message: string): never {
|
|
||||||
console.error(`[hover-probe] ERROR: ${message}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- stylesheet with :hover rewritten to .simhover ----
|
|
||||||
|
|
||||||
if (!existsSync(DIST)) fail(`no build output at ${DIST} — run: bun run build:ui`);
|
|
||||||
const styles = readdirSync(DIST).find((f) => f.startsWith("styles-") && f.endsWith(".css"));
|
|
||||||
if (!styles) fail(`no styles-*.css in ${DIST} — run: bun run build:ui`);
|
|
||||||
|
|
||||||
const css = await Bun.file(path.join(DIST, styles)).text();
|
|
||||||
// Global on purpose: EVERY hover rule becomes assertable markup. `:hover` has
|
|
||||||
// no partial-token hazard (no `:hoverish` exists in CSS).
|
|
||||||
await Bun.write(path.join(DIST, "_probe.css"), css.replaceAll(":hover", ".simhover"));
|
|
||||||
|
|
||||||
// ---- the probe page: the app's frame markup, X force-hovered ----
|
|
||||||
|
|
||||||
// Mirrors ui/src/app/skins/7like/app.html + title-bar.ts EXACTLY where it
|
|
||||||
// matters: .window.glass.w7-frame.active with .title-bar as a DIRECT child
|
|
||||||
// (7.css couples the glass with `.window.glass > .title-bar`), controls keyed
|
|
||||||
// by aria-label. Fixed-size windows instead of h-screen so two fit one shot.
|
|
||||||
const win = (id: string, hover: boolean) => `
|
|
||||||
<div id="${id}" class="window glass w7-frame active" style="width: 420px; height: 120px; position: relative;">
|
|
||||||
<div class="title-bar electrobun-webkit-app-region-drag">
|
|
||||||
<div class="title-bar-text">miniSCP</div>
|
|
||||||
<div class="title-bar-controls">
|
|
||||||
<button aria-label="Minimize"></button>
|
|
||||||
<button aria-label="Maximize"></button>
|
|
||||||
<button aria-label="Close"${hover ? ' class="simhover"' : ""}></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="window-body" style="height: 60px"></div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const page = `<!doctype html>
|
|
||||||
<html class="win7" lang="en">
|
|
||||||
<head><meta charset="utf-8"><link rel="stylesheet" href="_probe.css">
|
|
||||||
<style>
|
|
||||||
/* checkerboard ground so semi-transparent escapes are visible over ANY tone */
|
|
||||||
body { margin: 0; padding: 40px; background: #808080; }
|
|
||||||
.slot { margin-bottom: 40px; }
|
|
||||||
</style></head>
|
|
||||||
<body>
|
|
||||||
<div class="slot">${win("control", false)}</div>
|
|
||||||
<div class="slot">${win("hovered", true)}</div>
|
|
||||||
</body></html>`;
|
|
||||||
|
|
||||||
const pagePath = path.join(DIST, "_hover-probe.html");
|
|
||||||
await Bun.write(pagePath, page);
|
|
||||||
|
|
||||||
// ---- shoot ----
|
|
||||||
|
|
||||||
mkdirSync(WORK, { recursive: true });
|
|
||||||
const profile = path.join(WORK, "profile");
|
|
||||||
mkdirSync(profile, { recursive: true });
|
|
||||||
rmSync(path.join(profile, "parent.lock"), { force: true });
|
|
||||||
|
|
||||||
const out = path.join(WORK, "hover-probe.png");
|
|
||||||
const proc = Bun.spawnSync(
|
|
||||||
["firefox", "--headless", "--profile", profile, "--screenshot", out, "--window-size", "560,420", `file://${pagePath}`],
|
|
||||||
{ cwd: ROOT, stdout: "pipe", stderr: "pipe" },
|
|
||||||
);
|
|
||||||
if (!existsSync(out)) {
|
|
||||||
console.error(proc.stderr.toString().trim());
|
|
||||||
fail("firefox produced no image");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- pixel scan ----
|
|
||||||
|
|
||||||
// Page geometry (CSS px = image px; no DPR scaling in headless Firefox):
|
|
||||||
// body padding 40, window at x:40..460 (width 420), first window y:40..160,
|
|
||||||
// second y:200..320. The window's own 1px border is AT x=40 and x=459.
|
|
||||||
const WIN_X = 40;
|
|
||||||
const WIN_W = 420;
|
|
||||||
const CONTROL_Y = 40;
|
|
||||||
const HOVER_Y = 200;
|
|
||||||
const WIN_H = 120;
|
|
||||||
|
|
||||||
interface Region {
|
|
||||||
name: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
w: number;
|
|
||||||
h: number;
|
|
||||||
artifact: boolean; // true = red here is a DEFECT; false = red here is the intended glow
|
|
||||||
}
|
|
||||||
|
|
||||||
// The close button: last control, min-width 48px (3rem), controls end 6px
|
|
||||||
// (--w7-w-space padding) from the window's right edge — so the button spans
|
|
||||||
// roughly x = right-6-48 .. right-6. The glow reaches up to blur+spread = 10px
|
|
||||||
// beyond any edge of it.
|
|
||||||
const regions = (top: number): Region[] => [
|
|
||||||
// the window's right border column (2px wide: border + inset ring), full title-bar height
|
|
||||||
{ name: "right border (A)", x: WIN_X + WIN_W - 2, y: top + 2, w: 2, h: 30, artifact: true },
|
|
||||||
// OUTSIDE the window entirely, right of the border, title-bar height
|
|
||||||
{ name: "outside right (B)", x: WIN_X + WIN_W, y: top, w: 12, h: 34, artifact: true },
|
|
||||||
// OUTSIDE above the top edge / rounded corner
|
|
||||||
{ name: "outside top (B)", x: WIN_X + WIN_W - 80, y: top - 12, w: 92, h: 12, artifact: true },
|
|
||||||
// the gutter between the button and the border — glow HERE is the intended look
|
|
||||||
{ name: "gutter (glow ok)", x: WIN_X + WIN_W - 7, y: top + 2, w: 5, h: 30, artifact: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
async function redCount(img: string, r: Region): Promise<number> {
|
|
||||||
// txt: gives one "x,y: (r,g,b[,a])" line per pixel
|
|
||||||
const p = Bun.spawnSync(
|
|
||||||
["magick", img, "-crop", `${r.w}x${r.h}+${r.x}+${r.y}`, "+repage", "txt:"],
|
|
||||||
{ stdout: "pipe", stderr: "pipe" },
|
|
||||||
);
|
|
||||||
let count = 0;
|
|
||||||
for (const line of p.stdout.toString().split("\n").slice(1)) {
|
|
||||||
const m = /\((\d+),(\d+),(\d+)/.exec(line);
|
|
||||||
if (!m) continue;
|
|
||||||
const [red, green, blue] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
||||||
// "red" = the red channel dominating both others decisively. The Aero
|
|
||||||
// chrome is blue/white, so any genuine red here is the glow.
|
|
||||||
if (red > 120 && red > green + 30 && red > blue + 30) count++;
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("[hover-probe] region scan (red-dominant pixels):\n");
|
|
||||||
console.log(" region control hovered verdict");
|
|
||||||
let defects = 0;
|
|
||||||
for (const [i, region] of regions(CONTROL_Y).entries()) {
|
|
||||||
const hoverRegion = regions(HOVER_Y)[i];
|
|
||||||
const inControl = await redCount(out, region);
|
|
||||||
const inHover = await redCount(out, hoverRegion);
|
|
||||||
const introduced = inHover - inControl;
|
|
||||||
let verdict: string;
|
|
||||||
if (!region.artifact) verdict = introduced > 0 ? "glow present (intended)" : "no glow";
|
|
||||||
else if (introduced > 0) {
|
|
||||||
verdict = "DEFECT: red introduced by hover";
|
|
||||||
defects++;
|
|
||||||
} else verdict = "clean";
|
|
||||||
console.log(
|
|
||||||
` ${region.name.padEnd(20)}${String(inControl).padStart(7)}${String(inHover).padStart(9)} ${verdict}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
console.log(`\n[hover-probe] image: ${out}`);
|
|
||||||
if (defects > 0) {
|
|
||||||
console.log(`[hover-probe] ${defects} artifact region(s) show hover-introduced red`);
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
console.log("[hover-probe] clean");
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
// Programmatic POSITIONING + HIGHLIGHT test for the win7 context menu.
|
|
||||||
//
|
|
||||||
// bun scripts/menu-probe.ts
|
|
||||||
//
|
|
||||||
// Why this exists: 7.css declares `position: relative` on ul[role=menu] (it
|
|
||||||
// needs it for the gutter stripe and submenus), and the `skin` layer outranks
|
|
||||||
// `utilities` — so Tailwind's `.fixed` on the app's own menu lost, its
|
|
||||||
// [style.left/top.px] became FLOW offsets, and every right-click menu rendered
|
|
||||||
// below the viewport of the h-screen frame. Nothing about the idle window
|
|
||||||
// changes when that breaks, so the screenshot harness (scripts/shot.ts) cannot
|
|
||||||
// see it, and hover-probe.ts tests a different axis. This script renders the
|
|
||||||
// menu markup at declared coordinates and pixel-scans whether it actually
|
|
||||||
// landed there.
|
|
||||||
//
|
|
||||||
// It carries its own control: a second menu WITHOUT the `w7-ctx` marker class,
|
|
||||||
// i.e. the bug verbatim. That one must stay invisible — if it ever shows up,
|
|
||||||
// 7.css stopped forcing `relative` and this probe's premise is stale.
|
|
||||||
//
|
|
||||||
// It also checks the item HIGHLIGHT, using hover-probe.ts's trick (`:hover`
|
|
||||||
// rewritten to a `.simhover` class, since headless Firefox has no pointer):
|
|
||||||
// 7.css paints hovered items with a translucent gradient wash, and this app
|
|
||||||
// wants one flat colour. A gradient is detectable as vertical colour spread
|
|
||||||
// down a text-free strip of the item, so "is it flat?" is a measurement, not
|
|
||||||
// an opinion. What canNOT be measured here is the 1s afterglow fade — a
|
|
||||||
// temporal effect — but its cause is structural (`button::before` still has
|
|
||||||
// `content`), and this probe would show the glow's colour if the pseudo ever
|
|
||||||
// came back.
|
|
||||||
//
|
|
||||||
// Inherits shot.ts's constraints: dedicated Firefox profile (a running Firefox
|
|
||||||
// holds the default profile's lock), and the page must be written INTO the
|
|
||||||
// dist folder or strict_origin_policy silently unstyles it.
|
|
||||||
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
const ROOT = path.join(import.meta.dir, "..");
|
|
||||||
const DIST = path.join(ROOT, "ui", "dist", "ui", "browser");
|
|
||||||
const WORK = path.join(ROOT, ".shot");
|
|
||||||
|
|
||||||
function fail(message: string): never {
|
|
||||||
console.error(`[menu-probe] ERROR: ${message}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existsSync(DIST)) fail(`no build output at ${DIST} — run: bun run build:ui`);
|
|
||||||
const styles = readdirSync(DIST).find((f) => f.startsWith("styles-") && f.endsWith(".css"));
|
|
||||||
if (!styles) fail(`no styles-*.css in ${DIST} — run: bun run build:ui`);
|
|
||||||
|
|
||||||
// Hover as a CLASS: same rewrite hover-probe.ts uses, for the same reason —
|
|
||||||
// `--screenshot` has no pointer. `:hover` has no partial-token hazard.
|
|
||||||
const css = await Bun.file(path.join(DIST, styles)).text();
|
|
||||||
const PROBE_CSS = "_menu-probe.css";
|
|
||||||
await Bun.write(path.join(DIST, PROBE_CSS), css.replaceAll(":hover", ".simhover"));
|
|
||||||
|
|
||||||
// ---- the probe page ----
|
|
||||||
|
|
||||||
// Geometry (CSS px = image px; headless Firefox applies no DPR scaling).
|
|
||||||
const MENU_X = 200;
|
|
||||||
const MENU_Y = 100;
|
|
||||||
const CONTROL_X = 560;
|
|
||||||
const MENU_W = 200;
|
|
||||||
|
|
||||||
// Mirrors ui/src/app/skins/7like/context-menu.ts EXACTLY where it matters:
|
|
||||||
// ul[role=menu] > li[role=menuitem] > button, the `w7-ctx fixed` class pair,
|
|
||||||
// and left/top as inline pixel styles. `w7-ctx` is what the skinfix rule in
|
|
||||||
// 7like.css keys on; the control deliberately omits it.
|
|
||||||
const menu = (x: number, marker: boolean) => `
|
|
||||||
<ul role="menu" tabindex="-1"
|
|
||||||
class="${marker ? "w7-ctx " : ""}fixed z-50 outline-none"
|
|
||||||
style="left: ${x}px; top: ${MENU_Y}px; width: ${MENU_W}px">
|
|
||||||
<li role="menuitem"><button type="button"${marker ? ' class="simhover"' : ""}>Send to Other Panel</button></li>
|
|
||||||
<li role="menuitem" class="has-divider"><button type="button">Rename</button></li>
|
|
||||||
<li role="menuitem"><button type="button">Copy Path</button></li>
|
|
||||||
<li role="menuitem" aria-disabled="true"><button type="button">Delete</button></li>
|
|
||||||
</ul>`;
|
|
||||||
|
|
||||||
// The frame reproduces the app's: .window.w7-frame is a 100vh flex column and
|
|
||||||
// <app-context-menu> is one of its LAST children, so a menu that falls back to
|
|
||||||
// static flow lands past the bottom edge — which is the whole bug.
|
|
||||||
// The body gets an explicit white background (inline, so no layer can touch
|
|
||||||
// it) purely so "is anything drawn here?" is an unambiguous question.
|
|
||||||
const page = `<!doctype html>
|
|
||||||
<html class="win7" lang="en">
|
|
||||||
<head><meta charset="utf-8"><link rel="stylesheet" href="${PROBE_CSS}">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="window glass w7-frame active" style="display: flex; flex-direction: column; height: 100vh">
|
|
||||||
<div class="title-bar">
|
|
||||||
<div class="title-bar-text">miniSCP</div>
|
|
||||||
<div class="title-bar-controls"><button aria-label="Close"></button></div>
|
|
||||||
</div>
|
|
||||||
<div class="window-body" style="flex: 1; min-height: 0; background: #fff"></div>
|
|
||||||
${menu(MENU_X, true)}
|
|
||||||
${menu(CONTROL_X, false)}
|
|
||||||
</div>
|
|
||||||
</body></html>`;
|
|
||||||
|
|
||||||
const pagePath = path.join(DIST, "_menu-probe.html");
|
|
||||||
await Bun.write(pagePath, page);
|
|
||||||
|
|
||||||
// ---- shoot ----
|
|
||||||
|
|
||||||
mkdirSync(WORK, { recursive: true });
|
|
||||||
const profile = path.join(WORK, "profile");
|
|
||||||
mkdirSync(profile, { recursive: true });
|
|
||||||
rmSync(path.join(profile, "parent.lock"), { force: true });
|
|
||||||
|
|
||||||
const out = path.join(WORK, "menu-probe.png");
|
|
||||||
const proc = Bun.spawnSync(
|
|
||||||
["firefox", "--headless", "--profile", profile, "--screenshot", out, "--window-size", "800,400", `file://${pagePath}`],
|
|
||||||
{ cwd: ROOT, stdout: "pipe", stderr: "pipe" },
|
|
||||||
);
|
|
||||||
if (!existsSync(out)) {
|
|
||||||
console.error(proc.stderr.toString().trim());
|
|
||||||
fail("firefox produced no image");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- pixel scan ----
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count pixels that are visibly NOT the white window body: the menu's border
|
|
||||||
* (#0006 over white ≈ #999), its shadow and its black item text all qualify.
|
|
||||||
* A menu that rendered where it was told fills this with hundreds of them; an
|
|
||||||
* empty white region scores 0.
|
|
||||||
*/
|
|
||||||
async function inkCount(img: string, x: number, y: number, w: number, h: number): Promise<number> {
|
|
||||||
const p = Bun.spawnSync(["magick", img, "-crop", `${w}x${h}+${x}+${y}`, "+repage", "txt:"], {
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
});
|
|
||||||
let count = 0;
|
|
||||||
for (const line of p.stdout.toString().split("\n").slice(1)) {
|
|
||||||
const m = /\((\d+),(\d+),(\d+)/.exec(line);
|
|
||||||
if (!m) continue;
|
|
||||||
const [r, g, b] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
||||||
if (Math.max(r, g, b) < 200) count++;
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Colour spread down a text-free strip of the first (force-hovered) item: the
|
|
||||||
* ul's own 2px padding + the item's 1px border put the item's background at
|
|
||||||
* x >= menu+3, and its 32px left padding keeps label text out to menu+32. So a
|
|
||||||
* column at menu+5..menu+26 is pure fill — except for the gutter stripe, which
|
|
||||||
* 7.css puts at left: 28px. A flat colour reads 0 spread; a gradient does not.
|
|
||||||
*/
|
|
||||||
async function fillSpread(
|
|
||||||
img: string,
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
w: number,
|
|
||||||
h: number,
|
|
||||||
): Promise<{ spread: number; mean: [number, number, number] }> {
|
|
||||||
const p = Bun.spawnSync(["magick", img, "-crop", `${w}x${h}+${x}+${y}`, "+repage", "txt:"], {
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
});
|
|
||||||
const lo: number[] = [255, 255, 255];
|
|
||||||
const hi: number[] = [0, 0, 0];
|
|
||||||
const sum = [0, 0, 0];
|
|
||||||
let n = 0;
|
|
||||||
for (const line of p.stdout.toString().split("\n").slice(1)) {
|
|
||||||
const m = /\((\d+),(\d+),(\d+)/.exec(line);
|
|
||||||
if (!m) continue;
|
|
||||||
for (let c = 0; c < 3; c++) {
|
|
||||||
const v = Number(m[c + 1]);
|
|
||||||
lo[c] = Math.min(lo[c], v);
|
|
||||||
hi[c] = Math.max(hi[c], v);
|
|
||||||
sum[c] += v;
|
|
||||||
}
|
|
||||||
n++;
|
|
||||||
}
|
|
||||||
if (!n) return { spread: -1, mean: [0, 0, 0] };
|
|
||||||
return {
|
|
||||||
spread: Math.max(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]),
|
|
||||||
mean: [Math.round(sum[0] / n), Math.round(sum[1] / n), Math.round(sum[2] / n)],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inset by 2px so the scan cannot straddle the region's own edge.
|
|
||||||
const BOX = { w: MENU_W - 4, h: 60 };
|
|
||||||
const placed = await inkCount(out, MENU_X + 2, MENU_Y + 2, BOX.w, BOX.h);
|
|
||||||
const control = await inkCount(out, CONTROL_X + 2, MENU_Y + 2, BOX.w, BOX.h);
|
|
||||||
|
|
||||||
console.log("[menu-probe] ink at the declared coordinates:\n");
|
|
||||||
console.log(" menu px verdict");
|
|
||||||
console.log(
|
|
||||||
` w7-ctx (position: fixed)${String(placed).padStart(6)} ${placed > 0 ? "placed" : "MISSING: menu did not render where it was told"}`,
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
` control (no marker) ${String(control).padStart(6)} ${control > 0 ? "NOTE: 7.css no longer forces relative — premise stale" : "off-viewport, as expected"}`,
|
|
||||||
);
|
|
||||||
// The force-hovered first item: fill strip, and the same strip on the item
|
|
||||||
// below it as a not-hovered reference (which must stay the menu surface).
|
|
||||||
const FILL = { x: MENU_X + 5, w: 21, h: 14 };
|
|
||||||
const hovered = await fillSpread(out, FILL.x, MENU_Y + 7, FILL.w, FILL.h);
|
|
||||||
const resting = await fillSpread(out, FILL.x, MENU_Y + 34, FILL.w, FILL.h);
|
|
||||||
const FLAT_MAX = 6; // antialiasing tolerance; 7.css's wash spreads far wider
|
|
||||||
|
|
||||||
console.log("\n[menu-probe] item highlight (force-hovered vs resting):\n");
|
|
||||||
console.log(" item mean rgb spread verdict");
|
|
||||||
console.log(
|
|
||||||
` hovered (.simhover) ${hovered.mean.join(",").padEnd(15)} ${String(hovered.spread).padStart(6)} ${
|
|
||||||
hovered.spread <= FLAT_MAX ? "flat" : "GRADIENT: highlight is not a flat colour"
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
console.log(` resting ${resting.mean.join(",").padEnd(15)} ${String(resting.spread).padStart(6)} reference`);
|
|
||||||
|
|
||||||
console.log(`\n[menu-probe] image: ${out}`);
|
|
||||||
|
|
||||||
let failed = false;
|
|
||||||
if (placed === 0) {
|
|
||||||
console.log("[menu-probe] the context menu is positioned in FLOW again — see 7like.css .w7-ctx");
|
|
||||||
failed = true;
|
|
||||||
}
|
|
||||||
if (hovered.spread > FLAT_MAX) {
|
|
||||||
console.log("[menu-probe] the item highlight regained a gradient — see the menu block in 7like.css");
|
|
||||||
failed = true;
|
|
||||||
}
|
|
||||||
if (hovered.mean.join(",") === resting.mean.join(",")) {
|
|
||||||
console.log("[menu-probe] hovered and resting items are identical — the highlight is not painting at all");
|
|
||||||
failed = true;
|
|
||||||
}
|
|
||||||
if (failed) process.exit(2);
|
|
||||||
console.log("[menu-probe] clean");
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
// Programmatic test for the synthesized window resize's frame arithmetic.
|
|
||||||
//
|
|
||||||
// bun scripts/resize-math-probe.ts
|
|
||||||
//
|
|
||||||
// Why this exists: the win7 skin resizes itself by dragging handles and moving
|
|
||||||
// the native frame (see ui/src/app/skins/7like/window-resize/window-resize.ts —
|
|
||||||
// a STOPGAP with a removal checklist). Pointer drags cannot be synthesized
|
|
||||||
// headlessly on this machine, so the interaction itself needs a human; but the
|
|
||||||
// part most likely to be subtly wrong is pure arithmetic — eight edges, four of
|
|
||||||
// which move the window's ORIGIN as well as its size, plus a minimum-size clamp
|
|
||||||
// that must not let the anchored edge drift. That is all in `resize-math.ts`,
|
|
||||||
// which is deliberately dependency-free so this script can drive it directly.
|
|
||||||
//
|
|
||||||
// The failure this guards against is not "the window does not resize" (obvious
|
|
||||||
// on sight) but "dragging the west edge inward past the minimum walks the whole
|
|
||||||
// window across the screen" — correct-looking until you hit the limit.
|
|
||||||
//
|
|
||||||
// This covers MORE than the UI currently mounts, deliberately: only the
|
|
||||||
// bottom-right grip is wired up (an invisible handle over a caption button
|
|
||||||
// steals its hover — see the component), but the math stays general so that
|
|
||||||
// restoring a side later is one line against already-proven arithmetic.
|
|
||||||
import {
|
|
||||||
MIN_HEIGHT,
|
|
||||||
MIN_WIDTH,
|
|
||||||
RESIZE_EDGES,
|
|
||||||
type Frame,
|
|
||||||
type ResizeEdge,
|
|
||||||
resizeFrame,
|
|
||||||
} from "../ui/src/app/skins/7like/window-resize/resize-math";
|
|
||||||
|
|
||||||
const START: Frame = { x: 100, y: 200, width: 1000, height: 800 };
|
|
||||||
|
|
||||||
interface Case {
|
|
||||||
name: string;
|
|
||||||
edge: ResizeEdge;
|
|
||||||
dx: number;
|
|
||||||
dy: number;
|
|
||||||
want: Frame;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every expectation is written out in full rather than computed, so a bug in
|
|
||||||
// resizeFrame cannot be mirrored into the expectation by sharing its algebra.
|
|
||||||
const CASES: Case[] = [
|
|
||||||
// --- edges: each moves only what it owns ---
|
|
||||||
{ name: "east grows width only", edge: "e", dx: 50, dy: 999, want: { x: 100, y: 200, width: 1050, height: 800 } },
|
|
||||||
{ name: "south grows height only", edge: "s", dx: 999, dy: 40, want: { x: 100, y: 200, width: 1000, height: 840 } },
|
|
||||||
{ name: "west moves x AND width", edge: "w", dx: -30, dy: 0, want: { x: 70, y: 200, width: 1030, height: 800 } },
|
|
||||||
{ name: "north moves y AND height", edge: "n", dx: 0, dy: -25, want: { x: 100, y: 175, width: 1000, height: 825 } },
|
|
||||||
// --- corners: two axes at once ---
|
|
||||||
{ name: "south-east", edge: "se", dx: 10, dy: 20, want: { x: 100, y: 200, width: 1010, height: 820 } },
|
|
||||||
{ name: "north-west", edge: "nw", dx: -10, dy: -20, want: { x: 90, y: 180, width: 1010, height: 820 } },
|
|
||||||
{ name: "north-east", edge: "ne", dx: 10, dy: -20, want: { x: 100, y: 180, width: 1010, height: 820 } },
|
|
||||||
{ name: "south-west", edge: "sw", dx: -10, dy: 20, want: { x: 90, y: 200, width: 1010, height: 820 } },
|
|
||||||
// --- the anchored edge must not drift once clamped ---
|
|
||||||
// west dragged far east: width parks at MIN, and x parks where the EAST edge
|
|
||||||
// (100 + 1000 = 1100) minus MIN puts it. If x kept tracking dx, the window
|
|
||||||
// would slide away while appearing frozen.
|
|
||||||
{
|
|
||||||
name: "west clamps, east edge holds",
|
|
||||||
edge: "w",
|
|
||||||
dx: 5000,
|
|
||||||
dy: 0,
|
|
||||||
want: { x: 1100 - MIN_WIDTH, y: 200, width: MIN_WIDTH, height: 800 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "north clamps, south edge holds",
|
|
||||||
edge: "n",
|
|
||||||
dx: 0,
|
|
||||||
dy: 5000,
|
|
||||||
want: { x: 100, y: 1000 - MIN_HEIGHT, width: 1000, height: MIN_HEIGHT },
|
|
||||||
},
|
|
||||||
// east/south clamp the size but never touch the origin
|
|
||||||
{ name: "east clamps, origin holds", edge: "e", dx: -5000, dy: 0, want: { x: 100, y: 200, width: MIN_WIDTH, height: 800 } },
|
|
||||||
{ name: "south clamps, origin holds", edge: "s", dx: 0, dy: -5000, want: { x: 100, y: 200, width: 1000, height: MIN_HEIGHT } },
|
|
||||||
// a corner clamping on BOTH axes at once
|
|
||||||
{
|
|
||||||
name: "north-west clamps both",
|
|
||||||
edge: "nw",
|
|
||||||
dx: 5000,
|
|
||||||
dy: 5000,
|
|
||||||
want: { x: 1100 - MIN_WIDTH, y: 1000 - MIN_HEIGHT, width: MIN_WIDTH, height: MIN_HEIGHT },
|
|
||||||
},
|
|
||||||
// a zero drag must be a no-op, or a click on a handle would nudge the window
|
|
||||||
{ name: "zero drag is identity", edge: "se", dx: 0, dy: 0, want: START },
|
|
||||||
];
|
|
||||||
|
|
||||||
const fmt = (f: Frame) => `${f.x},${f.y} ${f.width}x${f.height}`;
|
|
||||||
|
|
||||||
console.log("[resize-math] frame arithmetic:\n");
|
|
||||||
console.log(" case edge got verdict");
|
|
||||||
let failed = 0;
|
|
||||||
for (const c of CASES) {
|
|
||||||
const got = resizeFrame(c.edge, START, c.dx, c.dy);
|
|
||||||
const ok = fmt(got) === fmt(c.want);
|
|
||||||
if (!ok) failed++;
|
|
||||||
console.log(
|
|
||||||
` ${c.name.padEnd(32)} ${c.edge.padEnd(5)} ${fmt(got).padEnd(22)} ${ok ? "ok" : `FAIL: want ${fmt(c.want)}`}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Structural check: every edge must be exercised above, so adding a ninth
|
|
||||||
// handle without a case cannot slip through.
|
|
||||||
const covered = new Set(CASES.map((c) => c.edge));
|
|
||||||
const missing = RESIZE_EDGES.filter((e) => !covered.has(e));
|
|
||||||
if (missing.length) {
|
|
||||||
console.log(`\n[resize-math] no case covers: ${missing.join(", ")}`);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The clamp must never produce a frame smaller than the minimum, whatever the
|
|
||||||
// drag — a cheap sweep over every edge and a range of deltas.
|
|
||||||
for (const edge of RESIZE_EDGES) {
|
|
||||||
for (const d of [-9999, -1000, -1, 0, 1, 1000, 9999]) {
|
|
||||||
const got = resizeFrame(edge, START, d, d);
|
|
||||||
if (got.width < MIN_WIDTH || got.height < MIN_HEIGHT) {
|
|
||||||
console.log(`\n[resize-math] ${edge} at delta ${d} produced ${fmt(got)} — under the minimum`);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failed) {
|
|
||||||
console.log(`\n[resize-math] ${failed} failure(s)`);
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
console.log("\n[resize-math] clean");
|
|
||||||
-106
@@ -1,106 +0,0 @@
|
|||||||
// Headless screenshot of the skin harness, so the Win7 look can be reviewed
|
|
||||||
// visually during development rather than inferred from a clean build log.
|
|
||||||
//
|
|
||||||
// bun scripts/shot.ts [--dark] [--width 1200] [--height 1400] [--out path.png]
|
|
||||||
// bun scripts/shot.ts --url http://localhost:4200 (any page, not just the harness)
|
|
||||||
//
|
|
||||||
// Firefox is used because it ships on this machine and has a built-in
|
|
||||||
// --screenshot mode; no Chromium/puppeteer download is needed. Two constraints
|
|
||||||
// it imposes:
|
|
||||||
// - a DEDICATED PROFILE is mandatory. A running Firefox holds a lock on the
|
|
||||||
// default profile and the headless run aborts with "Firefox is already
|
|
||||||
// running, but is not responding".
|
|
||||||
// - it renders with Gecko, NOT the WebKitGTK engine the app actually uses.
|
|
||||||
// Good enough for skin fidelity (layout, colour, gradients, borders);
|
|
||||||
// useless for engine-specific behaviour or native window transparency.
|
|
||||||
// Real confirmation is still `bun run start`.
|
|
||||||
//
|
|
||||||
// The harness links the REAL hashed stylesheet from ui/dist (produced by the
|
|
||||||
// normal `bun run build:ui`), so there is no second build pipeline to keep in
|
|
||||||
// sync.
|
|
||||||
//
|
|
||||||
// The composed page is written INTO that same dist folder, and this is not
|
|
||||||
// incidental: Firefox's security.fileuri.strict_origin_policy stops a file://
|
|
||||||
// document from loading a stylesheet that lives in a different directory. Host
|
|
||||||
// the page elsewhere and it renders completely unstyled — which looks exactly
|
|
||||||
// like "the skin is broken" rather than "the CSS never loaded". Keeping them
|
|
||||||
// co-located also lets media/ fonts resolve.
|
|
||||||
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
const ROOT = path.join(import.meta.dir, "..");
|
|
||||||
const DIST = path.join(ROOT, "ui", "dist", "ui", "browser");
|
|
||||||
const HARNESS = path.join(ROOT, "ui", "dev", "harness.html");
|
|
||||||
const WORK = path.join(ROOT, ".shot");
|
|
||||||
|
|
||||||
function arg(name: string): string | undefined {
|
|
||||||
const i = process.argv.indexOf(`--${name}`);
|
|
||||||
return i >= 0 ? process.argv[i + 1] : undefined;
|
|
||||||
}
|
|
||||||
const flag = (name: string) => process.argv.includes(`--${name}`);
|
|
||||||
|
|
||||||
function fail(message: string): never {
|
|
||||||
console.error(`[shot] ERROR: ${message}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- resolve the built stylesheet ----
|
|
||||||
|
|
||||||
if (!existsSync(DIST)) fail(`no build output at ${DIST} — run: bun run build:ui`);
|
|
||||||
const styles = readdirSync(DIST).find((f) => f.startsWith("styles-") && f.endsWith(".css"));
|
|
||||||
if (!styles) fail(`no styles-*.css in ${DIST} — run: bun run build:ui`);
|
|
||||||
|
|
||||||
// ---- compose the page ----
|
|
||||||
|
|
||||||
mkdirSync(WORK, { recursive: true });
|
|
||||||
const profile = path.join(WORK, "profile");
|
|
||||||
mkdirSync(profile, { recursive: true });
|
|
||||||
|
|
||||||
const url = arg("url");
|
|
||||||
let target: string;
|
|
||||||
|
|
||||||
if (url) {
|
|
||||||
target = url;
|
|
||||||
} else {
|
|
||||||
const dark = flag("dark");
|
|
||||||
let html = await Bun.file(HARNESS).text();
|
|
||||||
// replaceAll, not replace: the harness documents its own {{STYLES}} token in
|
|
||||||
// a header comment that precedes the <link>, so a first-match replace
|
|
||||||
// substitutes the comment and silently leaves the stylesheet unlinked.
|
|
||||||
html = html.replaceAll("{{STYLES}}", styles); // relative: page sits next to it
|
|
||||||
// The preload bakes these attributes in the real app; do it by hand here.
|
|
||||||
if (dark) html = html.replace('<html class="win7"', '<html class="win7" data-theme="dark"');
|
|
||||||
const page = path.join(DIST, dark ? "_harness.dark.html" : "_harness.light.html");
|
|
||||||
await Bun.write(page, html);
|
|
||||||
target = `file://${page}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- shoot ----
|
|
||||||
|
|
||||||
const out = path.resolve(arg("out") ?? path.join(WORK, flag("dark") ? "dark.png" : "light.png"));
|
|
||||||
const width = arg("width") ?? "1200";
|
|
||||||
const height = arg("height") ?? "1500";
|
|
||||||
|
|
||||||
// A stale lock from a killed run makes every later shot fail confusingly.
|
|
||||||
rmSync(path.join(profile, "parent.lock"), { force: true });
|
|
||||||
|
|
||||||
const proc = Bun.spawnSync(
|
|
||||||
[
|
|
||||||
"firefox",
|
|
||||||
"--headless",
|
|
||||||
"--profile",
|
|
||||||
profile,
|
|
||||||
"--screenshot",
|
|
||||||
out,
|
|
||||||
"--window-size",
|
|
||||||
`${width},${height}`,
|
|
||||||
target,
|
|
||||||
],
|
|
||||||
{ cwd: ROOT, stdout: "pipe", stderr: "pipe" },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!existsSync(out)) {
|
|
||||||
console.error(proc.stderr.toString().trim());
|
|
||||||
fail("firefox produced no image (see stderr above)");
|
|
||||||
}
|
|
||||||
console.log(`[shot] ${out}`);
|
|
||||||
Binary file not shown.
+3
-29
@@ -15,13 +15,7 @@ import { migrateLegacyDataDir } from "./store/paths";
|
|||||||
import { SettingsStore } from "./store/settings";
|
import { SettingsStore } from "./store/settings";
|
||||||
import { AppTray } from "./tray";
|
import { AppTray } from "./tray";
|
||||||
import { TransferEngine } from "./transfers/engine";
|
import { TransferEngine } from "./transfers/engine";
|
||||||
import {
|
import { buildPreload, chromeOptions, showWhenReady } from "./windows";
|
||||||
buildPreload,
|
|
||||||
chromeOptions,
|
|
||||||
initSessionSkin,
|
|
||||||
showWhenReady,
|
|
||||||
trackMaximizeState,
|
|
||||||
} from "./windows";
|
|
||||||
|
|
||||||
const PROD_URL = "views://app/index.html";
|
const PROD_URL = "views://app/index.html";
|
||||||
|
|
||||||
@@ -60,19 +54,7 @@ const events = {
|
|||||||
openConnectionManager: () => {},
|
openConnectionManager: () => {},
|
||||||
showAbout: () => {},
|
showAbout: () => {},
|
||||||
};
|
};
|
||||||
// Late-bound for the same reason events is: `win` below takes this rpc in its
|
const rpc = createAppRPC({ registry, engine, store, settings, edits, watcher, events });
|
||||||
// constructor, so it cannot exist yet. Only the win7 title bar reads it.
|
|
||||||
let mainWin: BrowserWindow | undefined;
|
|
||||||
const rpc = createAppRPC({
|
|
||||||
registry,
|
|
||||||
engine,
|
|
||||||
store,
|
|
||||||
settings,
|
|
||||||
edits,
|
|
||||||
watcher,
|
|
||||||
mainWindow: () => mainWin,
|
|
||||||
events,
|
|
||||||
});
|
|
||||||
// late-bound like events: a watched local dir changed -> tell the main webview
|
// late-bound like events: a watched local dir changed -> tell the main webview
|
||||||
watcher.onChange = (dir) => rpc.send.pathRefresh({ sessionId: "local", dir });
|
watcher.onChange = (dir) => rpc.send.pathRefresh({ sessionId: "local", dir });
|
||||||
const url = await getMainViewUrl();
|
const url = await getMainViewUrl();
|
||||||
@@ -82,11 +64,6 @@ const url = await getMainViewUrl();
|
|||||||
// fresh via the saveSettings flow below.
|
// fresh via the saveSettings flow below.
|
||||||
let currentSettings = await settings.load();
|
let currentSettings = await settings.load();
|
||||||
setBackendLocale(currentSettings.locale); // native menus/errors follow the UI language
|
setBackendLocale(currentSettings.locale); // native menus/errors follow the UI language
|
||||||
// Freeze the skin for this process BEFORE any window is constructed. It picks
|
|
||||||
// a component tree and a native frame, neither of which can change on a live
|
|
||||||
// window, so the persisted value may only take effect at the next start — see
|
|
||||||
// initSessionSkin.
|
|
||||||
initSessionSkin(currentSettings.skin);
|
|
||||||
|
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
title: BRANDING.appName,
|
title: BRANDING.appName,
|
||||||
@@ -97,8 +74,7 @@ const win = new BrowserWindow({
|
|||||||
x: 120,
|
x: 120,
|
||||||
y: 120,
|
y: 120,
|
||||||
},
|
},
|
||||||
// Frame decision lives in chromeOptions — see windows.ts. Constructor-only,
|
// frame decision lives in chromeOptions (windows.ts); constructor-only
|
||||||
// and read from the pinned session skin, not from currentSettings.
|
|
||||||
...chromeOptions(),
|
...chromeOptions(),
|
||||||
// lets the UI pick the fused layout + theme before first paint
|
// lets the UI pick the fused layout + theme before first paint
|
||||||
preload: buildPreload("main", currentSettings),
|
preload: buildPreload("main", currentSettings),
|
||||||
@@ -107,8 +83,6 @@ const win = new BrowserWindow({
|
|||||||
hidden: true,
|
hidden: true,
|
||||||
});
|
});
|
||||||
showWhenReady(win);
|
showWhenReady(win);
|
||||||
mainWin = win;
|
|
||||||
trackMaximizeState(win, (maximized) => rpc.send.windowState({ maximized }));
|
|
||||||
|
|
||||||
const editors = new EditorWindows({
|
const editors = new EditorWindows({
|
||||||
registry,
|
registry,
|
||||||
|
|||||||
@@ -70,8 +70,6 @@ export class ManagerWindow {
|
|||||||
this.close();
|
this.close();
|
||||||
},
|
},
|
||||||
onClose: () => this.close(),
|
onClose: () => this.close(),
|
||||||
// late-bound: the window takes this rpc in its constructor
|
|
||||||
window: () => this.win ?? undefined,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+1
-97
@@ -1,4 +1,4 @@
|
|||||||
import { BrowserView, type BrowserWindow, Updater, Utils } from "electrobun/bun";
|
import { BrowserView, Updater, Utils } from "electrobun/bun";
|
||||||
import type { AppRPC, EditorOpenContext } from "../shared/rpc";
|
import type { AppRPC, EditorOpenContext } from "../shared/rpc";
|
||||||
import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types";
|
import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types";
|
||||||
import { readFileText, writeFileText, type EditManager } from "./edits";
|
import { readFileText, writeFileText, type EditManager } from "./edits";
|
||||||
@@ -24,12 +24,6 @@ export interface AppDeps {
|
|||||||
settings: SettingsStore;
|
settings: SettingsStore;
|
||||||
edits: EditManager;
|
edits: EditManager;
|
||||||
watcher: LocalDirWatcher;
|
watcher: LocalDirWatcher;
|
||||||
/**
|
|
||||||
* The main BrowserWindow, for the win7 skin's custom title-bar controls.
|
|
||||||
* Lazy for the same reason events is late-bound: the window takes this rpc
|
|
||||||
* instance in its constructor, so it does not exist yet at createAppRPC time.
|
|
||||||
*/
|
|
||||||
mainWindow: () => BrowserWindow | undefined;
|
|
||||||
/** Assigned after the rpc object exists (it needs rpc.send). */
|
/** Assigned after the rpc object exists (it needs rpc.send). */
|
||||||
events: {
|
events: {
|
||||||
sessionClosed: (info: { sessionId: string; reason: string }) => void;
|
sessionClosed: (info: { sessionId: string; reason: string }) => void;
|
||||||
@@ -49,78 +43,6 @@ async function wrap<T extends object>(fn: () => Promise<T> | T): Promise<Result<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Window-control requests for the win7 skin's custom title bar. Shared by all
|
|
||||||
* three window kinds; each passes a getter for ITS OWN window.
|
|
||||||
*
|
|
||||||
* The getter is lazy because the main window's rpc instance is constructed
|
|
||||||
* before the BrowserWindow exists (the window takes the rpc in its
|
|
||||||
* constructor) — same late-binding reason as AppDeps.events. Under the modern
|
|
||||||
* skin nothing calls these; they are harmless no-ops if the window is gone.
|
|
||||||
*/
|
|
||||||
export function windowControlHandlers(
|
|
||||||
getWindow: () => BrowserWindow | undefined,
|
|
||||||
/**
|
|
||||||
* What the title bar's X does. Defaults to a raw `win.close()`. Editor
|
|
||||||
* windows MUST pass their confirmed-close hook instead — the native close
|
|
||||||
* is not cancellable, so a raw close there would skip the dirty prompt
|
|
||||||
* (see gotchas.md "Window `close` is not cancellable").
|
|
||||||
*/
|
|
||||||
onClose: (win: BrowserWindow | undefined) => void = (win) => win?.close(),
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
windowMinimize: () =>
|
|
||||||
wrap(() => {
|
|
||||||
getWindow()?.minimize();
|
|
||||||
return {};
|
|
||||||
}),
|
|
||||||
|
|
||||||
windowToggleMaximize: () =>
|
|
||||||
wrap(() => {
|
|
||||||
const win = getWindow();
|
|
||||||
if (!win) return { maximized: false };
|
|
||||||
// isMaximized is a synchronous FFI read, so this stays one round trip
|
|
||||||
const next = !win.isMaximized();
|
|
||||||
if (next) win.maximize();
|
|
||||||
else win.unmaximize();
|
|
||||||
return { maximized: next };
|
|
||||||
}),
|
|
||||||
|
|
||||||
windowClose: () =>
|
|
||||||
wrap(() => {
|
|
||||||
onClose(getWindow());
|
|
||||||
return {};
|
|
||||||
}),
|
|
||||||
|
|
||||||
windowIsMaximized: () => wrap(() => ({ maximized: getWindow()?.isMaximized() ?? false })),
|
|
||||||
|
|
||||||
// ---- STOPGAP: synthesized resize ----
|
|
||||||
// A frameless GTK window has no resize borders, and electrobun exposes no
|
|
||||||
// native resize gesture (it has startWindowMove and nothing else — the
|
|
||||||
// shipped libNativeWrapper does not even link gtk_window_begin_resize_drag).
|
|
||||||
// So the webview drags its own handles and moves the frame itself. Both are
|
|
||||||
// synchronous FFI reads/writes, like isMaximized above, so each drag tick
|
|
||||||
// stays one round trip. DELETE WITH THE REST OF THE HACK — the checklist is
|
|
||||||
// in ui/src/app/skins/7like/window-resize/window-resize.ts.
|
|
||||||
windowGetFrame: () =>
|
|
||||||
wrap(() => getWindow()?.getFrame() ?? { x: 0, y: 0, width: 0, height: 0 }),
|
|
||||||
|
|
||||||
// Params are annotated from the schema rather than inferred: this factory
|
|
||||||
// returns a bare object literal, so it gets none of the contextual typing
|
|
||||||
// the inline handler sets inside defineRPC<AppRPC> enjoy.
|
|
||||||
windowSetFrame: ({
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
}: AppRPC["bun"]["requests"]["windowSetFrame"]["params"]) =>
|
|
||||||
wrap(() => {
|
|
||||||
getWindow()?.setFrame(x, y, width, height);
|
|
||||||
return {};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Connection-store requests, shared by the main and manager window rpcs. */
|
/** Connection-store requests, shared by the main and manager window rpcs. */
|
||||||
function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void) {
|
function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void) {
|
||||||
return {
|
return {
|
||||||
@@ -199,7 +121,6 @@ export function createAppRPC({
|
|||||||
settings,
|
settings,
|
||||||
edits,
|
edits,
|
||||||
watcher,
|
watcher,
|
||||||
mainWindow,
|
|
||||||
events,
|
events,
|
||||||
}: AppDeps) {
|
}: AppDeps) {
|
||||||
return BrowserView.defineRPC<AppRPC>({
|
return BrowserView.defineRPC<AppRPC>({
|
||||||
@@ -297,9 +218,6 @@ export function createAppRPC({
|
|||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// ---- custom window chrome (win7 skin) ----
|
|
||||||
...windowControlHandlers(mainWindow),
|
|
||||||
|
|
||||||
// ---- connection store ----
|
// ---- connection store ----
|
||||||
...connectionStoreHandlers(store, () => events.connectionsChanged()),
|
...connectionStoreHandlers(store, () => events.connectionsChanged()),
|
||||||
|
|
||||||
@@ -429,8 +347,6 @@ export interface EditorRpcHooks {
|
|||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
/** Webview resolved its dirty prompt: close without the native fallback. */
|
/** Webview resolved its dirty prompt: close without the native fallback. */
|
||||||
onCloseConfirmed: () => void;
|
onCloseConfirmed: () => void;
|
||||||
/** This window, for the win7 title-bar controls (late-bound like the rest). */
|
|
||||||
window: () => BrowserWindow | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -466,12 +382,6 @@ export function createEditorRPC(
|
|||||||
hooks.onCloseConfirmed();
|
hooks.onCloseConfirmed();
|
||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// win7 skin: this window is frameless, so the webview draws the
|
|
||||||
// chrome. The X routes through onCloseConfirmed (suppressPrompt +
|
|
||||||
// close), exactly like editorClose — the editor UI runs its dirty
|
|
||||||
// prompt BEFORE calling this, same as the ⌘W path.
|
|
||||||
...windowControlHandlers(hooks.window, () => hooks.onCloseConfirmed()),
|
|
||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
editorState: (state) => hooks.onDraft(state),
|
editorState: (state) => hooks.onDraft(state),
|
||||||
@@ -487,8 +397,6 @@ export interface ManagerRpcHooks {
|
|||||||
onConnect: (id: string) => void;
|
onConnect: (id: string) => void;
|
||||||
onOpenStack: (id: string) => void;
|
onOpenStack: (id: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** This window, for the win7 title-bar controls (late-bound like the rest). */
|
|
||||||
window: () => BrowserWindow | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** RPC for the single connection-manager window (per-window instance). */
|
/** RPC for the single connection-manager window (per-window instance). */
|
||||||
@@ -521,10 +429,6 @@ export function createManagerRPC(
|
|||||||
hooks.onClose();
|
hooks.onClose();
|
||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// win7 skin: frameless window, webview-drawn chrome. The X reuses
|
|
||||||
// the managerClose path so the modal bookkeeping still runs.
|
|
||||||
...windowControlHandlers(hooks.window, () => hooks.onClose()),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+10
-89
@@ -1,31 +1,9 @@
|
|||||||
import { BrowserWindow } from "electrobun/bun";
|
import { BrowserWindow } from "electrobun/bun";
|
||||||
import { DEFAULT_SETTINGS, type AppSettings } from "../shared/types";
|
import type { AppSettings } from "../shared/types";
|
||||||
|
|
||||||
/**
|
|
||||||
* The skin THIS PROCESS booted with, frozen at startup by `initSessionSkin`.
|
|
||||||
*
|
|
||||||
* Every window must be built from this, never from the live settings snapshot.
|
|
||||||
* The skin picks a whole component tree (bootstrapped from the preload-baked
|
|
||||||
* __APP_SKIN) and the native frame (constructor-only), so it cannot change
|
|
||||||
* without a restart — but the persisted setting CAN change mid-session. Reading
|
|
||||||
* the live value would boot a newly opened editor or manager into the pending
|
|
||||||
* skin while the main window still runs the old one: two windows, two trees,
|
|
||||||
* two frames.
|
|
||||||
*
|
|
||||||
* Pinning it in one place makes that impossible rather than making each call
|
|
||||||
* site remember. Everything else in the preload keeps using the live snapshot,
|
|
||||||
* because theme/tone/accent/text-size genuinely do apply live.
|
|
||||||
*/
|
|
||||||
let sessionSkin: AppSettings["skin"] = DEFAULT_SETTINGS.skin;
|
|
||||||
|
|
||||||
/** Call once, at startup, before any window is constructed. */
|
|
||||||
export function initSessionSkin(skin: AppSettings["skin"]): void {
|
|
||||||
sessionSkin = skin;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The preload globals every window gets. Single composition point: the UI
|
* The preload globals every window gets. Single composition point: the UI
|
||||||
* reads them in ui/src/app/platform.ts before first paint (window chrome and
|
* reads them in ui/src/app/core/platform.ts before first paint (window chrome and
|
||||||
* root-component selection both depend on them).
|
* root-component selection both depend on them).
|
||||||
*
|
*
|
||||||
* The persisted theme attributes are baked in here — mirroring the
|
* The persisted theme attributes are baked in here — mirroring the
|
||||||
@@ -39,22 +17,11 @@ export function buildPreload(
|
|||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
ctx?: unknown,
|
ctx?: unknown,
|
||||||
): string {
|
): string {
|
||||||
// sessionSkin, NOT settings.skin — a pending skin change must not leak into
|
|
||||||
// a window opened before the restart. See initSessionSkin.
|
|
||||||
let script =
|
let script =
|
||||||
`window.__APP_PLATFORM=${JSON.stringify(process.platform)};` +
|
`window.__APP_PLATFORM=${JSON.stringify(process.platform)};` +
|
||||||
`window.__APP_MODE=${JSON.stringify(mode)};` +
|
`window.__APP_MODE=${JSON.stringify(mode)};`;
|
||||||
`window.__APP_SKIN=${JSON.stringify(sessionSkin)};`;
|
|
||||||
if (ctx !== undefined) script += `window.__APP_CTX=${JSON.stringify(ctx)};`;
|
if (ctx !== undefined) script += `window.__APP_CTX=${JSON.stringify(ctx)};`;
|
||||||
|
|
||||||
// A class, not a data-* attribute: 7.scoped.css is precompiled to require a
|
|
||||||
// literal `.win7` ancestor, so the name is not ours to choose. This is the
|
|
||||||
// ONLY place it is ever written — the UI must never toggle it live, because
|
|
||||||
// each skin's CSS is written against its own tree's markup.
|
|
||||||
if (sessionSkin === "win7") {
|
|
||||||
script += `document.documentElement.classList.add("win7");`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataset: Record<string, string> = {};
|
const dataset: Record<string, string> = {};
|
||||||
if (settings.theme !== "system") dataset["theme"] = settings.theme;
|
if (settings.theme !== "system") dataset["theme"] = settings.theme;
|
||||||
if (settings.darkTone !== "dark") dataset["darkTone"] = settings.darkTone;
|
if (settings.darkTone !== "dark") dataset["darkTone"] = settings.darkTone;
|
||||||
@@ -72,32 +39,14 @@ export function buildPreload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Native chrome options for a window. The ONE place the frame decision is made —
|
* Native chrome for every window — the ONE place the frame is decided; the main
|
||||||
* both the main window (src/bun/index.ts) and openSecondaryWindow spread this.
|
* window (src/bun/index.ts) and openSecondaryWindow both spread this. macOS
|
||||||
*
|
* fuses the titlebar with the app's own header row (hiddenInset: the traffic
|
||||||
* Both values are BrowserWindow CONSTRUCTOR options — there is no API to
|
* lights overlay it, which is what IS_MAC_CHROME reserves pl-24 for); other
|
||||||
* re-decorate a live window — which is half of why the skin is a launch-time
|
* platforms keep the native titlebar. Constructor-only: there is no API to
|
||||||
* choice. Takes no argument on purpose: it reads the pinned sessionSkin, so no
|
* re-decorate a live window.
|
||||||
* call site can accidentally frame a window from a pending setting.
|
|
||||||
*/
|
*/
|
||||||
export function chromeOptions() {
|
export function chromeOptions() {
|
||||||
const skin = sessionSkin;
|
|
||||||
// win7 draws its own Aero title bar (drag region + our minimize/maximize/
|
|
||||||
// close), so the window is frameless on EVERY platform. `titleBarStyle:
|
|
||||||
// "hidden"` clears `Titled` from the Cocoa style mask, which is also what
|
|
||||||
// removes the macOS traffic lights — there is no separate
|
|
||||||
// hide-traffic-lights API. Linux honours it too: the GTK wrapper calls
|
|
||||||
// gtk_window_set_decorated(false).
|
|
||||||
//
|
|
||||||
// `transparent` is what lets the Aero frame be a real frame: .window's 6px
|
|
||||||
// radius is drawn by the webview OUTSIDE the window's own rectangle, so on
|
|
||||||
// an opaque surface the corners are simply clipped square.
|
|
||||||
// PORTABILITY: on Linux this needs a compositor granting an RGBA visual.
|
|
||||||
if (skin === "win7") return { titleBarStyle: "hidden" as const, transparent: true };
|
|
||||||
|
|
||||||
// modern: the native frame stays. On macOS it fuses with the app's own
|
|
||||||
// header row (hiddenInset), and the traffic lights overlay it — which is
|
|
||||||
// what IS_MAC_CHROME reserves pl-24 for in the shadlike tree.
|
|
||||||
return process.platform === "darwin"
|
return process.platform === "darwin"
|
||||||
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
||||||
: {};
|
: {};
|
||||||
@@ -123,31 +72,6 @@ export function showWhenReady(win: BrowserWindow): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Push maximize-state changes to a window's webview so the win7 title bar can
|
|
||||||
* swap its maximize/restore glyph.
|
|
||||||
*
|
|
||||||
* electrobun emits NO maximize/unmaximize event (only close/resize/move/focus/
|
|
||||||
* blur/keyDown/keyUp), so the state is derived from `resize` — which is also
|
|
||||||
* what makes OS-initiated changes work: double-click zoom, Win+Up, Aero Snap
|
|
||||||
* all resize the window. `isMaximized()` is a synchronous FFI read, so polling
|
|
||||||
* it per resize is cheap; the last-value guard keeps the wire quiet during a
|
|
||||||
* drag-resize. The emitter has no off(), so this closure must not capture
|
|
||||||
* anything that needs to be garbage collected (see WindowRegistry.track).
|
|
||||||
*/
|
|
||||||
export function trackMaximizeState(
|
|
||||||
win: BrowserWindow,
|
|
||||||
push: (maximized: boolean) => void,
|
|
||||||
): void {
|
|
||||||
let last = win.isMaximized();
|
|
||||||
win.on("resize", () => {
|
|
||||||
const now = win.isMaximized();
|
|
||||||
if (now === last) return;
|
|
||||||
last = now;
|
|
||||||
push(now);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SecondaryWindowOptions {
|
export interface SecondaryWindowOptions {
|
||||||
title: string;
|
title: string;
|
||||||
/** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */
|
/** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */
|
||||||
@@ -162,10 +86,7 @@ export interface SecondaryWindowOptions {
|
|||||||
rpc: NonNullable<ConstructorParameters<typeof BrowserWindow>[0]>["rpc"];
|
rpc: NonNullable<ConstructorParameters<typeof BrowserWindow>[0]>["rpc"];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** App-chromed secondary window; its frame comes from chromeOptions. */
|
||||||
* App-chromed secondary window. The frame decision is delegated to
|
|
||||||
* chromeOptions (the single frame decision).
|
|
||||||
*/
|
|
||||||
export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow {
|
export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow {
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
title: opts.title,
|
title: opts.title,
|
||||||
|
|||||||
@@ -33,12 +33,6 @@ export const de = {
|
|||||||
title: "Optionen",
|
title: "Optionen",
|
||||||
language: "Sprache",
|
language: "Sprache",
|
||||||
system: "System",
|
system: "System",
|
||||||
skin: {
|
|
||||||
label: "Optik",
|
|
||||||
modern: "Modern",
|
|
||||||
win7: "Windows 7",
|
|
||||||
restartHint: "Wird beim nächsten Start von miniSCP übernommen",
|
|
||||||
},
|
|
||||||
theme: { label: "Design", system: "System", light: "Hell", dark: "Dunkel" },
|
theme: { label: "Design", system: "System", light: "Hell", dark: "Dunkel" },
|
||||||
tone: { label: "Ton", dark: "Dunkel", darker: "Dunkler" },
|
tone: { label: "Ton", dark: "Dunkel", darker: "Dunkler" },
|
||||||
accent: { label: "Akzent" },
|
accent: { label: "Akzent" },
|
||||||
|
|||||||
@@ -37,12 +37,6 @@ export const en = {
|
|||||||
title: "Options",
|
title: "Options",
|
||||||
language: "Language",
|
language: "Language",
|
||||||
system: "System",
|
system: "System",
|
||||||
skin: {
|
|
||||||
label: "Skin",
|
|
||||||
modern: "Modern",
|
|
||||||
win7: "Windows 7",
|
|
||||||
restartHint: "Applies next time you start miniSCP",
|
|
||||||
},
|
|
||||||
theme: { label: "Theme", system: "System", light: "Light", dark: "Dark" },
|
theme: { label: "Theme", system: "System", light: "Light", dark: "Dark" },
|
||||||
tone: { label: "Tone", dark: "Dark", darker: "Darker" },
|
tone: { label: "Tone", dark: "Dark", darker: "Darker" },
|
||||||
accent: { label: "Accent" },
|
accent: { label: "Accent" },
|
||||||
|
|||||||
@@ -33,12 +33,6 @@ export const es = {
|
|||||||
title: "Opciones",
|
title: "Opciones",
|
||||||
language: "Idioma",
|
language: "Idioma",
|
||||||
system: "Sistema",
|
system: "Sistema",
|
||||||
skin: {
|
|
||||||
label: "Aspecto",
|
|
||||||
modern: "Moderno",
|
|
||||||
win7: "Windows 7",
|
|
||||||
restartHint: "Se aplica la próxima vez que abras miniSCP",
|
|
||||||
},
|
|
||||||
theme: { label: "Tema", system: "Sistema", light: "Claro", dark: "Oscuro" },
|
theme: { label: "Tema", system: "Sistema", light: "Claro", dark: "Oscuro" },
|
||||||
tone: { label: "Tono", dark: "Oscuro", darker: "Más oscuro" },
|
tone: { label: "Tono", dark: "Oscuro", darker: "Más oscuro" },
|
||||||
accent: { label: "Acento" },
|
accent: { label: "Acento" },
|
||||||
|
|||||||
@@ -35,12 +35,6 @@ export const eu = {
|
|||||||
title: "Aukerak",
|
title: "Aukerak",
|
||||||
language: "Hizkuntza",
|
language: "Hizkuntza",
|
||||||
system: "Sistema",
|
system: "Sistema",
|
||||||
skin: {
|
|
||||||
label: "Itxura",
|
|
||||||
modern: "Modernoa",
|
|
||||||
win7: "Windows 7",
|
|
||||||
restartHint: "Hurrengoan miniSCP abiaraztean aplikatuko da",
|
|
||||||
},
|
|
||||||
theme: { label: "Gaia", system: "Sistema", light: "Argia", dark: "Iluna" },
|
theme: { label: "Gaia", system: "Sistema", light: "Argia", dark: "Iluna" },
|
||||||
tone: { label: "Tonua", dark: "Iluna", darker: "Ilunagoa" },
|
tone: { label: "Tonua", dark: "Iluna", darker: "Ilunagoa" },
|
||||||
accent: { label: "Azentua" },
|
accent: { label: "Azentua" },
|
||||||
|
|||||||
@@ -33,12 +33,6 @@ export const fr = {
|
|||||||
title: "Options",
|
title: "Options",
|
||||||
language: "Langue",
|
language: "Langue",
|
||||||
system: "Système",
|
system: "Système",
|
||||||
skin: {
|
|
||||||
label: "Apparence",
|
|
||||||
modern: "Moderne",
|
|
||||||
win7: "Windows 7",
|
|
||||||
restartHint: "Prend effet au prochain démarrage de miniSCP",
|
|
||||||
},
|
|
||||||
theme: { label: "Thème", system: "Système", light: "Clair", dark: "Sombre" },
|
theme: { label: "Thème", system: "Système", light: "Clair", dark: "Sombre" },
|
||||||
tone: { label: "Ton", dark: "Sombre", darker: "Plus sombre" },
|
tone: { label: "Ton", dark: "Sombre", darker: "Plus sombre" },
|
||||||
accent: { label: "Accent" },
|
accent: { label: "Accent" },
|
||||||
|
|||||||
@@ -34,12 +34,6 @@ export const ja = {
|
|||||||
title: "オプション",
|
title: "オプション",
|
||||||
language: "言語",
|
language: "言語",
|
||||||
system: "システム",
|
system: "システム",
|
||||||
skin: {
|
|
||||||
label: "外観",
|
|
||||||
modern: "モダン",
|
|
||||||
win7: "Windows 7",
|
|
||||||
restartHint: "次回 miniSCP を起動したときに適用されます",
|
|
||||||
},
|
|
||||||
theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" },
|
theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" },
|
||||||
tone: { label: "トーン", dark: "ダーク", darker: "より暗く" },
|
tone: { label: "トーン", dark: "ダーク", darker: "より暗く" },
|
||||||
accent: { label: "アクセント" },
|
accent: { label: "アクセント" },
|
||||||
|
|||||||
@@ -34,12 +34,6 @@ export const zh = {
|
|||||||
title: "选项",
|
title: "选项",
|
||||||
language: "语言",
|
language: "语言",
|
||||||
system: "系统",
|
system: "系统",
|
||||||
skin: {
|
|
||||||
label: "外观",
|
|
||||||
modern: "现代",
|
|
||||||
win7: "Windows 7",
|
|
||||||
restartHint: "下次启动 miniSCP 时生效",
|
|
||||||
},
|
|
||||||
theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" },
|
theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" },
|
||||||
tone: { label: "色调", dark: "深色", darker: "更深" },
|
tone: { label: "色调", dark: "深色", darker: "更深" },
|
||||||
accent: { label: "强调色" },
|
accent: { label: "强调色" },
|
||||||
|
|||||||
+1
-37
@@ -128,7 +128,7 @@ export type AppRPC = {
|
|||||||
// local shell integration
|
// local shell integration
|
||||||
/**
|
/**
|
||||||
* Shell actions the context menu triggers on a LOCAL path. The menu
|
* Shell actions the context menu triggers on a LOCAL path. The menu
|
||||||
* itself is HTML (ui/src/app/context-menu.ts) — electrobun's native
|
* itself is HTML (ui/src/app/core/context-menu.service.ts) — electrobun's native
|
||||||
* context menu is a no-op on Linux — but these three still need the
|
* context menu is a no-op on Linux — but these three still need the
|
||||||
* bun side's Utils.
|
* bun side's Utils.
|
||||||
*/
|
*/
|
||||||
@@ -154,35 +154,6 @@ export type AppRPC = {
|
|||||||
managerConnect: { params: { id: string }; response: Result };
|
managerConnect: { params: { id: string }; response: Result };
|
||||||
managerOpenStack: { params: { id: string }; response: Result };
|
managerOpenStack: { params: { id: string }; response: Result };
|
||||||
managerClose: { params: Record<string, never>; response: Result };
|
managerClose: { params: Record<string, never>; response: Result };
|
||||||
// custom window chrome (win7 skin — the window is frameless, so the
|
|
||||||
// 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)
|
// auto-updater (electrobun Updater)
|
||||||
/** Main window: re-run the update check on demand (menu item). */
|
/** Main window: re-run the update check on demand (menu item). */
|
||||||
checkForUpdate: {
|
checkForUpdate: {
|
||||||
@@ -213,13 +184,6 @@ export type AppRPC = {
|
|||||||
editorCloseRequest: Record<string, never>;
|
editorCloseRequest: Record<string, never>;
|
||||||
/** bun → main: the modal connection manager opened/closed. */
|
/** bun → main: the modal connection manager opened/closed. */
|
||||||
managerState: { open: boolean };
|
managerState: { open: boolean };
|
||||||
/**
|
|
||||||
* bun → any window: maximize state changed, so the custom title bar can
|
|
||||||
* swap its maximize/restore glyph. electrobun has no maximize event —
|
|
||||||
* this is derived from the window's `resize` event (see windows.ts
|
|
||||||
* trackMaximizeState), which also covers OS-initiated Aero Snap.
|
|
||||||
*/
|
|
||||||
windowState: { maximized: boolean };
|
|
||||||
/** bun → main: the manager window changed the connection store. */
|
/** bun → main: the manager window changed the connection store. */
|
||||||
connectionsChanged: Record<string, never>;
|
connectionsChanged: Record<string, never>;
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-14
@@ -78,18 +78,6 @@ export type AccentTone =
|
|||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
/** UI language; "system" resolves to the OS locale (fallback English). */
|
/** UI language; "system" resolves to the OS locale (fallback English). */
|
||||||
locale: "system" | SupportedLocale;
|
locale: "system" | SupportedLocale;
|
||||||
/**
|
|
||||||
* Visual skin. "modern" is the shadcn-style look; "win7" swaps in 7.css
|
|
||||||
* (Windows 7 Aero) and switches the window to custom frameless chrome.
|
|
||||||
* Each skin is a whole component tree under ui/src/app/skins/ — "modern" is
|
|
||||||
* shadlike/, "win7" is 7like/ — selected at bootstrap in ui/src/main.ts.
|
|
||||||
* The value is NOT named after its folder on purpose: it is persisted in
|
|
||||||
* user settings, and "win7" is also the literal class 7.scoped.css is
|
|
||||||
* precompiled to require, so neither can be renamed freely.
|
|
||||||
* Changing this needs a window reopen to take effect on the frame itself —
|
|
||||||
* the CSS half applies live.
|
|
||||||
*/
|
|
||||||
skin: "modern" | "win7";
|
|
||||||
theme: "system" | "light" | "dark";
|
theme: "system" | "light" | "dark";
|
||||||
/** Dark appearance: grey ("dark") or near-black AMOLED ("darker"). */
|
/** Dark appearance: grey ("dark") or near-black AMOLED ("darker"). */
|
||||||
darkTone: "dark" | "darker";
|
darkTone: "dark" | "darker";
|
||||||
@@ -103,7 +91,7 @@ export interface AppSettings {
|
|||||||
/**
|
/**
|
||||||
* Interface scale factor, 0.5–4 in 0.1 steps (shown as 50%–400%). Works by
|
* Interface scale factor, 0.5–4 in 0.1 steps (shown as 50%–400%). Works by
|
||||||
* setting the root font-size — every size in the UI is rem, so one lever
|
* setting the root font-size — every size in the UI is rem, so one lever
|
||||||
* scales BOTH skins whole. Replaced the 3-tier `textSize`
|
* scales the whole UI. Replaced the 3-tier `textSize`
|
||||||
* (compact|default|large); SettingsStore.load migrates old files.
|
* (compact|default|large); SettingsStore.load migrates old files.
|
||||||
*/
|
*/
|
||||||
uiScale: number;
|
uiScale: number;
|
||||||
@@ -125,7 +113,6 @@ export interface AppSettings {
|
|||||||
|
|
||||||
export const DEFAULT_SETTINGS: AppSettings = {
|
export const DEFAULT_SETTINGS: AppSettings = {
|
||||||
locale: "system",
|
locale: "system",
|
||||||
skin: "modern",
|
|
||||||
theme: "system",
|
theme: "system",
|
||||||
darkTone: "dark",
|
darkTone: "dark",
|
||||||
accent: "blue",
|
accent: "blue",
|
||||||
|
|||||||
@@ -1,292 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<!--
|
|
||||||
Skin harness — a static gallery rendered headlessly by scripts/shot.ts so the
|
|
||||||
Win7 skin can be LOOKED AT during development instead of inferred from a
|
|
||||||
clean build log. Not shipped: it lives outside ui/src, so the Angular build
|
|
||||||
never sees it.
|
|
||||||
|
|
||||||
Each section pairs 7.css's own documented markup ("reference") with what the
|
|
||||||
app currently renders ("ours"). Where those two disagree visually, 7.css is
|
|
||||||
not reaching our component — that mismatch is the whole point of the page.
|
|
||||||
|
|
||||||
Layout here uses INLINE STYLES on purpose: Tailwind v4 only emits utilities it
|
|
||||||
found while scanning ui/src, so utility classes used only here would not exist
|
|
||||||
in the built stylesheet. {{STYLES}} is replaced by scripts/shot.ts with the
|
|
||||||
real hashed build output.
|
|
||||||
-->
|
|
||||||
<html class="win7" lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<title>7.css harness</title>
|
|
||||||
<link rel="stylesheet" href="{{STYLES}}" />
|
|
||||||
<style>
|
|
||||||
body { margin: 0; padding: 20px; }
|
|
||||||
h2 { font-size: 13px; margin: 22px 0 6px; opacity: 0.75; }
|
|
||||||
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; }
|
|
||||||
.cell { min-width: 0; }
|
|
||||||
.cell > figcaption {
|
|
||||||
font-size: 11px; opacity: 0.6; margin-bottom: 5px; text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
.stage { padding: 14px; background: #6ba5d7; border-radius: 4px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h2>1 — Window frame, glass, drop shadow, active colour</h2>
|
|
||||||
<div class="pair">
|
|
||||||
<figure class="cell" style="margin:0">
|
|
||||||
<figcaption>reference — .window.glass.active</figcaption>
|
|
||||||
<div class="stage">
|
|
||||||
<div class="window glass active" style="width: 320px">
|
|
||||||
<div class="title-bar">
|
|
||||||
<div class="title-bar-text">miniSCP</div>
|
|
||||||
<div class="title-bar-controls">
|
|
||||||
<button aria-label="Minimize"></button>
|
|
||||||
<button aria-label="Maximize"></button>
|
|
||||||
<button aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="window-body"><p>Glass frame, 6px radius, drop shadow.</p></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</figure>
|
|
||||||
<figure class="cell" style="margin:0">
|
|
||||||
<figcaption>ours — app.html shell (active above, inactive below)</figcaption>
|
|
||||||
<div class="stage">
|
|
||||||
<div class="window glass active" style="width: 320px">
|
|
||||||
<div class="title-bar">
|
|
||||||
<div class="title-bar-text">miniSCP</div>
|
|
||||||
<div class="title-bar-controls">
|
|
||||||
<button aria-label="Minimize"></button>
|
|
||||||
<button aria-label="Maximize"></button>
|
|
||||||
<button aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="window-body"><p style="margin:0">Focused window.</p></div>
|
|
||||||
</div>
|
|
||||||
<div class="window glass" style="width: 320px; margin-top: 12px">
|
|
||||||
<div class="title-bar">
|
|
||||||
<div class="title-bar-text">miniSCP</div>
|
|
||||||
<div class="title-bar-controls">
|
|
||||||
<button aria-label="Minimize"></button>
|
|
||||||
<button aria-label="Maximize"></button>
|
|
||||||
<button aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="window-body"><p style="margin:0">Unfocused — chrome desaturates.</p></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</figure>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>2 — ListView (file panel)</h2>
|
|
||||||
<div class="pair">
|
|
||||||
<figure class="cell" style="margin:0">
|
|
||||||
<figcaption>reference — <table></figcaption>
|
|
||||||
<table class="has-shadow" style="width:100%">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th class="highlighted indicator">Size</th>
|
|
||||||
<th>Modified</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>..</td><td style="text-align:right">—</td><td>—</td></tr>
|
|
||||||
<tr><td>Documents</td><td style="text-align:right">—</td><td>3/29/2007 12:00 AM</td></tr>
|
|
||||||
<tr class="highlighted"><td>report.pdf</td><td style="text-align:right">27 KB</td><td>3/23/2007 12:00 AM</td></tr>
|
|
||||||
<tr><td>notes.txt</td><td style="text-align:right">4 KB</td><td>3/13/2007 12:00 AM</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</figure>
|
|
||||||
<figure class="cell" style="margin:0">
|
|
||||||
<figcaption>ours — focused pane (top) vs unfocused pane (bottom)</figcaption>
|
|
||||||
<!-- Focused pane: strong band on selected rows AND the cursor row;
|
|
||||||
the cursor row additionally gets the dotted focus rect. -->
|
|
||||||
<table class="is-active">
|
|
||||||
<colgroup>
|
|
||||||
<col />
|
|
||||||
<col style="width: 5.5rem" />
|
|
||||||
<col style="width: 8.5rem" />
|
|
||||||
</colgroup>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th class="highlighted indicator up" style="text-align:right">Size</th>
|
|
||||||
<th>Modified</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>..</td><td style="text-align:right">—</td><td>—</td></tr>
|
|
||||||
<tr class="highlighted"><td>Documents</td><td style="text-align:right">—</td><td>3/29/2007 12:00 AM</td></tr>
|
|
||||||
<tr class="highlighted"><td>report.pdf</td><td style="text-align:right">27 KB</td><td>3/23/2007 12:00 AM</td></tr>
|
|
||||||
<tr class="highlighted is-cursor"><td>notes.txt</td><td style="text-align:right">4 KB</td><td>3/13/2007 12:00 AM</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<!-- Unfocused pane: same rows, faint inactive wash, no dots. -->
|
|
||||||
<table style="margin-top:10px">
|
|
||||||
<colgroup>
|
|
||||||
<col />
|
|
||||||
<col style="width: 5.5rem" />
|
|
||||||
<col style="width: 8.5rem" />
|
|
||||||
</colgroup>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th style="text-align:right">Size</th>
|
|
||||||
<th>Modified</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>..</td><td style="text-align:right">—</td><td>—</td></tr>
|
|
||||||
<tr class="highlighted"><td>Documents</td><td style="text-align:right">—</td><td>3/29/2007 12:00 AM</td></tr>
|
|
||||||
<tr><td>report.pdf</td><td style="text-align:right">27 KB</td><td>3/23/2007 12:00 AM</td></tr>
|
|
||||||
<tr class="highlighted"><td>notes.txt</td><td style="text-align:right">4 KB</td><td>3/13/2007 12:00 AM</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</figure>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>2b — Selection geometry (rows must NOT shift when selected)</h2>
|
|
||||||
<div class="pair">
|
|
||||||
<figure class="cell" style="margin:0">
|
|
||||||
<figcaption>no selection</figcaption>
|
|
||||||
<table class="is-active">
|
|
||||||
<colgroup><col /><col style="width:6rem" /></colgroup>
|
|
||||||
<thead><tr><th>Name</th><th>Size</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>aaa.txt</td><td>1 KB</td></tr>
|
|
||||||
<tr><td>bbb.txt</td><td>2 KB</td></tr>
|
|
||||||
<tr><td>ccc.txt</td><td>3 KB</td></tr>
|
|
||||||
<tr><td>ddd.txt</td><td>4 KB</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</figure>
|
|
||||||
<figure class="cell" style="margin:0">
|
|
||||||
<figcaption>middle row selected — baselines must still line up</figcaption>
|
|
||||||
<table class="is-active">
|
|
||||||
<colgroup><col /><col style="width:6rem" /></colgroup>
|
|
||||||
<thead><tr><th>Name</th><th>Size</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>aaa.txt</td><td>1 KB</td></tr>
|
|
||||||
<tr class="highlighted"><td>bbb.txt</td><td>2 KB</td></tr>
|
|
||||||
<tr><td>ccc.txt</td><td>3 KB</td></tr>
|
|
||||||
<tr><td>ddd.txt</td><td>4 KB</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</figure>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>3 — Context menu</h2>
|
|
||||||
<div class="pair">
|
|
||||||
<figure class="cell" style="margin:0">
|
|
||||||
<figcaption>reference — ul[role=menu]</figcaption>
|
|
||||||
<ul role="menu" style="width:200px">
|
|
||||||
<li role="menuitem"><a href="#m">Send to Other Panel</a></li>
|
|
||||||
<li role="menuitem"><a href="#m">Open</a></li>
|
|
||||||
<li role="menuitem" class="has-divider"><a href="#m">Rename</a></li>
|
|
||||||
<li role="menuitem"><a href="#m">Copy Path</a></li>
|
|
||||||
<li role="menuitem" aria-disabled="true"><a href="#m">Permissions</a></li>
|
|
||||||
<li role="menuitem" class="has-divider"><a href="#m">Delete</a></li>
|
|
||||||
<li role="menuitem">
|
|
||||||
<input type="checkbox" id="h-col-size" checked />
|
|
||||||
<label for="h-col-size">Size</label>
|
|
||||||
</li>
|
|
||||||
<li role="menuitem">
|
|
||||||
<input type="checkbox" id="h-col-kind" />
|
|
||||||
<label for="h-col-kind">Kind</label>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</figure>
|
|
||||||
<figure class="cell" style="margin:0">
|
|
||||||
<figcaption>ours — context-menu.ts output (buttons, divider, accelerator, checkables)</figcaption>
|
|
||||||
<!-- w7-ctx is the app's marker class (flat highlight, no Aero afterglow).
|
|
||||||
It also carries a position:fixed restatement, which the app wants and a
|
|
||||||
review page in normal flow does not — hence the inline static. -->
|
|
||||||
<ul role="menu" class="w7-ctx" style="width:200px;position:static">
|
|
||||||
<li role="menuitem"><button type="button">Send to Other Panel</button></li>
|
|
||||||
<li role="menuitem"><button type="button">Open</button></li>
|
|
||||||
<li role="menuitem"><button type="button">Edit <span>F4</span></button></li>
|
|
||||||
<li role="menuitem" class="has-divider"><button type="button">Rename</button></li>
|
|
||||||
<li role="menuitem"><button type="button">Copy Path</button></li>
|
|
||||||
<li role="menuitem" class="has-divider"><button type="button">Delete</button></li>
|
|
||||||
<li role="menuitem">
|
|
||||||
<input type="checkbox" id="ours-col-size" checked tabindex="-1" />
|
|
||||||
<label for="ours-col-size">Size</label>
|
|
||||||
</li>
|
|
||||||
<li role="menuitem">
|
|
||||||
<input type="checkbox" id="ours-col-kind" tabindex="-1" />
|
|
||||||
<label for="ours-col-kind">Kind</label>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</figure>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>4 — Controls 7.css already owns (sanity check)</h2>
|
|
||||||
<div class="pair">
|
|
||||||
<figure class="cell" style="margin:0">
|
|
||||||
<figcaption>buttons / textbox / progress</figcaption>
|
|
||||||
<div style="display:flex;flex-direction:column;gap:10px">
|
|
||||||
<div style="display:flex;gap:6px"><button>OK</button><button>Cancel</button><button disabled>Disabled</button></div>
|
|
||||||
<input type="text" value="/home/supongo/code" style="width:100%" />
|
|
||||||
<div role="progressbar" style="width:100%"><div style="width:42%"></div></div>
|
|
||||||
<div role="progressbar" class="marquee" style="width:100%"></div>
|
|
||||||
</div>
|
|
||||||
</figure>
|
|
||||||
<figure class="cell" style="margin:0">
|
|
||||||
<figcaption>reference — menu[role=tablist]</figcaption>
|
|
||||||
<menu role="tablist">
|
|
||||||
<button role="tab" aria-selected="true">local</button>
|
|
||||||
<button role="tab">server-a</button>
|
|
||||||
<button role="tab">server-b</button>
|
|
||||||
</menu>
|
|
||||||
<div role="tabpanel" style="padding:10px">Tab body</div>
|
|
||||||
</figure>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>5 — Workspace tab strip (ours, as tab-bar.ts renders it)</h2>
|
|
||||||
<!--
|
|
||||||
The live app only ever has ONE workspace at startup and there is no way to
|
|
||||||
click "+" from a headless capture, so multi-tab state can only be checked
|
|
||||||
here. This mirrors tab-bar.ts exactly: the tabs sit in a menu[role=tablist]
|
|
||||||
inside a .w7-toolbar row, and the "+" / right-hand tools sit OUTSIDE the
|
|
||||||
menu so 7.css's descendant selector does not style them as tabs.
|
|
||||||
-->
|
|
||||||
<div class="pair">
|
|
||||||
<figure class="cell" style="margin:0">
|
|
||||||
<figcaption>ours — 3 workspaces, 2nd active</figcaption>
|
|
||||||
<div class="w7-toolbar relative flex h-9 items-center gap-0.5 border-b border-border bg-chrome pt-1 pr-1.5 pl-1.5">
|
|
||||||
<menu role="tablist" class="flex min-w-0 items-center overflow-x-auto">
|
|
||||||
<button role="tab" class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5">
|
|
||||||
<span class="truncate">Local</span>
|
|
||||||
</button>
|
|
||||||
<button role="tab" aria-selected="true" class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5">
|
|
||||||
<span class="truncate">build-server</span>
|
|
||||||
</button>
|
|
||||||
<button role="tab" class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5">
|
|
||||||
<span class="truncate">a-very-long-workspace-name-that-truncates</span>
|
|
||||||
</button>
|
|
||||||
</menu>
|
|
||||||
<button class="ml-0.5 grid size-6 place-items-center rounded-md">+</button>
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
<button class="grid h-6 px-2 py-1 place-items-center rounded-md text-[11px]">Connections</button>
|
|
||||||
</div>
|
|
||||||
<div style="height:60px;background:var(--w7-tab-bg,#fff);border:1px solid #8e8f8f;border-top:0"></div>
|
|
||||||
</figure>
|
|
||||||
<figure class="cell" style="margin:0">
|
|
||||||
<figcaption>ours — single workspace (the live startup state)</figcaption>
|
|
||||||
<div class="w7-toolbar relative flex h-9 items-center gap-0.5 border-b border-border bg-chrome pt-1 pr-1.5 pl-1.5">
|
|
||||||
<menu role="tablist" class="flex min-w-0 items-center overflow-x-auto">
|
|
||||||
<button role="tab" aria-selected="true" class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5">
|
|
||||||
<span class="truncate">Local</span>
|
|
||||||
</button>
|
|
||||||
</menu>
|
|
||||||
<button class="ml-0.5 grid size-6 place-items-center rounded-md">+</button>
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
<button class="grid h-6 px-2 py-1 place-items-center rounded-md text-[11px]">Connections</button>
|
|
||||||
</div>
|
|
||||||
<div style="height:60px;background:var(--w7-tab-bg,#fff);border:1px solid #8e8f8f;border-top:0"></div>
|
|
||||||
</figure>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -9,7 +9,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "bun@1.3.13",
|
"packageManager": "bun@1.3.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7.css": "^0.21.1",
|
|
||||||
"@angular/common": "^22.0.0",
|
"@angular/common": "^22.0.0",
|
||||||
"@angular/compiler": "^22.0.0",
|
"@angular/compiler": "^22.0.0",
|
||||||
"@angular/core": "^22.0.0",
|
"@angular/core": "^22.0.0",
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ export interface MenuItem {
|
|||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
/** Shortcut hint, rendered right-aligned. */
|
/** Shortcut hint, rendered right-aligned. */
|
||||||
accelerator?: string;
|
accelerator?: string;
|
||||||
/** Greyed and unclickable. Win7 menus disable rather than hide, so the set
|
/** Greyed and unclickable. Menus disable rather than hide, so the set of
|
||||||
* of commands stays in the same place from one right-click to the next. */
|
* commands stays in the same place from one right-click to the next. */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ export class ContextMenuService {
|
|||||||
* assume `activeWorkspace`.
|
* assume `activeWorkspace`.
|
||||||
*
|
*
|
||||||
* Everything illegal is DISABLED, not omitted: a menu whose items move
|
* Everything illegal is DISABLED, not omitted: a menu whose items move
|
||||||
* between right-clicks is a Win7 sin, and "Close Tab" greyed out explains
|
* between right-clicks is worse than a dead row, and "Close Tab" greyed out explains
|
||||||
* why the last tab cannot be closed better than its absence does.
|
* why the last tab cannot be closed better than its absence does.
|
||||||
*
|
*
|
||||||
* Duplicate/Rename/Save-session are deliberately absent until tabs have an
|
* Duplicate/Rename/Save-session are deliberately absent until tabs have an
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ declare global {
|
|||||||
__APP_PLATFORM?: string;
|
__APP_PLATFORM?: string;
|
||||||
/** Which root component this window boots. */
|
/** Which root component this window boots. */
|
||||||
__APP_MODE?: 'main' | 'editor' | 'manager';
|
__APP_MODE?: 'main' | 'editor' | 'manager';
|
||||||
/** Which skin tree this window boots. Baked into the preload because
|
|
||||||
* settings resolve too late to decide layout without a flash. */
|
|
||||||
__APP_SKIN?: 'modern' | 'win7';
|
|
||||||
/** Window-kind specific context (EditorOpenContext for editor windows). */
|
/** Window-kind specific context (EditorOpenContext for editor windows). */
|
||||||
__APP_CTX?: unknown;
|
__APP_CTX?: unknown;
|
||||||
}
|
}
|
||||||
@@ -24,8 +21,7 @@ export const IS_WINDOWS = PLATFORM === 'win32';
|
|||||||
|
|
||||||
export const IS_LINUX = PLATFORM === 'linux';
|
export const IS_LINUX = PLATFORM === 'linux';
|
||||||
|
|
||||||
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. Keyed on the real
|
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. */
|
||||||
* platform — the keyboard does not change with the window chrome. */
|
|
||||||
export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+';
|
export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+';
|
||||||
export const ALT_LABEL = IS_MAC ? '⌥' : 'Alt+';
|
export const ALT_LABEL = IS_MAC ? '⌥' : 'Alt+';
|
||||||
|
|
||||||
@@ -35,25 +31,9 @@ export const APP_MODE: 'main' | 'editor' | 'manager' =
|
|||||||
export const EDITOR_CONTEXT =
|
export const EDITOR_CONTEXT =
|
||||||
typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined;
|
typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined;
|
||||||
|
|
||||||
/**
|
/** True when macOS traffic lights overlay the app's own header row (the fused
|
||||||
* Which skin tree this window booted. Read from the preload rather than from
|
* hiddenInset layout, see chromeOptions), which reserves `pl-24` for them. */
|
||||||
* SettingsService because both are needed before first paint — main.ts picks a
|
export const IS_MAC_CHROME = IS_MAC;
|
||||||
* root component from it, and it must not change under a running window (the
|
|
||||||
* native frame is fixed at BrowserWindow construction).
|
|
||||||
*/
|
|
||||||
export const APP_SKIN: 'modern' | 'win7' =
|
|
||||||
typeof window !== 'undefined' ? (window.__APP_SKIN ?? 'modern') : 'modern';
|
|
||||||
|
|
||||||
export const IS_WIN7_CHROME = APP_SKIN === 'win7';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True when macOS traffic lights overlay the app's own header row — the fused
|
|
||||||
* `hiddenInset` layout, which reserves `pl-24` for them. False under the win7
|
|
||||||
* skin even on macOS: `titleBarStyle: 'hidden'` removes the traffic lights
|
|
||||||
* altogether, so reserving space would leave a gap. Only the shadlike tree
|
|
||||||
* reads this; 7like draws its own title bar and never defers to native chrome.
|
|
||||||
*/
|
|
||||||
export const IS_MAC_CHROME = IS_MAC && !IS_WIN7_CHROME;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kill the webview's default right-click menu (Reload, Inspect Element…)
|
* Kill the webview's default right-click menu (Reload, Inspect Element…)
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export class RpcService {
|
|||||||
private managerStateListeners = new Set<(open: boolean) => void>();
|
private managerStateListeners = new Set<(open: boolean) => void>();
|
||||||
private connectionsListeners = new Set<() => void>();
|
private connectionsListeners = new Set<() => void>();
|
||||||
private updateStatusListeners = new Set<(status: UpdateStatus) => void>();
|
private updateStatusListeners = new Set<(status: UpdateStatus) => void>();
|
||||||
private windowStateListeners = new Set<(maximized: boolean) => void>();
|
|
||||||
|
|
||||||
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
|
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
|
||||||
private ready: Promise<void> = Promise.resolve();
|
private ready: Promise<void> = Promise.resolve();
|
||||||
@@ -72,9 +71,6 @@ export class RpcService {
|
|||||||
updateStatus: (status) => {
|
updateStatus: (status) => {
|
||||||
for (const listener of this.updateStatusListeners) listener(status);
|
for (const listener of this.updateStatusListeners) listener(status);
|
||||||
},
|
},
|
||||||
windowState: ({ maximized }) => {
|
|
||||||
for (const listener of this.windowStateListeners) listener(maximized);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -136,12 +132,6 @@ export class RpcService {
|
|||||||
this.updateStatusListeners.add(listener);
|
this.updateStatusListeners.add(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** win7 skin: the frameless window's maximize state changed (derived from
|
|
||||||
* the native resize event, so Aero Snap and Win+Up land here too). */
|
|
||||||
onWindowState(listener: (maximized: boolean) => void): void {
|
|
||||||
this.windowStateListeners.add(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */
|
/** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */
|
||||||
get request() {
|
get request() {
|
||||||
const rpc = this.rpc;
|
const rpc = this.rpc;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { RpcService } from './rpc.service';
|
|||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class SettingsService {
|
export class SettingsService {
|
||||||
readonly locale = signal<AppSettings['locale']>(DEFAULT_SETTINGS.locale);
|
readonly locale = signal<AppSettings['locale']>(DEFAULT_SETTINGS.locale);
|
||||||
readonly skin = signal<AppSettings['skin']>(DEFAULT_SETTINGS.skin);
|
|
||||||
readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme);
|
readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme);
|
||||||
readonly darkTone = signal<AppSettings['darkTone']>(DEFAULT_SETTINGS.darkTone);
|
readonly darkTone = signal<AppSettings['darkTone']>(DEFAULT_SETTINGS.darkTone);
|
||||||
readonly accent = signal<AppSettings['accent']>(DEFAULT_SETTINGS.accent);
|
readonly accent = signal<AppSettings['accent']>(DEFAULT_SETTINGS.accent);
|
||||||
@@ -51,7 +50,6 @@ export class SettingsService {
|
|||||||
|
|
||||||
private applyAll(settings: AppSettings): void {
|
private applyAll(settings: AppSettings): void {
|
||||||
this.locale.set(settings.locale);
|
this.locale.set(settings.locale);
|
||||||
this.skin.set(settings.skin);
|
|
||||||
this.theme.set(settings.theme);
|
this.theme.set(settings.theme);
|
||||||
this.darkTone.set(settings.darkTone);
|
this.darkTone.set(settings.darkTone);
|
||||||
this.accent.set(settings.accent);
|
this.accent.set(settings.accent);
|
||||||
@@ -64,9 +62,6 @@ export class SettingsService {
|
|||||||
this.autoClearCompleted.set(settings.autoClearCompleted);
|
this.autoClearCompleted.set(settings.autoClearCompleted);
|
||||||
this.editor.set(settings.editor);
|
this.editor.set(settings.editor);
|
||||||
this.editorApp.set(settings.editorApp);
|
this.editorApp.set(settings.editorApp);
|
||||||
// NO applySkin here, deliberately — see setSkin. applyAll also runs on the
|
|
||||||
// settingsChanged push, so flipping the class here would break every OPEN
|
|
||||||
// editor and manager window too, not just the one that changed the setting.
|
|
||||||
this.applyTheme();
|
this.applyTheme();
|
||||||
this.applyDarkTone();
|
this.applyDarkTone();
|
||||||
this.applyAccent();
|
this.applyAccent();
|
||||||
@@ -80,30 +75,6 @@ export class SettingsService {
|
|||||||
void this.persist();
|
void this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves the preference and NOTHING else — the skin is a launch-time choice.
|
|
||||||
*
|
|
||||||
* It deliberately does not touch the `.win7` class, which is the whole bug
|
|
||||||
* this replaced: each skin's CSS is written against its OWN component tree's
|
|
||||||
* markup, and the tree is fixed at bootstrap from the preload-baked
|
|
||||||
* __APP_SKIN. Flipping the class live therefore painted 7.css onto shadlike
|
|
||||||
* markup that has no .window.glass, no .title-bar, no menu[role=tablist] and
|
|
||||||
* no <table> ListView — a broken window, not a reskinned one.
|
|
||||||
*
|
|
||||||
* A reload would not have helped either: the preload string is frozen at
|
|
||||||
* webview construction (a persistent WebKitUserScript), and titleBarStyle /
|
|
||||||
* transparent have no setters, so the native frame cannot change on a live
|
|
||||||
* window. win7 -> modern without a reframe is the dangerous direction — the
|
|
||||||
* modern tree draws no title bar, so it would land in a frameless window
|
|
||||||
* with no way to move, minimize or close it.
|
|
||||||
*
|
|
||||||
* The options row shows the saved value as pending until the next start.
|
|
||||||
*/
|
|
||||||
setSkin(skin: AppSettings['skin']): void {
|
|
||||||
this.skin.set(skin);
|
|
||||||
void this.persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
setTheme(theme: AppSettings['theme']): void {
|
setTheme(theme: AppSettings['theme']): void {
|
||||||
this.theme.set(theme);
|
this.theme.set(theme);
|
||||||
this.applyTheme();
|
this.applyTheme();
|
||||||
@@ -171,12 +142,6 @@ export class SettingsService {
|
|||||||
void this.persist();
|
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 {
|
private applyTheme(): void {
|
||||||
const theme = this.theme();
|
const theme = this.theme();
|
||||||
if (theme === 'system') {
|
if (theme === 'system') {
|
||||||
@@ -205,7 +170,7 @@ export class SettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The whole interface scale is ONE declaration: every size in the app is
|
/** The whole interface scale is ONE declaration: every size in the app is
|
||||||
* rem, so moving the root font-size moves both skins in lockstep. 1 = the
|
* rem, so moving the root font-size moves the whole UI. 1 = the
|
||||||
* browser-default 16px root; delete-when-default keeps the pristine state
|
* browser-default 16px root; delete-when-default keeps the pristine state
|
||||||
* attribute-free like the other axes. buildPreload mirrors this for a
|
* attribute-free like the other axes. buildPreload mirrors this for a
|
||||||
* flash-free first paint. */
|
* flash-free first paint. */
|
||||||
@@ -225,7 +190,6 @@ export class SettingsService {
|
|||||||
protected settings(): AppSettings {
|
protected settings(): AppSettings {
|
||||||
return {
|
return {
|
||||||
locale: this.locale(),
|
locale: this.locale(),
|
||||||
skin: this.skin(),
|
|
||||||
theme: this.theme(),
|
theme: this.theme(),
|
||||||
darkTone: this.darkTone(),
|
darkTone: this.darkTone(),
|
||||||
accent: this.accent(),
|
accent: this.accent(),
|
||||||
|
|||||||
@@ -1,840 +0,0 @@
|
|||||||
/* The SCOPED 7.css build: postcss-prefix-selector rewrote every selector to
|
|
||||||
`.win7` (root rules) or `.win7 <sel>` (the rest), so the whole framework is
|
|
||||||
inert until the `.win7` class is on <html> — written once by buildPreload
|
|
||||||
for the skin this window booted, and never changed after. All 61
|
|
||||||
icons are data URIs, so nothing needs copying into assets — and NOT the
|
|
||||||
`7.inline.css` build, which flattens away the --w7-* vars the dark palette
|
|
||||||
below depends on. The deep path is required: 7.css ships no `exports` map
|
|
||||||
and Tailwind's resolver only reads `style`/`mainFields`, so a bare
|
|
||||||
`@import "7.css"` finds no entry. */
|
|
||||||
@import "7.css/dist/7.scoped.css" layer(skin);
|
|
||||||
|
|
||||||
/* ==========================================================================
|
|
||||||
Windows 7 skin — buildPreload puts `.win7` on <html> at first paint
|
|
||||||
==========================================================================
|
|
||||||
|
|
||||||
Three tiers, and the split matters:
|
|
||||||
|
|
||||||
- `@layer skin` (the 7.css import at the top of this file) owns the
|
|
||||||
CONTROLS: buttons, inputs, tabs, progress bars, tree views, the Aero
|
|
||||||
title bar. It sits ABOVE `utilities`, so a bare `button` rule in 7.css
|
|
||||||
beats `.rounded-md` on that same button. That inversion is the whole
|
|
||||||
trick — it restyles ~1900 inline utility classes with no template edits.
|
|
||||||
- This unlayered section owns the BRIDGE: it re-points the app's own
|
|
||||||
semantic tokens at Win7 values, so utilities Tailwind still controls
|
|
||||||
(bg-card, border-border, text-muted-foreground) also come out
|
|
||||||
Win7-correct. It adds NO new tokens on purpose — a new colour token
|
|
||||||
would need a matching `--color-*` line in `@theme inline`.
|
|
||||||
- `@layer skinfix` (last, above `skin`) hands individual properties BACK to
|
|
||||||
the utilities layer with `revert-layer`, for the places where 7.css's
|
|
||||||
Win7-dialog metrics are wrong for this app.
|
|
||||||
|
|
||||||
WARNING for future edits: because `skin` outranks `utilities`, any Tailwind
|
|
||||||
utility added later to a <button>, <input>, <select>, <textarea> or <table>
|
|
||||||
silently loses under this skin. `skinfix` is the only place to fix that.
|
|
||||||
|
|
||||||
Specificity notes — the blocks below must beat the token blocks above:
|
|
||||||
:root.win7 (0,2,0) beats base :root (0,1,0)
|
|
||||||
:root.win7[data-theme='dark'] (0,3,0) beats :root[data-theme=…] (0,2,0)
|
|
||||||
and ties the darker-tone block (0,3,0),
|
|
||||||
winning on source order (it is last).
|
|
||||||
The accent formula blocks are deliberately NOT overridden: they only set
|
|
||||||
--primary/--ring/--cursor-tint, and letting them through is what makes the
|
|
||||||
accent picker recolour the Aero chrome — authentic, Win7 shipped exactly
|
|
||||||
that ("Window Color" personalization). */
|
|
||||||
|
|
||||||
/* Segoe UI is not on macOS/Linux and is not redistributable. Noto Sans is the
|
|
||||||
substitute 7.css itself names in its --w7-font fallback stack. Vendored
|
|
||||||
(OFL-1.1) from @fontsource/noto-sans@5.3.0, files/noto-sans-latin-{400,700}
|
|
||||||
-normal.woff2 -> ./fonts/, i.e. INSIDE this skin: the font exists only for
|
|
||||||
the Win7 look, and keeping it here means the url() below stays relative to
|
|
||||||
this file wherever the skin folder moves. (It used to sit at ui/src/fonts/
|
|
||||||
next to the old single stylesheet — splitting this file out rebased the
|
|
||||||
url() three directories deeper and would have silently 404'd the font.)
|
|
||||||
Vendored rather than @imported from node_modules because Tailwind inlines
|
|
||||||
imported CSS, which would leave @fontsource's relative url() rebased against
|
|
||||||
the wrong directory. They live under src/ (not public/) so esbuild emits them
|
|
||||||
as hashed assets and rewrites the url() — a public/ path is not resolvable at
|
|
||||||
build time. */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Noto Sans App';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: block;
|
|
||||||
src: url('./fonts/noto-sans-latin-400-normal.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Noto Sans App';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: block;
|
|
||||||
src: url('./fonts/noto-sans-latin-700-normal.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.win7 {
|
|
||||||
/* Real Segoe first (Windows), bundled substitute everywhere else. */
|
|
||||||
--w7-family: 'Segoe UI', 'SegoeUI', 'Noto Sans App', system-ui, sans-serif;
|
|
||||||
/* ~7.css's 9pt, in REM so it follows the interface scale (the root
|
|
||||||
font-size lever) like every other size in the app — 0.75rem = 12px at the
|
|
||||||
16px root. Restating the `font` SHORTHAND (not just the family) is
|
|
||||||
deliberate: 7.css applies `font: var(--w7-font)` on 15 component rules,
|
|
||||||
and the shorthand also resets weight and line-height — pinning 400/1.4
|
|
||||||
here stops those rules from flattening the app's `font-medium` / leading. */
|
|
||||||
--w7-fs: 0.75rem;
|
|
||||||
--w7-font: 400 var(--w7-fs) / 1.4 var(--w7-family);
|
|
||||||
|
|
||||||
/* Surface ramp, Win7 Explorer. This INVERTS the modern ramp's direction:
|
|
||||||
there --chrome (frame strips) sits below --background (content panes);
|
|
||||||
here the content pane is the brightest thing on screen and the frame is
|
|
||||||
the #f0f0f0 window surface. Deliberate — that is what Explorer was. */
|
|
||||||
--background: #ffffff;
|
|
||||||
--chrome: #f0f0f0;
|
|
||||||
--card: #f0f0f0;
|
|
||||||
--foreground: #000000;
|
|
||||||
--card-foreground: #000000;
|
|
||||||
--muted-foreground: #6d6d6d;
|
|
||||||
--secondary: #e8e8e8;
|
|
||||||
--secondary-foreground: #000000;
|
|
||||||
--muted: #e8e8e8;
|
|
||||||
/* Win7's hover is a pale blue wash, not a grey — matches --w7-el-grad-h */
|
|
||||||
--accent: #eaf6fd;
|
|
||||||
--accent-foreground: #000000;
|
|
||||||
--border: #d5d5d5;
|
|
||||||
--input: #abadb3; /* 7.css's own text-input border */
|
|
||||||
--destructive: #c62828;
|
|
||||||
--destructive-foreground: #ffffff;
|
|
||||||
--success: #06b025; /* the Win7 progress-bar green */
|
|
||||||
|
|
||||||
/* 0.3125rem = 5px at the 16px root — NOT 3px: `rounded-md` is 74 of the 112
|
|
||||||
radius utilities in the templates and compiles to
|
|
||||||
calc(var(--radius) - 0.125rem), so 5 lands it exactly on --w7-el-bdr (3px).
|
|
||||||
--radius itself feeds `rounded-lg` (12 uses) at 5px, near the 6px window
|
|
||||||
radius. Bare `rounded` (26 uses) is a Tailwind literal 0.25rem. Rem so the
|
|
||||||
corners follow the interface scale with everything else. */
|
|
||||||
--radius: 0.3125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Light values for every 7.css literal the skinfix rules below restate through
|
|
||||||
vars (7.css hardcodes these outside its var system). The values are 7.css's
|
|
||||||
own, verbatim — light must render exactly as stock. Dark overrides live in
|
|
||||||
the duplicated dark blocks above, which is the house pattern: rules are
|
|
||||||
written ONCE and theme through tokens. (The previous attribute-keyed dark
|
|
||||||
rules silently missed SYSTEM-dark — the media-query path sets no attribute.) */
|
|
||||||
:root.win7 {
|
|
||||||
/* ListView (listview.css) */
|
|
||||||
--w7-lv-bg: #fff;
|
|
||||||
--w7-lv-bd: #c0c1cd;
|
|
||||||
--w7-lv-c: #000;
|
|
||||||
--w7-lv-head-grad: linear-gradient(180deg, #fff 45%, #fafafa 0, #f0f0f0);
|
|
||||||
--w7-lv-head-bd: #d7d7d7;
|
|
||||||
--w7-lv-cell-bd: #eee;
|
|
||||||
/* Selected row. Explorer painted a FLAT blue, not a gradient — 7.css reuses
|
|
||||||
its Aero element-hover gradient for the band, which is what made the list
|
|
||||||
look glossy. These are dedicated tokens on purpose: --w7-el-grad-h is
|
|
||||||
consumed by buttons and tabs and must keep its gradient.
|
|
||||||
`-idle` is the unfocused pane's band. Explorer greyed the selection out
|
|
||||||
when the list lost focus rather than dropping it, which is exactly the
|
|
||||||
commander convention this app already follows (see the pane-focus model
|
|
||||||
below): the band never disappears, only its strength changes. */
|
|
||||||
--w7-sel-bg: #cbe8f6;
|
|
||||||
--w7-sel-bg-idle: #dedede;
|
|
||||||
/* Tabs (tabs.css). 7.css hardcodes `color: #222` on every tablist button,
|
|
||||||
outside its own var system, which is unreadable on the dark surface. */
|
|
||||||
--w7-tab-c: #222;
|
|
||||||
/* Menu (menu.css) */
|
|
||||||
--w7-mn-c: #000;
|
|
||||||
--w7-mn-bd: #0006;
|
|
||||||
--w7-mn-stripe: inset 1px 0 #00000026, inset -1px 0 #fff;
|
|
||||||
--w7-mn-divider: inset 0 1px #00000026, inset 0 -1px #fff;
|
|
||||||
--w7-mn-check-ring: #b3d3f9;
|
|
||||||
--w7-mn-check-c: #0c12a1;
|
|
||||||
/* Glass caption streaks (window.css .window.glass>.title-bar): a literal.
|
|
||||||
The two #fff5 corner shines wash a DARK caption silver, so dark subdues
|
|
||||||
every white in the stack while keeping the streak structure. */
|
|
||||||
--w7-glass-title: linear-gradient(135deg, #fff5 70px, transparent 100px), linear-gradient(225deg, #fff5 70px, transparent 100px), linear-gradient(54deg, #0002 0 4%, #6661 6% 6%, #0002 8% 10%, #0002 15% 16%, #aaa1 17% 18%, #0002 23% 24%, #bbb2 25% 26%, #0002 31% 33%, #0002 34% 34.5%, #bbb2 36% 40%, #0002 41% 41.5%, #bbb2 44% 45%, #bbb2 46% 47%, #0002 48% 49%, #0002 50% 50.5%, #0002 56% 56.5%, #bbb2 57% 63%, #0002 67% 69%, #bbb2 69.5% 70%, #0002 73.5% 74%, #bbb2 74.5% 79%, #0002 80% 84%, #aaa2 85% 86%, #0002 87%, #bbb1 90%) 0 / 100vw 100vh no-repeat fixed;
|
|
||||||
/* Caption text (window.css .title-bar-text: black + 8-fold white halo) */
|
|
||||||
--w7-cap-c: #000;
|
|
||||||
--w7-cap-glow: 0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff,
|
|
||||||
0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hand-authored dark Win7. Microsoft never shipped one — this exists so the
|
|
||||||
theme and tone settings stay live under the skin. Only the colour-carrying
|
|
||||||
--w7-* vars are listed: --w7-el-grad and --w7-w-grad are DERIVED from the
|
|
||||||
values below and recolour for free, while --w7-el-grad-h/-a and
|
|
||||||
--w7-li-bg-hl are literal hex in 7.css and so must be restated. The ~25
|
|
||||||
geometry vars and ~20 base64 icon vars are theme-independent.
|
|
||||||
DUPLICATED for forced-dark and system-dark, per the rule the token blocks
|
|
||||||
above set: every dark value goes in BOTH. */
|
|
||||||
:root.win7[data-theme='dark'] {
|
|
||||||
--w7-surface: #2b2b2b;
|
|
||||||
--w7-el-bg: #3a3a3a;
|
|
||||||
--w7-el-bg-d: #333333;
|
|
||||||
--w7-el-bg-s-1: #333333;
|
|
||||||
--w7-el-bg-s-2: #262626;
|
|
||||||
--w7-el-bd: #565656;
|
|
||||||
--w7-el-bd-h: #4a8cbe;
|
|
||||||
--w7-el-bd-a: #3f6c8c;
|
|
||||||
--w7-el-bd-d: #3f3f3f;
|
|
||||||
--w7-el-c: #f0f0f0;
|
|
||||||
--w7-el-c-d: #6f6f6f;
|
|
||||||
--w7-el-sd: inset 0 0 0 1px #ffffff14;
|
|
||||||
--w7-el-grad-h: linear-gradient(#3c5a6b 45%, #315062 45%, #2b4557);
|
|
||||||
--w7-el-grad-a: linear-gradient(#2c4657, #24384a 30% 50%, #1d2f3f 50%, #16242f);
|
|
||||||
--w7-li-bd-hl: #3f6c8c;
|
|
||||||
--w7-li-bg-hl: linear-gradient(#ffffff14, #4a6b8433 90%, #ffffff1a);
|
|
||||||
--w7-w-bd: #000000e6;
|
|
||||||
/* Ares-style black glass: near-black ground; 7.css's own alpha streaks and
|
|
||||||
highlight gradients become the gloss. Blue lives in accents (selection,
|
|
||||||
active tab), not in the glass itself. */
|
|
||||||
--w7-w-bg: #101013;
|
|
||||||
--w7-tab-bg: #1c1c1e;
|
|
||||||
--w7-title-c: #f5f5f5;
|
|
||||||
--w7-link-c: #6fb3ff;
|
|
||||||
/* literal restatements consumed by the skinfix rules (see :root.win7) */
|
|
||||||
--w7-lv-bg: var(--w7-el-bg-s-2);
|
|
||||||
--w7-lv-bd: var(--w7-el-bd-d);
|
|
||||||
--w7-lv-c: var(--w7-el-c);
|
|
||||||
--w7-lv-head-grad: linear-gradient(180deg, var(--w7-el-bg) 45%, var(--w7-el-bg-d) 0, var(--w7-el-bg-s-1));
|
|
||||||
--w7-lv-head-bd: var(--w7-el-bd-d);
|
|
||||||
--w7-lv-cell-bd: var(--w7-el-bd-d);
|
|
||||||
--w7-sel-bg: #2a4a63;
|
|
||||||
--w7-sel-bg-idle: #3a3a3a;
|
|
||||||
--w7-tab-c: var(--w7-el-c);
|
|
||||||
--w7-mn-c: var(--w7-el-c);
|
|
||||||
--w7-mn-bd: var(--w7-el-bd-d);
|
|
||||||
--w7-mn-stripe: inset 1px 0 #00000059, inset -1px 0 #ffffff14;
|
|
||||||
--w7-mn-divider: inset 0 1px #00000059, inset 0 -1px #ffffff14;
|
|
||||||
--w7-mn-check-ring: var(--w7-li-bd-hl);
|
|
||||||
--w7-mn-check-c: var(--w7-el-c);
|
|
||||||
--w7-glass-title: linear-gradient(135deg, #ffffff1a 70px, transparent 100px), linear-gradient(225deg, #ffffff1a 70px, transparent 100px), linear-gradient(54deg, #0003 0 4%, #ffffff08 6% 6%, #0003 8% 10%, #0003 15% 16%, #ffffff0d 17% 18%, #0003 23% 24%, #ffffff10 25% 26%, #0003 31% 33%, #0003 34% 34.5%, #ffffff10 36% 40%, #0003 41% 41.5%, #ffffff10 44% 45%, #ffffff10 46% 47%, #0003 48% 49%, #0003 50% 50.5%, #0003 56% 56.5%, #ffffff10 57% 63%, #0003 67% 69%, #ffffff10 69.5% 70%, #0003 73.5% 74%, #ffffff10 74.5% 79%, #0003 80% 84%, #ffffff0d 85% 86%, #0003 87%, #ffffff0a 90%) 0 / 100vw 100vh no-repeat fixed;
|
|
||||||
/* Ares: white caption on black glass, dark halo instead of white */
|
|
||||||
--w7-cap-c: var(--w7-title-c);
|
|
||||||
--w7-cap-glow: 0 0 8px #000c, 0 0 8px #000c, 0 1px 2px #000;
|
|
||||||
--w7-link-c-h: #9acbff;
|
|
||||||
|
|
||||||
--background: #1f1f1f;
|
|
||||||
--chrome: #2b2b2b;
|
|
||||||
--card: #2b2b2b;
|
|
||||||
--foreground: #f0f0f0;
|
|
||||||
--card-foreground: #f0f0f0;
|
|
||||||
--muted-foreground: #a0a0a0;
|
|
||||||
--secondary: #383838;
|
|
||||||
--secondary-foreground: #f0f0f0;
|
|
||||||
--muted: #383838;
|
|
||||||
--accent: #2f4a5c;
|
|
||||||
--accent-foreground: #f0f0f0;
|
|
||||||
--border: #3c3c3c;
|
|
||||||
--input: #55585c;
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root.win7:not([data-theme='light']) {
|
|
||||||
--w7-surface: #2b2b2b;
|
|
||||||
--w7-el-bg: #3a3a3a;
|
|
||||||
--w7-el-bg-d: #333333;
|
|
||||||
--w7-el-bg-s-1: #333333;
|
|
||||||
--w7-el-bg-s-2: #262626;
|
|
||||||
--w7-el-bd: #565656;
|
|
||||||
--w7-el-bd-h: #4a8cbe;
|
|
||||||
--w7-el-bd-a: #3f6c8c;
|
|
||||||
--w7-el-bd-d: #3f3f3f;
|
|
||||||
--w7-el-c: #f0f0f0;
|
|
||||||
--w7-el-c-d: #6f6f6f;
|
|
||||||
--w7-el-sd: inset 0 0 0 1px #ffffff14;
|
|
||||||
--w7-el-grad-h: linear-gradient(#3c5a6b 45%, #315062 45%, #2b4557);
|
|
||||||
--w7-el-grad-a: linear-gradient(#2c4657, #24384a 30% 50%, #1d2f3f 50%, #16242f);
|
|
||||||
--w7-li-bd-hl: #3f6c8c;
|
|
||||||
--w7-li-bg-hl: linear-gradient(#ffffff14, #4a6b8433 90%, #ffffff1a);
|
|
||||||
--w7-w-bd: #000000e6;
|
|
||||||
--w7-w-bg: #101013;
|
|
||||||
--w7-tab-bg: #1c1c1e;
|
|
||||||
--w7-title-c: #f5f5f5;
|
|
||||||
--w7-link-c: #6fb3ff;
|
|
||||||
/* literal restatements consumed by the skinfix rules (see :root.win7) */
|
|
||||||
--w7-lv-bg: var(--w7-el-bg-s-2);
|
|
||||||
--w7-lv-bd: var(--w7-el-bd-d);
|
|
||||||
--w7-lv-c: var(--w7-el-c);
|
|
||||||
--w7-lv-head-grad: linear-gradient(180deg, var(--w7-el-bg) 45%, var(--w7-el-bg-d) 0, var(--w7-el-bg-s-1));
|
|
||||||
--w7-lv-head-bd: var(--w7-el-bd-d);
|
|
||||||
--w7-lv-cell-bd: var(--w7-el-bd-d);
|
|
||||||
--w7-sel-bg: #2a4a63;
|
|
||||||
--w7-sel-bg-idle: #3a3a3a;
|
|
||||||
--w7-tab-c: var(--w7-el-c);
|
|
||||||
--w7-mn-c: var(--w7-el-c);
|
|
||||||
--w7-mn-bd: var(--w7-el-bd-d);
|
|
||||||
--w7-mn-stripe: inset 1px 0 #00000059, inset -1px 0 #ffffff14;
|
|
||||||
--w7-mn-divider: inset 0 1px #00000059, inset 0 -1px #ffffff14;
|
|
||||||
--w7-mn-check-ring: var(--w7-li-bd-hl);
|
|
||||||
--w7-mn-check-c: var(--w7-el-c);
|
|
||||||
--w7-glass-title: linear-gradient(135deg, #ffffff1a 70px, transparent 100px), linear-gradient(225deg, #ffffff1a 70px, transparent 100px), linear-gradient(54deg, #0003 0 4%, #ffffff08 6% 6%, #0003 8% 10%, #0003 15% 16%, #ffffff0d 17% 18%, #0003 23% 24%, #ffffff10 25% 26%, #0003 31% 33%, #0003 34% 34.5%, #ffffff10 36% 40%, #0003 41% 41.5%, #ffffff10 44% 45%, #ffffff10 46% 47%, #0003 48% 49%, #0003 50% 50.5%, #0003 56% 56.5%, #ffffff10 57% 63%, #0003 67% 69%, #ffffff10 69.5% 70%, #0003 73.5% 74%, #ffffff10 74.5% 79%, #0003 80% 84%, #ffffff0d 85% 86%, #0003 87%, #ffffff0a 90%) 0 / 100vw 100vh no-repeat fixed;
|
|
||||||
/* Ares: white caption on black glass, dark halo instead of white */
|
|
||||||
--w7-cap-c: var(--w7-title-c);
|
|
||||||
--w7-cap-glow: 0 0 8px #000c, 0 0 8px #000c, 0 1px 2px #000;
|
|
||||||
--w7-link-c-h: #9acbff;
|
|
||||||
|
|
||||||
--background: #1f1f1f;
|
|
||||||
--chrome: #2b2b2b;
|
|
||||||
--card: #2b2b2b;
|
|
||||||
--foreground: #f0f0f0;
|
|
||||||
--card-foreground: #f0f0f0;
|
|
||||||
--muted-foreground: #a0a0a0;
|
|
||||||
--secondary: #383838;
|
|
||||||
--secondary-foreground: #f0f0f0;
|
|
||||||
--muted: #383838;
|
|
||||||
--accent: #2f4a5c;
|
|
||||||
--accent-foreground: #f0f0f0;
|
|
||||||
--border: #3c3c3c;
|
|
||||||
--input: #55585c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark tone "darker": Aero has no meaningful true black, so rather than leave
|
|
||||||
the Tone row a dead control the skin just goes a step deeper. Duplicated
|
|
||||||
forced + system like everything else. */
|
|
||||||
:root.win7[data-theme='dark'][data-dark-tone='darker'] {
|
|
||||||
--w7-surface: #1a1a1a;
|
|
||||||
--w7-el-bg: #262626;
|
|
||||||
--w7-w-bg: #060607;
|
|
||||||
--background: #121212;
|
|
||||||
--chrome: #1a1a1a;
|
|
||||||
--card: #1a1a1a;
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root.win7:not([data-theme='light'])[data-dark-tone='darker'] {
|
|
||||||
--w7-surface: #1a1a1a;
|
|
||||||
--w7-el-bg: #262626;
|
|
||||||
--w7-w-bg: #060607;
|
|
||||||
--background: #121212;
|
|
||||||
--chrome: #1a1a1a;
|
|
||||||
--card: #1a1a1a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Accent -> Aero window colour, so the existing accent picker keeps meaning
|
|
||||||
under the skin. --w7-w-bg is the one colour 7.css documents as settable.
|
|
||||||
LIGHT ONLY: in dark the glass is Ares black regardless of accent (the accent
|
|
||||||
keeps tinting selection etc. through --primary). These arms out-rank the
|
|
||||||
accent rule below on specificity — without them it wins the tie on source
|
|
||||||
order and dark glass turns accent-coloured. */
|
|
||||||
:root.win7[data-accent] {
|
|
||||||
--w7-w-bg: var(--primary);
|
|
||||||
}
|
|
||||||
:root.win7[data-theme='dark'][data-accent] {
|
|
||||||
--w7-w-bg: #101013;
|
|
||||||
}
|
|
||||||
:root.win7[data-theme='dark'][data-dark-tone='darker'][data-accent] {
|
|
||||||
--w7-w-bg: #060607;
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root.win7:not([data-theme='light'])[data-accent] {
|
|
||||||
--w7-w-bg: #101013;
|
|
||||||
}
|
|
||||||
:root.win7:not([data-theme='light'])[data-dark-tone='darker'][data-accent] {
|
|
||||||
--w7-w-bg: #060607;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Unlayered on purpose: the html/body rule above sets font-family unlayered,
|
|
||||||
so a layered override would lose. Family only — NOT font-size: the app's
|
|
||||||
rem-based utilities are root-relative, and shifting the root would move
|
|
||||||
every line box for no gain. */
|
|
||||||
.win7 body {
|
|
||||||
font-family: var(--w7-family);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The win7 window is a FRAMELESS, natively transparent window whose only
|
|
||||||
opaque surface is meant to be `.window` — that is the whole premise of the
|
|
||||||
6px rounded frame, since the area outside the radius must let the desktop
|
|
||||||
through. The unlayered `html, body { background: var(--background) }` above
|
|
||||||
broke that premise silently: it painted an opaque rectangle behind the
|
|
||||||
frame, so the rounded corners had an opaque ground filling them in and the
|
|
||||||
window read as a rectangle with a drawn-on curve.
|
|
||||||
Unlayered on purpose, same reason as the font-family rule: the html/body
|
|
||||||
background it overrides is itself unlayered, and unlayered declarations beat
|
|
||||||
every @layer regardless of specificity — a `skinfix` rule could not touch
|
|
||||||
it. Scoped to .win7 so the modern skin keeps its opaque ground. */
|
|
||||||
html.win7,
|
|
||||||
html.win7 body {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Neutralizers live in `skin`, NOT `skinfix`, and this is load-bearing:
|
|
||||||
`revert-layer` rolls a property back to the previous LAYER, so from inside
|
|
||||||
`skin` it lands on `utilities` (the Tailwind classes already in the
|
|
||||||
templates) — which is the whole point. From `skinfix` it would land back on
|
|
||||||
7.css and do nothing. Within `skin` these beat the imported rules on equal
|
|
||||||
specificity by source order, since the import is at the top of this file. */
|
|
||||||
@layer skin {
|
|
||||||
/* 7.css sizes every <button> like a Win7 dialog push button
|
|
||||||
(min-width 75px, min-height 23px, padding 0 12px). Most of this app's 84
|
|
||||||
buttons are ~24px icon squares sized by utilities, so without this the tab
|
|
||||||
bar wraps and the options popover overflows. Hand the BOX MODEL back to
|
|
||||||
the utilities layer and keep 7.css's paint. The title bar's own controls
|
|
||||||
are matched by a MORE specific 7.css selector (.title-bar-controls button)
|
|
||||||
and so keep their Aero glyph metrics untouched. */
|
|
||||||
.win7 button {
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
padding: revert-layer;
|
|
||||||
text-align: revert-layer;
|
|
||||||
color: revert-layer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 7.css hardcodes #fff / #abadb3 / #222 on form controls and tables, outside
|
|
||||||
its own var system. Reverting routes them back through the bridged tokens
|
|
||||||
above, which is what makes dark mode nearly free. */
|
|
||||||
.win7 input[type='text'],
|
|
||||||
.win7 input[type='password'],
|
|
||||||
.win7 input[type='number'],
|
|
||||||
.win7 input[type='search'],
|
|
||||||
.win7 textarea {
|
|
||||||
background-color: revert-layer;
|
|
||||||
border-color: revert-layer;
|
|
||||||
}
|
|
||||||
.win7 table {
|
|
||||||
background-color: revert-layer;
|
|
||||||
color: revert-layer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Win7 toolbar buttons are FLAT with a hover glow, not Aero slabs —
|
|
||||||
Explorer's command bar worked exactly that way. `w7-toolbar` marks the
|
|
||||||
three header rows; a plain `.title-bar` sibling is deliberately NOT one of
|
|
||||||
them, so its controls keep the real Aero treatment. `w7-flat` is the
|
|
||||||
per-button escape hatch. Both classes are inert under the modern skin.
|
|
||||||
`:not([role='tab'])` is load-bearing: the workspace strip IS a w7-toolbar,
|
|
||||||
so without the exemption this rule would strip the border, gradient and
|
|
||||||
both pseudo-elements off every tab and leave 7.css's tab component drawing
|
|
||||||
nothing. Tabs want the full Aero slab; the +/Connections/options buttons
|
|
||||||
beside them want the flat treatment.
|
|
||||||
Only `background` stays here — it is the one property that needs to land
|
|
||||||
on `utilities` via revert-layer. The literals that suppress the Aero slab
|
|
||||||
moved to `skinfix`, because 7.css restates border-color at
|
|
||||||
`.win7 button:not(:disabled):not(:hover)` = (0,3,1), which outranks
|
|
||||||
`.win7 .w7-flat` = (0,2,0). The toolbar arm only ever won that fight by
|
|
||||||
tying on specificity and landing later in the same layer — an accident,
|
|
||||||
and one w7-flat did not share: it rendered a bordered slab regardless. */
|
|
||||||
.win7 .w7-toolbar button:not([role='tab']),
|
|
||||||
.win7 .w7-flat {
|
|
||||||
background: revert-layer;
|
|
||||||
}
|
|
||||||
.win7 .w7-toolbar button:not([role='tab'])::before,
|
|
||||||
.win7 .w7-toolbar button:not([role='tab'])::after,
|
|
||||||
.win7 .w7-flat::before,
|
|
||||||
.win7 .w7-flat::after {
|
|
||||||
content: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Above `skin`, for rules that must state a value rather than defer to a
|
|
||||||
neighbouring layer. */
|
|
||||||
@layer skinfix {
|
|
||||||
/* ---- Scale bridge: re-express 7.css's px metrics in rem ----
|
|
||||||
7.css sizes everything in px (370 of them, zero rem) and its --w7-font
|
|
||||||
shorthand reaches only 15 rules' TYPOGRAPHY — the boxes stay frozen. Under
|
|
||||||
the interface scale (root font-size lever) win7 text would grow inside
|
|
||||||
23px buttons, 14px table rows and a 15px-line-height caption. Every value
|
|
||||||
below is 7.css's own, converted at the 16px root (23px -> 1.4375rem …),
|
|
||||||
so scale 1.0 renders pixel-identical to stock and every other scale keeps
|
|
||||||
the boxes proportional to the text.
|
|
||||||
Deliberately NOT converted: 1px hairlines and focus rects (the tr border
|
|
||||||
that stops selection shifting rows, .is-cursor's 1px dotted outline, menu
|
|
||||||
bevels, the frame's inset glass ring) — those stay crisp at any scale. */
|
|
||||||
|
|
||||||
/* 7.css's own size vars — defined on .win7 in px; rem lands on the same
|
|
||||||
pixels at scale 1. Covers checkbox/radio/slider/tooltip/treeview/menu
|
|
||||||
gutters and both window metrics in one shot. */
|
|
||||||
.win7 {
|
|
||||||
--w7-cb-size: 0.875rem; /* 14px */
|
|
||||||
--w7-rd-size: 0.875rem;
|
|
||||||
--w7-rdd-size: 0.5rem; /* 8px */
|
|
||||||
--w7-rdl-space: 0.375rem; /* 6px */
|
|
||||||
--w7-sdt-w: 0.625rem; /* 10px */
|
|
||||||
--w7-sdt-h: 1.125rem; /* 18px */
|
|
||||||
--w7-blt-size: 1.125rem;
|
|
||||||
--w7-tvb-size: 0.5rem;
|
|
||||||
--w7-tv-left: 1.25rem; /* 20px */
|
|
||||||
--w7-clc-size: 0.3125rem; /* 5px */
|
|
||||||
--w7-lvc-size: 0.3125rem;
|
|
||||||
--w7-mn-left: 1.75rem; /* 28px */
|
|
||||||
--w7-el-bdr: 0.1875rem; /* 3px */
|
|
||||||
--w7-w-space: 0.375rem; /* 6px */
|
|
||||||
--w7-w-bdr: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The hardcoded box metrics the vars do not reach, restated in rem; skinfix
|
|
||||||
outranks skin by layer, so these win over 7.css.
|
|
||||||
NO blanket `.win7 button` rule here, deliberately: the `skin`-layer
|
|
||||||
neutralizer strips 7.css's 75x23 metrics off every button (most are icon
|
|
||||||
squares sized by utilities), and a skinfix restatement would outrank that
|
|
||||||
neutralizer by LAYER and re-inflate all of them. The only buttons that
|
|
||||||
legitimately wear Win7 push-button metrics are .w7-btn (opted in below)
|
|
||||||
and the title-bar controls (which beat the neutralizer on specificity
|
|
||||||
inside `skin` and are restated here). */
|
|
||||||
.win7 input[type='email'],
|
|
||||||
.win7 input[type='number'],
|
|
||||||
.win7 input[type='password'],
|
|
||||||
.win7 input[type='search'],
|
|
||||||
.win7 input[type='text'],
|
|
||||||
.win7 input[type='url'] {
|
|
||||||
height: 1.4375rem;
|
|
||||||
}
|
|
||||||
.win7 input[type='email'],
|
|
||||||
.win7 input[type='number'],
|
|
||||||
.win7 input[type='password'],
|
|
||||||
.win7 input[type='search'],
|
|
||||||
.win7 input[type='text'],
|
|
||||||
.win7 input[type='url'],
|
|
||||||
.win7 textarea {
|
|
||||||
padding: 0.1875rem 0.25rem 0.3125rem; /* 3px 4px 5px */
|
|
||||||
}
|
|
||||||
.win7 table > thead > tr > * {
|
|
||||||
height: 1.375rem; /* 22px */
|
|
||||||
padding: 0 0.5rem; /* 0 8px */
|
|
||||||
}
|
|
||||||
.win7 table > tbody > tr > * {
|
|
||||||
height: 0.875rem; /* 14px */
|
|
||||||
padding: 0.125rem 0.5rem; /* 2px 8px */
|
|
||||||
}
|
|
||||||
.win7 .title-bar-text {
|
|
||||||
line-height: 0.9375rem; /* 15px */
|
|
||||||
}
|
|
||||||
.win7 .title-bar-controls button {
|
|
||||||
min-height: 1.1875rem; /* 19px */
|
|
||||||
min-width: 1.8125rem; /* 29px */
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
/* BOTH of 7.css's wide-close selectors, not just .is-close: the app's title
|
|
||||||
bars key the buttons on aria-label, and the base rule above outranks the
|
|
||||||
`skin`-layer variant BY LAYER — restating only one arm shrank the close
|
|
||||||
button from 48px to 29px (caught by the harness pixel diff). */
|
|
||||||
.win7 .title-bar-controls button.is-close,
|
|
||||||
.win7 .title-bar-controls button[aria-label='Close'] {
|
|
||||||
min-width: 3rem; /* 48px */
|
|
||||||
}
|
|
||||||
.win7 ul[role='menu'] {
|
|
||||||
min-width: 9.375rem; /* 150px */
|
|
||||||
padding: 0.125rem; /* 2px */
|
|
||||||
}
|
|
||||||
.win7 ul[role='menu'] > [role='menuitem'] > a,
|
|
||||||
.win7 ul[role='menu'] > [role='menuitem'] > button,
|
|
||||||
.win7 ul[role='menu'] > [role='menuitem'] > label {
|
|
||||||
padding: 0.25rem 0.625rem 0.25rem 2rem; /* 4px 10px 4px 32px */
|
|
||||||
}
|
|
||||||
.win7 menu[role='tablist'] button {
|
|
||||||
padding: 0.125rem 0.375rem; /* 2px 6px */
|
|
||||||
}
|
|
||||||
/* The raised active tab: 7.css gives it padding-bottom 4px at (0,2,2) inside
|
|
||||||
`skin`, but the shorthand above outranks that BY LAYER and would flatten
|
|
||||||
the raise — so the variant is restated here too. */
|
|
||||||
.win7 menu[role='tablist'] button[aria-selected='true'] {
|
|
||||||
padding-bottom: 0.25rem; /* 4px */
|
|
||||||
}
|
|
||||||
/* The 10pt checkmark glyph in menu checkables — one of five pt sizes in
|
|
||||||
7.css that bypass --w7-font entirely. 10pt = 13.333px ≈ 0.8333rem. */
|
|
||||||
.win7 ul [role='menuitem'] > input[type='checkbox']:checked + label:after {
|
|
||||||
font-size: 0.8333rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Opt back IN to real Win7 push-button metrics, for the dialog and options
|
|
||||||
buttons where they are right. Same rem values as the button rule above —
|
|
||||||
this class exists to undo the `skin`-layer neutralizer, not to differ. */
|
|
||||||
.win7 .w7-btn {
|
|
||||||
min-width: 4.6875rem;
|
|
||||||
min-height: 1.4375rem;
|
|
||||||
padding: 0 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- ListView (file panels) ----
|
|
||||||
7.css's listview.css hardcodes light literals (#fff, #c0c1cd, #eee and the
|
|
||||||
white header gradient) instead of --w7-* vars, so unlike the rest of the
|
|
||||||
skin it does NOT follow the hand-authored dark palette — the header labels
|
|
||||||
came out invisible, dark on dark. These restate the same rules in dark
|
|
||||||
terms; light keeps 7.css's own values untouched.
|
|
||||||
|
|
||||||
CRITICAL: the base surface goes on `table`, NEVER on `tbody > tr > *`.
|
|
||||||
7.css paints the selection band on the ROW, and a cell background paints
|
|
||||||
over a row background — setting one erased the selection highlight
|
|
||||||
entirely in dark mode. */
|
|
||||||
.win7 table {
|
|
||||||
background-color: var(--w7-lv-bg);
|
|
||||||
border-color: var(--w7-lv-bd);
|
|
||||||
color: var(--w7-lv-c);
|
|
||||||
}
|
|
||||||
.win7 table > thead > tr > * {
|
|
||||||
background: var(--w7-lv-head-grad);
|
|
||||||
border-color: var(--w7-lv-head-bd);
|
|
||||||
color: var(--w7-lv-c);
|
|
||||||
}
|
|
||||||
.win7 table > tbody > tr > :not(:last-child) {
|
|
||||||
border-right-color: var(--w7-lv-cell-bd);
|
|
||||||
}
|
|
||||||
/* 7.css drops the cell separators inside a selected row; the rule above is
|
|
||||||
more specific than its, so restore that behaviour explicitly. */
|
|
||||||
.win7 table > tbody > tr.highlighted > :not(:last-child) {
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
.win7 table > tbody > tr.highlighted {
|
|
||||||
background: var(--w7-sel-bg-idle);
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Window frame ----
|
|
||||||
The native window is transparent (chromeOptions in src/bun/windows.ts), and
|
|
||||||
nothing but .window may paint an opaque surface — that is what lets the 6px
|
|
||||||
corner radius actually be transparent at the corners. That invariant is NOT
|
|
||||||
free: html/body paint an opaque background unlayered, and had to be reset
|
|
||||||
to transparent for .win7 (see the rule above @layer skin). Anything else
|
|
||||||
that paints full-bleed will fill the corners the same way.
|
|
||||||
NO drop shadow, by request: 7.css's `2px 2px 10px 1px #000000b3` needed a
|
|
||||||
margin gutter to fall into and still read heavy against a real desktop, so
|
|
||||||
the frame keeps only the inset 1px glass highlight from that shorthand
|
|
||||||
(dropping the whole box-shadow would lose the highlight too — it lives in
|
|
||||||
the same declaration). No shadow -> no gutter -> full-bleed 100vh.
|
|
||||||
Keyed on `w7-frame` — the app hook the three window ROOTS carry — never on
|
|
||||||
bare .window: on any other .window (dialogs, demos) this sizing is
|
|
||||||
nonsense and 7.css's own shadow is fine. */
|
|
||||||
.win7 .window.w7-frame {
|
|
||||||
margin: 0;
|
|
||||||
height: 100vh;
|
|
||||||
box-shadow: inset 0 0 0 1px #fffa;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A maximized Win7 window has SQUARE corners — the radius only exists to let
|
|
||||||
the desktop show past a floating frame, and a rounded maximized window
|
|
||||||
leaves four notches of desktop in the screen corners.
|
|
||||||
Read off the title bar's own `is-maximized` (title-bar.ts publishes it on
|
|
||||||
its host) with :has(), so the frame reacts without a second windowState
|
|
||||||
subscription. .title-bar is a DIRECT child of .window — app-title-bar
|
|
||||||
carries the class on its host element, which is why `>` holds here.
|
|
||||||
The radius is declared three times in 7.css — on .window and on both the
|
|
||||||
::before tint and the ::after glass layer — so all three must be zeroed or
|
|
||||||
a rounded pseudo-element still cuts the corner off the square frame. */
|
|
||||||
.win7 .window.w7-frame:has(> .title-bar.is-maximized),
|
|
||||||
.win7 .window.w7-frame:has(> .title-bar.is-maximized)::before,
|
|
||||||
.win7 .window.w7-frame:has(> .title-bar.is-maximized)::after {
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* STOPGAP — the synthesized resize handles (window-resize/window-resize.ts,
|
|
||||||
which carries the removal checklist). A maximized window has no edges to
|
|
||||||
grab, so the overlay goes away entirely while it is maximized, and comes
|
|
||||||
back on restore.
|
|
||||||
Read off the title bar's own `is-maximized` with :has(), exactly like the
|
|
||||||
corner-squaring rule above — that is what keeps this component free of a
|
|
||||||
second `windowState` subscription (RpcService's push registries have no
|
|
||||||
off(), so every subscription is permanent). */
|
|
||||||
.win7 .window.w7-frame:has(> .title-bar.is-maximized) .w7-resize {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hovering the close button paints 7.css's red glow ::before
|
|
||||||
(box-shadow: 0 0 7px 3px #e68e75), and box-shadows of CHILDREN are not
|
|
||||||
clipped by the parent's border or radius — the caption buttons sit flush
|
|
||||||
against the window's top edge (border-top: 0), so the glow spilled ABOVE
|
|
||||||
the frame: a red tint across the top border and a red halo floating on the
|
|
||||||
desktop past it (scripts/hover-probe.ts renders the forced-hover state and
|
|
||||||
pixel-scans for exactly this; it caught 100 red pixels above the edge).
|
|
||||||
Clip the title bar's children to its own box on top/left/right — the glow
|
|
||||||
now dies at the frame border, which is where the real Win7 DWM stopped it
|
|
||||||
too — while the bottom stays open (-1rem) so the glow's bleed toward the
|
|
||||||
caption below survives; that part is authentic.
|
|
||||||
clip-path also clips HIT-TESTING, but only for content outside the region:
|
|
||||||
the title-bar's own box (the drag region) is entirely inside it.
|
|
||||||
Scoped to w7-frame: demo/harness windows keep stock 7.css behaviour. */
|
|
||||||
.win7 .window.w7-frame > .title-bar {
|
|
||||||
clip-path: inset(0 0 -1rem 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 7.css's glass layer is `backdrop-filter: blur(4px)` on .window::after —
|
|
||||||
but CSS cannot sample the DESKTOP behind a transparent native window
|
|
||||||
(there is no backdrop to read), and WebKitGTK paints the unsampleable
|
|
||||||
layer BLACK: every see-through part of the glass — the frame ring, the
|
|
||||||
area around the title-bar buttons — composited over black instead of over
|
|
||||||
the desktop. With it off, the real desktop shows through the glass tint,
|
|
||||||
which is closer to Aero than a blur we cannot have. (True blur-behind
|
|
||||||
needs compositor support; GNOME offers none to X11/XWayland clients.) */
|
|
||||||
.win7 .window.glass::after {
|
|
||||||
-webkit-backdrop-filter: none;
|
|
||||||
backdrop-filter: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Constant row geometry. 7.css gives ONLY tr.highlighted a 1px border, and
|
|
||||||
the table is border-collapse: collapse — so a selected row physically grew
|
|
||||||
1px at top and bottom and shoved every row below it down by 2px as the
|
|
||||||
cursor moved. Every row reserves that border instead, transparent when not
|
|
||||||
selected, so selecting changes colour and nothing else.
|
|
||||||
The border stays TRANSPARENT even when selected: it exists purely to
|
|
||||||
reserve space. 7.css outlines the selected row (#aaddfa) on top of its
|
|
||||||
fill, which stacked a second edge inside the band and, with the cursor
|
|
||||||
row's dotted rect, drew three concentric outlines around one row. The fill
|
|
||||||
paints under the transparent border (background-clip defaults to
|
|
||||||
border-box), so the band still covers the full row. */
|
|
||||||
.win7 table > tbody > tr {
|
|
||||||
border: 1px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pane-focus model (commander convention, matching Win7 Explorer):
|
|
||||||
the CURSOR ROW always carries the selection band in BOTH panels —
|
|
||||||
tr.highlighted covers "selected via Space" AND "cursor row" (the template
|
|
||||||
ORs them). What changes with pane focus is the band's strength, never
|
|
||||||
whether it exists:
|
|
||||||
- focused pane -> the flat blue selection (below)
|
|
||||||
- unfocused pane -> the grey idle band, which is what Explorer drew
|
|
||||||
when the list lost focus — faint by design, not a bug.
|
|
||||||
The dotted focus rectangle is NOT the focus signal; it is layered on top
|
|
||||||
of the band in the focused pane only, the way Win7 drew the keyboard
|
|
||||||
focus rect over the selected item.
|
|
||||||
FLAT, and only a fill: 7.css bands the row with --w7-el-grad-h, its Aero
|
|
||||||
element-hover ramp, which reads as a glossy button rather than a file
|
|
||||||
selection, and outlines it as well. One solid colour, no edge — the
|
|
||||||
dotted cursor rect is then the only line on the row, which is what makes
|
|
||||||
it legible as the cursor. */
|
|
||||||
.win7 table.is-active > tbody > tr.highlighted {
|
|
||||||
background: var(--w7-sel-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* One dotted rectangle around the ROW; per cell (tr > *) draws a dotted box
|
|
||||||
around every cell instead — visibly wrong. */
|
|
||||||
.win7 table > tbody > tr.is-cursor {
|
|
||||||
outline: 1px dotted var(--w7-el-c);
|
|
||||||
outline-offset: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Menu (context menu) ----
|
|
||||||
The menu itself is pure 7.css — ul[role=menu] > li[role=menuitem], see
|
|
||||||
ui/src/app/context-menu.ts for the exact contract. (A previous iteration
|
|
||||||
claimed "7.css has no menu component" and hand-built a lookalike here;
|
|
||||||
false — the markup was one level off and its selectors never matched.)
|
|
||||||
Only menu.css's hardcoded LIGHT literals need restating for the dark
|
|
||||||
palette, same as the ListView above: the black `color:initial`, the
|
|
||||||
white bevel halves of the gutter stripe and dividers, and the checked-box
|
|
||||||
ring (#b3d3f9). */
|
|
||||||
.win7 ul[role='menu'] {
|
|
||||||
color: var(--w7-mn-c);
|
|
||||||
border-color: var(--w7-mn-bd);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* THE POSITIONING ONE — this is why right-click looked dead under the skin.
|
|
||||||
menu.css declares `position: relative` on ul[role=menu] (it needs it for
|
|
||||||
the gutter stripe's ::before and for submenu anchoring), and `skin`
|
|
||||||
outranks `utilities`, so Tailwind's `.fixed` on the app's menu lost. Its
|
|
||||||
[style.left/top.px] then became FLOW offsets from the menu's static
|
|
||||||
position — the last child of an h-screen flex column — and every menu
|
|
||||||
rendered below the viewport, unreachable behind `overflow: hidden`. The
|
|
||||||
backdrop is a plain <div>, which 7.css does not match, so it stayed truly
|
|
||||||
fixed and swallowed the next click: "right click does nothing, then the
|
|
||||||
app ignores me once".
|
|
||||||
Scoped to the app menu's own marker class, never blanket: 7.css's own
|
|
||||||
relative positioning must survive for any other ul[role=menu].
|
|
||||||
Regression test: scripts/menu-probe.ts (a resting pixel diff cannot see a
|
|
||||||
positioning bug — nothing about the idle window changes). */
|
|
||||||
.win7 ul[role='menu'].w7-ctx {
|
|
||||||
position: fixed;
|
|
||||||
}
|
|
||||||
.win7 ul[role='menu']:before {
|
|
||||||
box-shadow: var(--w7-mn-stripe);
|
|
||||||
}
|
|
||||||
.win7 ul [role='menuitem'].has-divider:after {
|
|
||||||
box-shadow: var(--w7-mn-divider);
|
|
||||||
}
|
|
||||||
.win7 ul [role='menuitem'] > input[type] + label:before {
|
|
||||||
box-shadow: 0 0 0 1px var(--w7-mn-check-ring);
|
|
||||||
}
|
|
||||||
.win7 ul [role='menuitem'] > input[type='checkbox']:checked + label:after {
|
|
||||||
color: var(--w7-mn-check-c);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Menu items: flat highlight, and no Aero afterglow trailing the pointer.
|
|
||||||
Menu items ARE <button>s, so button.css reaches them with three effects
|
|
||||||
that do not belong on a menu:
|
|
||||||
(1) the highlight is 7.css's translucent wash
|
|
||||||
`linear-gradient(#fff9, #e6ecf5cc 90%, #fffc)` + an #aaddfa edge — the
|
|
||||||
same gradient the ListView selection above just dropped;
|
|
||||||
(2) `button::before` is a blue glow overlay whose opacity fades out over
|
|
||||||
a FULL SECOND (`button:not(:hover):before { transition: opacity 1s
|
|
||||||
linear }`). menu.css sets `content: none` on the :HOVER arm only, so
|
|
||||||
the pseudo reappears the instant you leave an item and then fades 1→0
|
|
||||||
— a ghost dragging behind the pointer. Killing `content` in EVERY
|
|
||||||
state is what removes it; suppressing the transition alone would not,
|
|
||||||
because the glow itself is legitimate on real push buttons;
|
|
||||||
(3) `button:focus` runs `pulse-anim` infinitely, and this menu moves REAL
|
|
||||||
focus between items for keyboard nav — so a focused item would breathe.
|
|
||||||
Scoped to the app's own menu (w7-ctx): 7.css's demo menus keep stock Aero. */
|
|
||||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button,
|
|
||||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > label {
|
|
||||||
transition: none;
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button::before,
|
|
||||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button::after {
|
|
||||||
content: none;
|
|
||||||
}
|
|
||||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button:hover,
|
|
||||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button:focus,
|
|
||||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button:focus-visible,
|
|
||||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > label:hover,
|
|
||||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > label:focus-visible {
|
|
||||||
background: var(--w7-sel-bg);
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Flat buttons (toolbars + the w7-flat escape hatch) ----
|
|
||||||
The paint-suppression half of the rule in `skin` (see there for the split).
|
|
||||||
It lives in `skinfix` because 7.css restates border-color at (0,3,1) via
|
|
||||||
`.win7 button:not(:disabled):not(:hover)` and again on :hover, which no
|
|
||||||
reasonable `.w7-flat` selector outranks — layer order settles it instead
|
|
||||||
of a specificity arms race. */
|
|
||||||
.win7 .w7-toolbar button:not([role='tab']),
|
|
||||||
.win7 .w7-flat {
|
|
||||||
border-color: transparent;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Tabs (workspace strip) ----
|
|
||||||
7.css owns the tab CHROME; these restore the tab CONTENT layout it breaks.
|
|
||||||
`menu[role=tablist] button` sets `display: block` at (0,2,1), which
|
|
||||||
outranks Tailwind's `.flex` in `utilities` — so the icon and the label
|
|
||||||
rendered jammed together with no gap and no truncation. Restate the flex
|
|
||||||
box here (skinfix outranks skin) and leave every painted property alone.
|
|
||||||
The gap matches the gap-1.5 the template asks for. */
|
|
||||||
.win7 menu[role='tablist'] button[role='tab'] {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* tabs.css hardcodes `color: #222` on every tablist button — a light-mode
|
|
||||||
literal outside 7.css's var system, which left the labels near-invisible
|
|
||||||
on the dark surface. Same failure and same remedy as the ListView and menu
|
|
||||||
literals above: route it through a token that both dark blocks override. */
|
|
||||||
.win7 menu[role='tablist'] button {
|
|
||||||
color: var(--w7-tab-c);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The workspace strip scrolls horizontally when tabs overflow. Setting
|
|
||||||
`overflow-x: auto` forces the computed `overflow-y` from visible to auto
|
|
||||||
as well (CSS will not pair visible with a non-visible axis), so the strip
|
|
||||||
clips vertically — and 7.css raises the ACTIVE tab with `margin-top: -2px`,
|
|
||||||
which put its top border outside the scroll box and silently deleted it.
|
|
||||||
Reserve those 2px as padding so the pulled-up tab lands inside the box.
|
|
||||||
7.css already reserves the matching 3px on the left (padding-left: 3px)
|
|
||||||
for the same tab's -3px left margin; it just never anticipated a scroller.
|
|
||||||
Verified by pixel scan: the active tab's top edge must be #8e8f8f, not a
|
|
||||||
straight jump from the toolbar grey to the tab fill. */
|
|
||||||
.win7 .w7-toolbar menu[role='tablist'] {
|
|
||||||
padding-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.win7 .window.glass > .title-bar {
|
|
||||||
background: var(--w7-glass-title);
|
|
||||||
background-attachment: fixed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Caption text: 7.css hardcodes black + a white halo; in dark that becomes
|
|
||||||
the Ares treatment (white caption, dark halo) through the same vars. */
|
|
||||||
.win7 .title-bar-text {
|
|
||||||
color: var(--w7-cap-c);
|
|
||||||
text-shadow: var(--w7-cap-glow);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<!-- .window.glass is 7.css's Aero frame (radius, 1px border, inset highlight,
|
|
||||||
drop shadow, backdrop blur); .active is what un-greys the title bar and its
|
|
||||||
control glyphs — every .title-bar-controls rule is keyed on it. -->
|
|
||||||
<div class="window glass w7-frame flex h-screen flex-col" [class.active]="windowFocused()">
|
|
||||||
@if (!rpc.available) {
|
|
||||||
<div class="flex flex-1 items-center justify-center">
|
|
||||||
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm">
|
|
||||||
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
|
|
||||||
<p class="mt-2 text-sm font-medium">{{ i18n.t('app.notConnectedTitle') }}</p>
|
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
|
||||||
{{ i18n.t('app.notConnectedBody') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<!-- the window is frameless, so this IS the chrome -->
|
|
||||||
<app-title-bar [title]="appName" (closeRequested)="closeWindow()" />
|
|
||||||
|
|
||||||
<!-- 7.css's .window-body: the recessed inner panel, with the 6px inset and
|
|
||||||
its own border that give a Win7 window its depth. -->
|
|
||||||
<div class="window-body flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
||||||
<app-tab-bar />
|
|
||||||
|
|
||||||
<main class="flex min-h-0 flex-1 divide-x divide-border">
|
|
||||||
<app-file-panel side="left" />
|
|
||||||
<app-file-panel side="right" />
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<app-transfer-bar />
|
|
||||||
<app-status-bar />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- modal block: the connection-manager window owns the app until closed -->
|
|
||||||
@if (managerOpen()) {
|
|
||||||
<div
|
|
||||||
class="fixed inset-0 z-60 flex items-center justify-center bg-black/40"
|
|
||||||
(mousedown)="focusManager()"
|
|
||||||
>
|
|
||||||
<div class="rounded-lg border border-border bg-card px-5 py-4 text-center shadow-xl">
|
|
||||||
<ng-icon name="mynaServers" size="1.25rem" class="text-muted-foreground" />
|
|
||||||
<p class="mt-1.5 text-xs text-muted-foreground">{{ i18n.t('app.managerOpen') }}</p>
|
|
||||||
<button
|
|
||||||
class="mt-3 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
{{ i18n.t('app.showConnections') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- dialogs -->
|
|
||||||
@if (dialogs.prompt(); as req) {
|
|
||||||
<app-prompt-dialog [request]="req" />
|
|
||||||
}
|
|
||||||
@if (dialogs.confirm(); as req) {
|
|
||||||
<app-confirm-dialog [request]="req" />
|
|
||||||
}
|
|
||||||
@if (dialogs.chmod(); as req) {
|
|
||||||
<app-chmod-dialog [request]="req" />
|
|
||||||
}
|
|
||||||
@if (dialogs.overwrite(); as req) {
|
|
||||||
<app-overwrite-dialog [request]="req" />
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- STOPGAP: the frameless window has no WM resize borders, so we draw our
|
|
||||||
own handles. Delete with the rest of the hack — checklist in
|
|
||||||
window-resize/window-resize.ts. -->
|
|
||||||
<app-window-resize />
|
|
||||||
|
|
||||||
<app-context-menu />
|
|
||||||
<app-toasts />
|
|
||||||
</div>
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
|
||||||
import { NgIcon } from '@ng-icons/core';
|
|
||||||
import { I18nService } from '@core/i18n.service';
|
|
||||||
import { BRANDING } from '@shared/branding';
|
|
||||||
import type { ColumnKey } from '@shared/types';
|
|
||||||
import { ConnectionsService } from '@core/connections.service';
|
|
||||||
import { ContextMenu } from './context-menu';
|
|
||||||
import { ChmodDialog } from './dialogs/chmod-dialog';
|
|
||||||
import { ConfirmDialog } from './dialogs/confirm-dialog';
|
|
||||||
import { OverwriteDialog } from './dialogs/overwrite-dialog';
|
|
||||||
import { PromptDialog } from './dialogs/prompt-dialog';
|
|
||||||
import { DialogsService } from '@core/dialogs.service';
|
|
||||||
import { FileActionsService } from '@core/file-actions.service';
|
|
||||||
import { FilePanel } from './file-panel/file-panel';
|
|
||||||
import { PanelsService } from '@core/panels.service';
|
|
||||||
import { blockNativeContextMenu } from '@core/platform';
|
|
||||||
import { RpcService } from '@core/rpc.service';
|
|
||||||
import { SettingsService } from '@core/settings.service';
|
|
||||||
import { ShortcutsService } from '@core/shortcuts.service';
|
|
||||||
import { StatusBar } from './status-bar/status-bar';
|
|
||||||
import { TabBar } from './tab-bar/tab-bar';
|
|
||||||
import { TitleBar } from './title-bar/title-bar';
|
|
||||||
import { ToastService } from '@core/toast.service';
|
|
||||||
import { Toasts } from './toasts/toasts';
|
|
||||||
import { TransferBar } from './transfer-bar/transfer-bar';
|
|
||||||
import { WindowResize } from './window-resize/window-resize';
|
|
||||||
import { TransfersService } from '@core/transfers.service';
|
|
||||||
import { UpdaterService } from '@core/updater.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-root',
|
|
||||||
imports: [
|
|
||||||
NgIcon,
|
|
||||||
FilePanel,
|
|
||||||
TabBar,
|
|
||||||
StatusBar,
|
|
||||||
PromptDialog,
|
|
||||||
ConfirmDialog,
|
|
||||||
ChmodDialog,
|
|
||||||
OverwriteDialog,
|
|
||||||
Toasts,
|
|
||||||
TransferBar,
|
|
||||||
TitleBar,
|
|
||||||
ContextMenu,
|
|
||||||
WindowResize,
|
|
||||||
],
|
|
||||||
templateUrl: './app.html',
|
|
||||||
host: {
|
|
||||||
'(window:keydown)': 'onWindowKeydown($event)',
|
|
||||||
'(window:contextmenu)': 'blockContextMenu($event)',
|
|
||||||
// Aero greys an inactive window's title bar and controls. The webview gets
|
|
||||||
// DOM focus/blur when the native window's focus changes, so this needs no
|
|
||||||
// RPC — see the `active` binding on .window in app.html.
|
|
||||||
'(window:focus)': 'windowFocused.set(true)',
|
|
||||||
'(window:blur)': 'windowFocused.set(false)',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export class App {
|
|
||||||
readonly i18n = inject(I18nService);
|
|
||||||
readonly rpc = inject(RpcService);
|
|
||||||
readonly toast = inject(ToastService);
|
|
||||||
readonly dialogs = inject(DialogsService);
|
|
||||||
readonly panels = inject(PanelsService);
|
|
||||||
readonly actions = inject(FileActionsService);
|
|
||||||
readonly connections = inject(ConnectionsService);
|
|
||||||
private readonly transfers = inject(TransfersService);
|
|
||||||
private readonly settings = inject(SettingsService);
|
|
||||||
private readonly shortcuts = inject(ShortcutsService);
|
|
||||||
private readonly updater = inject(UpdaterService);
|
|
||||||
|
|
||||||
/** The connection-manager window is open — this window is modal-blocked. */
|
|
||||||
readonly managerOpen = signal(false);
|
|
||||||
|
|
||||||
/** Drives `.window.active`: Aero desaturates an unfocused window's chrome. */
|
|
||||||
readonly windowFocused = signal(document.hasFocus());
|
|
||||||
|
|
||||||
/** Shown in the win7 title bar (no native titlebar to carry it). */
|
|
||||||
readonly appName = BRANDING.appName;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* win7 title bar's X. Closing the main window quits, and the app-level
|
|
||||||
* before-quit guard (dirty editors + running transfers) still runs — it is
|
|
||||||
* the one close path electrobun lets us cancel.
|
|
||||||
*/
|
|
||||||
closeWindow(): void {
|
|
||||||
void this.rpc.request.windowClose({});
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
document.title = BRANDING.appName;
|
|
||||||
if (this.rpc.available) {
|
|
||||||
this.rpc.onMenuAction((action) => void this.handleMenuAction(action));
|
|
||||||
this.rpc.onManagerState((open) => this.managerOpen.set(open));
|
|
||||||
void this.promptForEditLeftovers();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
focusManager(): void {
|
|
||||||
void this.rpc.request.openConnectionManager({});
|
|
||||||
}
|
|
||||||
|
|
||||||
blockContextMenu(event: Event): void {
|
|
||||||
blockNativeContextMenu(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Temp files from external edits in a previous run (crash / quit mid-edit). */
|
|
||||||
private async promptForEditLeftovers(): Promise<void> {
|
|
||||||
const res = await this.rpc.request.getEditLeftovers({});
|
|
||||||
if (!res.ok || !res.names.length) return;
|
|
||||||
const shown = res.names.slice(0, 5).join(', ');
|
|
||||||
const more =
|
|
||||||
res.names.length > 5 ? this.i18n.t('toasts.leftoverMore', { count: res.names.length - 5 }) : '';
|
|
||||||
this.dialogs.confirm.set({
|
|
||||||
title: this.i18n.t('toasts.leftoverTitle'),
|
|
||||||
body: this.i18n.t('toasts.leftoverBody', { count: res.names.length, names: shown + more }),
|
|
||||||
confirmLabel: this.i18n.t('toasts.leftoverClear'),
|
|
||||||
destructive: true,
|
|
||||||
onConfirm: () => void this.rpc.request.clearEditLeftovers({}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A connection by id; reloads once if unseen (a manager-window save can
|
|
||||||
* race the connectionsChanged push). */
|
|
||||||
private async findConnection(id: string) {
|
|
||||||
const lookup = () => this.connections.connections().find((c) => c.id === id);
|
|
||||||
if (!lookup()) await this.connections.reload();
|
|
||||||
return lookup();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Native application-menu / manager-window actions forwarded from bun. */
|
|
||||||
private async handleMenuAction(action: string): Promise<void> {
|
|
||||||
if (action.startsWith('ctx-')) {
|
|
||||||
this.handleContextAction(action);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (action.startsWith('connect:')) {
|
|
||||||
const connection = await this.findConnection(action.slice('connect:'.length));
|
|
||||||
if (connection) {
|
|
||||||
// remote goes right, local stays left — the commander convention
|
|
||||||
void this.panels.connectPanel('right', connection, connection.name, connection.localDir);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (action.startsWith('col:')) {
|
|
||||||
this.settings.toggleColumn(action.slice('col:'.length) as ColumnKey);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (action.startsWith('tab:')) {
|
|
||||||
this.handleTabAction(action.slice('tab:'.length));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (action.startsWith('open-stack:')) {
|
|
||||||
const id = action.slice('open-stack:'.length);
|
|
||||||
let stackConnections = this.connections.connections().filter((c) => c.stackId === id);
|
|
||||||
if (!stackConnections.length) {
|
|
||||||
await this.connections.reload();
|
|
||||||
stackConnections = this.connections.connections().filter((c) => c.stackId === id);
|
|
||||||
}
|
|
||||||
if (stackConnections.length) this.panels.openStack(stackConnections);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
switch (action) {
|
|
||||||
case 'new-tab':
|
|
||||||
this.panels.newWorkspace();
|
|
||||||
break;
|
|
||||||
case 'close-tab':
|
|
||||||
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
|
|
||||||
break;
|
|
||||||
case 'new-folder':
|
|
||||||
this.actions.mkdir(this.panels.active());
|
|
||||||
break;
|
|
||||||
case 'new-file':
|
|
||||||
this.actions.createFile(this.panels.active());
|
|
||||||
break;
|
|
||||||
case 'edit-file':
|
|
||||||
void this.actions.edit(this.panels.active());
|
|
||||||
break;
|
|
||||||
case 'refresh-active':
|
|
||||||
void this.panels.active().refresh();
|
|
||||||
break;
|
|
||||||
case 'check-for-updates':
|
|
||||||
void this.updater.check();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Window shortcuts — matching lives in ShortcutsService (one keymap for the
|
|
||||||
* app); this switch only maps ids onto actions. Panel-level keys stay on
|
|
||||||
* the panels themselves. */
|
|
||||||
onWindowKeydown(event: KeyboardEvent): void {
|
|
||||||
if (this.managerOpen()) return; // modal-blocked behind the manager
|
|
||||||
const match = this.shortcuts.resolve(event, 'main');
|
|
||||||
if (!match) return;
|
|
||||||
// scale keys are hostile mid-typing ('-' in a rename prompt must type '-');
|
|
||||||
// the tab shortcuts keep firing there, as they always did
|
|
||||||
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
|
|
||||||
event.preventDefault();
|
|
||||||
switch (match.id) {
|
|
||||||
case 'new-tab':
|
|
||||||
this.panels.newWorkspace();
|
|
||||||
break;
|
|
||||||
case 'close-tab':
|
|
||||||
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
|
|
||||||
break;
|
|
||||||
case 'cycle-tab':
|
|
||||||
this.panels.cycleWorkspace(1);
|
|
||||||
break;
|
|
||||||
case 'cycle-tab-back':
|
|
||||||
this.panels.cycleWorkspace(-1);
|
|
||||||
break;
|
|
||||||
case 'go-to-tab':
|
|
||||||
// ⌘1–⌘8 jump to that tab, ⌘9 to the last, like Firefox
|
|
||||||
this.panels.goToWorkspace(match.digit === 9 ? -1 : match.digit! - 1);
|
|
||||||
break;
|
|
||||||
case 'scale-up':
|
|
||||||
this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
|
||||||
break;
|
|
||||||
case 'scale-down':
|
|
||||||
this.settings.setUiScale(this.settings.uiScale() - 0.1);
|
|
||||||
break;
|
|
||||||
case 'scale-reset':
|
|
||||||
this.settings.setUiScale(1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Context-menu actions apply to the row/panel remembered at right-click time. */
|
|
||||||
/**
|
|
||||||
* Workspace-tab menu actions. The target id travels INSIDE the action string
|
|
||||||
* because the right-clicked tab is not necessarily the active one — nothing
|
|
||||||
* here may fall back to activeWorkspaceId.
|
|
||||||
*/
|
|
||||||
private handleTabAction(action: string): void {
|
|
||||||
if (action === 'new') {
|
|
||||||
this.panels.newWorkspace();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const parsed = /^([a-z-]+):(\d+)$/.exec(action);
|
|
||||||
if (!parsed) return;
|
|
||||||
const id = Number(parsed[2]);
|
|
||||||
switch (parsed[1]) {
|
|
||||||
case 'close':
|
|
||||||
this.panels.closeWorkspace(id);
|
|
||||||
break;
|
|
||||||
case 'close-others':
|
|
||||||
this.panels.closeOtherWorkspaces(id);
|
|
||||||
break;
|
|
||||||
case 'close-right':
|
|
||||||
this.panels.closeWorkspacesToRight(id);
|
|
||||||
break;
|
|
||||||
case 'disconnect':
|
|
||||||
void this.panels.disconnectWorkspace(id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleContextAction(action: string): void {
|
|
||||||
const target = this.actions.menuTarget;
|
|
||||||
if (!target) return;
|
|
||||||
const { panel, side, entry } = target;
|
|
||||||
// the empty-space menu acts on the directory itself
|
|
||||||
const path = entry?.path ?? panel.cwd();
|
|
||||||
switch (action) {
|
|
||||||
case 'ctx-open':
|
|
||||||
if (entry) panel.open(entry);
|
|
||||||
break;
|
|
||||||
// local shell integration — only bun can reach the OS shell/clipboard
|
|
||||||
case 'ctx-reveal':
|
|
||||||
void this.rpc.request.shellItemAction({ action: 'reveal', path });
|
|
||||||
break;
|
|
||||||
case 'ctx-open-default':
|
|
||||||
void this.rpc.request.shellItemAction({ action: 'open', path });
|
|
||||||
break;
|
|
||||||
case 'ctx-copy-path':
|
|
||||||
void this.rpc.request.shellItemAction({ action: 'copyPath', path });
|
|
||||||
break;
|
|
||||||
case 'ctx-edit':
|
|
||||||
void this.actions.edit(panel);
|
|
||||||
break;
|
|
||||||
case 'ctx-copy':
|
|
||||||
void this.transfers.copyToOtherPanel(side);
|
|
||||||
break;
|
|
||||||
case 'ctx-rename':
|
|
||||||
this.actions.rename(panel);
|
|
||||||
break;
|
|
||||||
case 'ctx-chmod':
|
|
||||||
this.actions.chmod(panel);
|
|
||||||
break;
|
|
||||||
case 'ctx-delete':
|
|
||||||
this.actions.delete(panel);
|
|
||||||
break;
|
|
||||||
case 'ctx-trash':
|
|
||||||
if (entry) void this.actions.trash(panel, entry);
|
|
||||||
break;
|
|
||||||
case 'ctx-new-folder':
|
|
||||||
this.actions.mkdir(panel);
|
|
||||||
break;
|
|
||||||
case 'ctx-new-file':
|
|
||||||
this.actions.createFile(panel);
|
|
||||||
break;
|
|
||||||
case 'ctx-refresh':
|
|
||||||
void panel.refresh();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,390 +0,0 @@
|
|||||||
<div class="window glass w7-frame flex h-screen flex-col" [class.active]="windowFocused()">
|
|
||||||
<!-- Frameless window. Fixed-size modal, so Close only. -->
|
|
||||||
<app-title-bar
|
|
||||||
[title]="i18n.t('connMgr.title')"
|
|
||||||
[resizable]="false"
|
|
||||||
(closeRequested)="close()"
|
|
||||||
/>
|
|
||||||
<!-- Toolbar strip under the title bar. h-9/pl-3 are load-bearing: without a
|
|
||||||
height this row collapses onto the icon and the screen reads as broken. -->
|
|
||||||
<header
|
|
||||||
class="w7-toolbar flex h-9 shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2 pl-3"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaServers" size="0.95rem" class="shrink-0 text-muted-foreground" />
|
|
||||||
<h1 class="text-sm font-semibold">{{ i18n.t('connMgr.title') }}</h1>
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
<button
|
|
||||||
(click)="importWinScp()"
|
|
||||||
[disabled]="importing()"
|
|
||||||
[title]="i18n.t('connMgr.importWinscpHint')"
|
|
||||||
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground disabled:opacity-50"
|
|
||||||
>
|
|
||||||
@if (importing()) {
|
|
||||||
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
|
|
||||||
} @else {
|
|
||||||
<!-- <ng-icon name="mynaArchive" size="0.85rem" /> -->
|
|
||||||
}
|
|
||||||
{{ i18n.t('connMgr.importWinscp') }}
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="flex min-h-0 flex-1">
|
|
||||||
<!-- stacks + connections list -->
|
|
||||||
<div class="flex w-72 shrink-0 flex-col border-r border-border bg-chrome">
|
|
||||||
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
|
||||||
<button
|
|
||||||
(click)="newConnection()"
|
|
||||||
[title]="i18n.t('connMgr.newConnectionHint')"
|
|
||||||
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaPlus" size="0.85rem" /> {{ i18n.t('connMgr.newConnection') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="newStack()"
|
|
||||||
[title]="i18n.t('connMgr.newStackHint')"
|
|
||||||
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaFolderPlus" size="0.85rem" /> {{ i18n.t('connMgr.newStack') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="has-scrollbar flex-1 overflow-y-auto py-1">
|
|
||||||
<!-- stacks -->
|
|
||||||
@for (group of store.grouped().stacks; track group.stack.id) {
|
|
||||||
<div
|
|
||||||
(dragover)="onDragOver($event, group.stack.id)"
|
|
||||||
(dragleave)="dropTarget() === group.stack.id && dropTarget.set(null)"
|
|
||||||
(drop)="onDrop($event, group.stack.id)"
|
|
||||||
class="rounded-md"
|
|
||||||
[class.bg-primary/10]="dropTarget() === group.stack.id"
|
|
||||||
[class.ring-1]="dropTarget() === group.stack.id"
|
|
||||||
[class.ring-inset]="dropTarget() === group.stack.id"
|
|
||||||
[class.ring-primary]="dropTarget() === group.stack.id"
|
|
||||||
>
|
|
||||||
<div class="group flex items-center gap-1 px-2 pt-2 pb-0.5">
|
|
||||||
<button
|
|
||||||
(click)="toggleStack(group.stack)"
|
|
||||||
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground hover:text-foreground"
|
|
||||||
[title]="group.stack.collapsed ? i18n.t('connMgr.expand') : i18n.t('connMgr.collapse')"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
[name]="group.stack.collapsed ? 'mynaChevronRight' : 'mynaChevronDown'"
|
|
||||||
size="0.8rem"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<span class="truncate text-[0.6875rem] font-medium text-muted-foreground uppercase">
|
|
||||||
{{ group.stack.name }}
|
|
||||||
</span>
|
|
||||||
<div class="min-w-0 flex-1"></div>
|
|
||||||
<!-- count and actions swap on hover: they never share the space,
|
|
||||||
so a long stack name can't collide with the buttons -->
|
|
||||||
<span
|
|
||||||
class="shrink-0 text-[0.625rem] text-muted-foreground/70 tabular-nums group-hover:hidden"
|
|
||||||
>
|
|
||||||
{{ group.connections.length }}
|
|
||||||
</span>
|
|
||||||
<div class="hidden shrink-0 items-center gap-0.5 group-hover:flex">
|
|
||||||
<button
|
|
||||||
(click)="renameStack(group.stack)"
|
|
||||||
[title]="i18n.t('connMgr.renameStack')"
|
|
||||||
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaEdit" size="0.75rem" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="deleteStack(group.stack)"
|
|
||||||
[title]="i18n.t('connMgr.deleteStack')"
|
|
||||||
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-destructive"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaTrash" size="0.75rem" />
|
|
||||||
</button>
|
|
||||||
@if (group.connections.length) {
|
|
||||||
<button
|
|
||||||
(click)="openStack(group.stack.id)"
|
|
||||||
[title]="i18n.t('connMgr.openStack')"
|
|
||||||
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-primary"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaArrowRight" size="0.8rem" />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@if (!group.stack.collapsed) {
|
|
||||||
@for (connection of group.connections; track connection.id) {
|
|
||||||
<button
|
|
||||||
(click)="edit(connection)"
|
|
||||||
draggable="true"
|
|
||||||
(dragstart)="onDragStart($event, connection)"
|
|
||||||
(dragend)="onDragEnd()"
|
|
||||||
class="flex w-full items-center gap-1.5 py-1.5 pr-3 pl-6 text-left text-xs hover:bg-accent"
|
|
||||||
[class.bg-accent]="draft()?.id === connection.id"
|
|
||||||
[class.opacity-50]="draggedId() === connection.id"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
|
||||||
<span class="truncate">{{ connection.name }}</span>
|
|
||||||
</button>
|
|
||||||
} @empty {
|
|
||||||
<div class="py-1 pl-6 text-[0.6875rem] text-muted-foreground italic">{{ i18n.t('connMgr.empty') }}</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- loose connections -->
|
|
||||||
<div
|
|
||||||
(dragover)="onDragOver($event, 'loose')"
|
|
||||||
(dragleave)="dropTarget() === 'loose' && dropTarget.set(null)"
|
|
||||||
(drop)="onDrop($event, null)"
|
|
||||||
class="min-h-8 rounded-md pb-1"
|
|
||||||
[class.bg-primary/10]="dropTarget() === 'loose'"
|
|
||||||
[class.ring-1]="dropTarget() === 'loose'"
|
|
||||||
[class.ring-inset]="dropTarget() === 'loose'"
|
|
||||||
[class.ring-primary]="dropTarget() === 'loose'"
|
|
||||||
>
|
|
||||||
@if (store.grouped().stacks.length && store.grouped().loose.length) {
|
|
||||||
<div class="px-2 pt-2 pb-0.5 text-[0.6875rem] font-medium text-muted-foreground uppercase">
|
|
||||||
{{ i18n.t('connMgr.connectionsHeader') }}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@for (connection of store.grouped().loose; track connection.id) {
|
|
||||||
<button
|
|
||||||
(click)="edit(connection)"
|
|
||||||
draggable="true"
|
|
||||||
(dragstart)="onDragStart($event, connection)"
|
|
||||||
(dragend)="onDragEnd()"
|
|
||||||
class="flex w-full items-center gap-1.5 px-3 py-1.5 text-left text-xs hover:bg-accent"
|
|
||||||
[class.bg-accent]="draft()?.id === connection.id"
|
|
||||||
[class.opacity-50]="draggedId() === connection.id"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
|
||||||
<span class="truncate">{{ connection.name }}</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (!store.connections().length && !store.stacks().length) {
|
|
||||||
<p class="px-3 py-2 text-[0.6875rem] text-muted-foreground">
|
|
||||||
{{ i18n.t('connMgr.noneTitle') }}
|
|
||||||
</p>
|
|
||||||
<p class="px-3 py-2 text-[0.6875rem] text-muted-foreground">
|
|
||||||
{{ i18n.t('connMgr.noneBody') }}
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- edit form -->
|
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
|
||||||
@if (draft(); as d) {
|
|
||||||
<div class="has-scrollbar grid flex-1 auto-rows-min grid-cols-2 gap-x-4 gap-y-3 overflow-y-auto p-5">
|
|
||||||
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.name') }}</span>
|
|
||||||
<input
|
|
||||||
[value]="d.name"
|
|
||||||
(input)="patch('name', $any($event.target).value)"
|
|
||||||
[placeholder]="i18n.t('connMgr.namePlaceholder')"
|
|
||||||
spellcheck="false"
|
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.host') }}</span>
|
|
||||||
<input
|
|
||||||
[value]="d.host"
|
|
||||||
(input)="patch('host', $any($event.target).value)"
|
|
||||||
placeholder="192.168.1.10"
|
|
||||||
spellcheck="false"
|
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
|
||||||
<label class="flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.port') }}</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
[value]="d.port"
|
|
||||||
(input)="patch('port', +$any($event.target).value)"
|
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.stack') }}</span>
|
|
||||||
<select
|
|
||||||
(change)="patch('stackId', $any($event.target).value || null)"
|
|
||||||
class="h-7 rounded-md border border-input bg-background px-1 outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
>
|
|
||||||
<!-- [selected] per option: binding [value] on the <select> runs
|
|
||||||
before the options render and silently shows "(none)" -->
|
|
||||||
<option value="" [selected]="!d.stackId">{{ i18n.t('connMgr.stackNone') }}</option>
|
|
||||||
@for (stack of store.stacks(); track stack.id) {
|
|
||||||
<option [value]="stack.id" [selected]="d.stackId === stack.id">
|
|
||||||
{{ stack.name }}
|
|
||||||
</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.username') }}</span>
|
|
||||||
<input
|
|
||||||
[value]="d.username"
|
|
||||||
(input)="patch('username', $any($event.target).value)"
|
|
||||||
spellcheck="false"
|
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.remotePath') }}</span>
|
|
||||||
<input
|
|
||||||
[value]="d.initialPath"
|
|
||||||
(input)="patch('initialPath', $any($event.target).value)"
|
|
||||||
[placeholder]="i18n.t('connMgr.remotePathPlaceholder')"
|
|
||||||
spellcheck="false"
|
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="col-span-2 flex items-center gap-4 text-xs">
|
|
||||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.auth') }}</span>
|
|
||||||
<label class="flex items-center gap-1.5">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="authKind"
|
|
||||||
[checked]="d.authKind === 'password'"
|
|
||||||
(change)="patch('authKind', 'password')"
|
|
||||||
class="accent-primary"
|
|
||||||
/>
|
|
||||||
{{ i18n.t('connMgr.authPassword') }}
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-1.5">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="authKind"
|
|
||||||
[checked]="d.authKind === 'key'"
|
|
||||||
(change)="patch('authKind', 'key')"
|
|
||||||
class="accent-primary"
|
|
||||||
/>
|
|
||||||
{{ i18n.t('connMgr.authKey') }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (d.authKind === 'password') {
|
|
||||||
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.password') }}</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
[value]="d.password"
|
|
||||||
(input)="patch('password', $any($event.target).value)"
|
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
} @else {
|
|
||||||
<label class="flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.privateKeyFile') }}</span>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
[value]="d.privateKeyPath"
|
|
||||||
(input)="patch('privateKeyPath', $any($event.target).value)"
|
|
||||||
placeholder="~/.ssh/id_ed25519"
|
|
||||||
spellcheck="false"
|
|
||||||
class="h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="pickKeyFile()"
|
|
||||||
class="h-7 shrink-0 rounded-md border border-border px-2 text-[0.6875rem] hover:bg-accent"
|
|
||||||
>
|
|
||||||
{{ i18n.t('common.choose') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label class="flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.passphrase') }}</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
[value]="d.passphrase"
|
|
||||||
(input)="patch('passphrase', $any($event.target).value)"
|
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (moreOpen()) {
|
|
||||||
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
|
||||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.localDir') }}</span>
|
|
||||||
<input
|
|
||||||
[value]="d.localDir"
|
|
||||||
(input)="patch('localDir', $any($event.target).value)"
|
|
||||||
[placeholder]="i18n.t('connMgr.localDirPlaceholder')"
|
|
||||||
spellcheck="false"
|
|
||||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
<span class="text-[0.625rem] text-muted-foreground/80">
|
|
||||||
{{ i18n.t('connMgr.localDirHint') }}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2 border-t border-border bg-chrome px-4 py-2.5">
|
|
||||||
<button
|
|
||||||
(click)="deleteCurrent()"
|
|
||||||
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-destructive"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaTrash" size="0.85rem" /> {{ i18n.t('common.delete') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="moreOpen.set(!moreOpen())"
|
|
||||||
class="rounded-md border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
{{ moreOpen() ? i18n.t('connMgr.less') : i18n.t('connMgr.more') }}
|
|
||||||
</button>
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
<button
|
|
||||||
(click)="save()"
|
|
||||||
[disabled]="!valid()"
|
|
||||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{{ i18n.t('common.save') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="saveAndConnect()"
|
|
||||||
[disabled]="!valid() || busy()"
|
|
||||||
class="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
@if (busy()) {
|
|
||||||
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
|
|
||||||
}
|
|
||||||
{{ i18n.t('common.connect') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="grid flex-1 place-items-center">
|
|
||||||
<div class="text-center">
|
|
||||||
<ng-icon name="mynaServers" size="1.75rem" class="text-muted-foreground" />
|
|
||||||
<p class="mt-2 text-xs text-muted-foreground">
|
|
||||||
{{ i18n.t('connMgr.selectPrompt') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- in-window dialogs (safe here: they depend only on FocusService) -->
|
|
||||||
@if (dialogs.prompt(); as req) {
|
|
||||||
<app-prompt-dialog [request]="req" />
|
|
||||||
}
|
|
||||||
@if (dialogs.confirm(); as req) {
|
|
||||||
<app-confirm-dialog [request]="req" />
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- STOPGAP: the frameless window has no WM resize borders, so we draw our
|
|
||||||
own handles. Delete with the rest of the hack — checklist in
|
|
||||||
window-resize/window-resize.ts. -->
|
|
||||||
<app-window-resize />
|
|
||||||
|
|
||||||
<app-toasts />
|
|
||||||
</div>
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
import { Component, computed, inject, signal } from '@angular/core';
|
|
||||||
import { NgIcon } from '@ng-icons/core';
|
|
||||||
import { I18nService } from '@core/i18n.service';
|
|
||||||
import type { SavedConnection, Stack } from '@shared/types';
|
|
||||||
import { ConnectionsService } from '@core/connections.service';
|
|
||||||
import { ConfirmDialog } from '../dialogs/confirm-dialog';
|
|
||||||
import { PromptDialog } from '../dialogs/prompt-dialog';
|
|
||||||
import { DialogsService } from '@core/dialogs.service';
|
|
||||||
import { blockNativeContextMenu } from '@core/platform';
|
|
||||||
import { RpcService } from '@core/rpc.service';
|
|
||||||
import { SettingsService } from '@core/settings.service';
|
|
||||||
import { ShortcutsService } from '@core/shortcuts.service';
|
|
||||||
import { TitleBar } from '../title-bar/title-bar';
|
|
||||||
import { Toasts } from '../toasts/toasts';
|
|
||||||
import { WindowResize } from '../window-resize/window-resize';
|
|
||||||
|
|
||||||
interface Draft {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
stackId: string | null;
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
username: string;
|
|
||||||
authKind: 'password' | 'key';
|
|
||||||
password: string;
|
|
||||||
privateKeyPath: string;
|
|
||||||
passphrase: string;
|
|
||||||
initialPath: string;
|
|
||||||
localDir: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toDraft(c: SavedConnection): Draft {
|
|
||||||
return {
|
|
||||||
id: c.id,
|
|
||||||
name: c.name,
|
|
||||||
stackId: c.stackId,
|
|
||||||
host: c.host,
|
|
||||||
port: c.port,
|
|
||||||
username: c.username,
|
|
||||||
// agent auth only exists on ~/.ssh/config hosts, which never reach this form
|
|
||||||
authKind: c.auth.kind === 'password' ? 'password' : 'key',
|
|
||||||
password: c.auth.kind === 'password' ? c.auth.password : '',
|
|
||||||
privateKeyPath: c.auth.kind === 'key' ? c.auth.privateKeyPath : '',
|
|
||||||
passphrase: c.auth.kind === 'key' ? (c.auth.passphrase ?? '') : '',
|
|
||||||
initialPath: c.initialPath ?? '',
|
|
||||||
localDir: c.localDir ?? '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function fromDraft(d: Draft): SavedConnection {
|
|
||||||
return {
|
|
||||||
id: d.id,
|
|
||||||
name: d.name.trim() || `${d.username}@${d.host}`,
|
|
||||||
stackId: d.stackId,
|
|
||||||
host: d.host.trim(),
|
|
||||||
port: d.port || 22,
|
|
||||||
username: d.username.trim(),
|
|
||||||
auth:
|
|
||||||
d.authKind === 'password'
|
|
||||||
? { kind: 'password', password: d.password }
|
|
||||||
: {
|
|
||||||
kind: 'key',
|
|
||||||
privateKeyPath: d.privateKeyPath.trim(),
|
|
||||||
...(d.passphrase ? { passphrase: d.passphrase } : {}),
|
|
||||||
},
|
|
||||||
initialPath: d.initialPath.trim() || null,
|
|
||||||
localDir: d.localDir.trim() || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Root component of the connection-manager window (__APP_MODE === 'manager').
|
|
||||||
* Modal to the main window (enforced bun-side); connect/open-stack requests
|
|
||||||
* are executed by the MAIN window — this window never touches panels.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'app-root',
|
|
||||||
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts, TitleBar, WindowResize],
|
|
||||||
templateUrl: './connection-manager.html',
|
|
||||||
host: {
|
|
||||||
'(window:keydown.escape)': 'onEscape()',
|
|
||||||
'(window:keydown)': 'onWindowKeydown($event)',
|
|
||||||
'(window:contextmenu)': 'blockContextMenu($event)',
|
|
||||||
// Aero desaturates an unfocused window's chrome — see .window.active.
|
|
||||||
'(window:focus)': 'windowFocused.set(true)',
|
|
||||||
'(window:blur)': 'windowFocused.set(false)',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export class ConnectionManager {
|
|
||||||
readonly i18n = inject(I18nService);
|
|
||||||
|
|
||||||
/** Drives `.window.active` on this window's Aero frame. */
|
|
||||||
readonly windowFocused = signal(document.hasFocus());
|
|
||||||
readonly store = inject(ConnectionsService);
|
|
||||||
readonly dialogs = inject(DialogsService);
|
|
||||||
private readonly rpc = inject(RpcService);
|
|
||||||
// injected for its side effects: fetches settings and applies theme/accent/
|
|
||||||
// text-size data attributes to this window's document root
|
|
||||||
private readonly settings = inject(SettingsService);
|
|
||||||
private readonly shortcuts = inject(ShortcutsService);
|
|
||||||
|
|
||||||
readonly draft = signal<Draft | null>(null);
|
|
||||||
readonly busy = signal(false);
|
|
||||||
readonly importing = signal(false);
|
|
||||||
/** The More… section (local directory) in the edit form. */
|
|
||||||
readonly moreOpen = signal(false);
|
|
||||||
/** Connection id being dragged between stacks. */
|
|
||||||
readonly draggedId = signal<string | null>(null);
|
|
||||||
/** Highlighted drop target: a stack id, 'loose', or null. */
|
|
||||||
readonly dropTarget = signal<string | null>(null);
|
|
||||||
|
|
||||||
readonly valid = computed(() => {
|
|
||||||
const d = this.draft();
|
|
||||||
if (!d) return false;
|
|
||||||
if (!d.host.trim() || !d.username.trim()) return false;
|
|
||||||
if (d.authKind === 'key' && !d.privateKeyPath.trim()) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
patch<K extends keyof Draft>(key: K, value: Draft[K]): void {
|
|
||||||
this.draft.update((d) => (d ? { ...d, [key]: value } : d));
|
|
||||||
}
|
|
||||||
|
|
||||||
newConnection(): void {
|
|
||||||
this.moreOpen.set(false);
|
|
||||||
this.draft.set({
|
|
||||||
id: '',
|
|
||||||
name: '',
|
|
||||||
stackId: null,
|
|
||||||
host: '',
|
|
||||||
port: 22,
|
|
||||||
username: 'root',
|
|
||||||
authKind: 'password',
|
|
||||||
password: '',
|
|
||||||
privateKeyPath: '',
|
|
||||||
passphrase: '',
|
|
||||||
initialPath: '',
|
|
||||||
localDir: '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
edit(connection: SavedConnection): void {
|
|
||||||
this.draft.set(toDraft(connection));
|
|
||||||
this.moreOpen.set(!!connection.localDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
close(): void {
|
|
||||||
void this.rpc.request.managerClose({});
|
|
||||||
}
|
|
||||||
|
|
||||||
onEscape(): void {
|
|
||||||
// in-window dialogs (confirm/prompt) handle their own Escape first
|
|
||||||
if (!this.dialogs.anyOpen) this.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Any-window shortcuts (interface scale). Escape has its own binding above. */
|
|
||||||
onWindowKeydown(event: KeyboardEvent): void {
|
|
||||||
const match = this.shortcuts.resolve(event, 'manager');
|
|
||||||
if (!match || this.shortcuts.isTypingTarget(event)) return;
|
|
||||||
event.preventDefault();
|
|
||||||
if (match.id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
|
||||||
else if (match.id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1);
|
|
||||||
else if (match.id === 'scale-reset') this.settings.setUiScale(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
blockContextMenu(event: Event): void {
|
|
||||||
blockNativeContextMenu(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
async save(): Promise<string | null> {
|
|
||||||
const d = this.draft();
|
|
||||||
if (!d || !this.valid()) return null;
|
|
||||||
const id = await this.store.upsert(fromDraft(d));
|
|
||||||
if (id) this.patch('id', id);
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveAndConnect(): Promise<void> {
|
|
||||||
if (!this.draft()) return;
|
|
||||||
this.busy.set(true);
|
|
||||||
try {
|
|
||||||
const id = await this.save();
|
|
||||||
if (!id) return;
|
|
||||||
// the main window connects (and this window closes); a failed connect
|
|
||||||
// surfaces as an error there
|
|
||||||
await this.rpc.request.managerConnect({ id });
|
|
||||||
} finally {
|
|
||||||
this.busy.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteCurrent(): void {
|
|
||||||
const d = this.draft();
|
|
||||||
if (!d?.id) {
|
|
||||||
this.draft.set(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.dialogs.confirm.set({
|
|
||||||
title: this.i18n.t('connMgr.deleteConnectionTitle'),
|
|
||||||
body: this.i18n.t('connMgr.deleteConnectionBody', { name: d.name || d.host }),
|
|
||||||
confirmLabel: this.i18n.t('common.delete'),
|
|
||||||
destructive: true,
|
|
||||||
onConfirm: async () => {
|
|
||||||
await this.store.remove(d.id);
|
|
||||||
this.draft.set(null);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async pickKeyFile(): Promise<void> {
|
|
||||||
const res = await this.rpc.request.pickKeyFile({});
|
|
||||||
if (res.ok && res.path) this.patch('privateKeyPath', res.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
async importWinScp(): Promise<void> {
|
|
||||||
this.importing.set(true);
|
|
||||||
try {
|
|
||||||
await this.store.importWinScp();
|
|
||||||
} finally {
|
|
||||||
this.importing.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- stacks ----
|
|
||||||
|
|
||||||
openStack(stackId: string): void {
|
|
||||||
// opened as tabs in the main window; bun closes this window after
|
|
||||||
void this.rpc.request.managerOpenStack({ id: stackId });
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleStack(stack: Stack): void {
|
|
||||||
void this.store.upsertStack({ ...stack, collapsed: !stack.collapsed });
|
|
||||||
}
|
|
||||||
|
|
||||||
newStack(): void {
|
|
||||||
this.dialogs.prompt.set({
|
|
||||||
title: this.i18n.t('connMgr.newStackTitle'),
|
|
||||||
placeholder: this.i18n.t('connMgr.newStackPrompt'),
|
|
||||||
confirmLabel: this.i18n.t('fileActions.createConfirm'),
|
|
||||||
onConfirm: (name) => void this.store.upsertStack({ id: '', name }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
renameStack(stack: Stack): void {
|
|
||||||
this.dialogs.prompt.set({
|
|
||||||
title: this.i18n.t('connMgr.renameStackTitle'),
|
|
||||||
initial: stack.name,
|
|
||||||
confirmLabel: this.i18n.t('connMgr.renameStack'),
|
|
||||||
onConfirm: (name) => void this.store.upsertStack({ ...stack, name }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteStack(stack: Stack): void {
|
|
||||||
this.dialogs.confirm.set({
|
|
||||||
title: this.i18n.t('connMgr.deleteStackTitle'),
|
|
||||||
body: this.i18n.t('connMgr.deleteStackBody', { name: stack.name }),
|
|
||||||
confirmLabel: this.i18n.t('common.delete'),
|
|
||||||
destructive: true,
|
|
||||||
onConfirm: () => void this.store.removeStack(stack.id),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- drag & drop (move a connection between stacks / loose) ----
|
|
||||||
|
|
||||||
onDragStart(event: DragEvent, connection: SavedConnection): void {
|
|
||||||
this.draggedId.set(connection.id);
|
|
||||||
event.dataTransfer?.setData('text/plain', connection.id);
|
|
||||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
|
||||||
}
|
|
||||||
|
|
||||||
onDragOver(event: DragEvent, target: string): void {
|
|
||||||
if (!this.draggedId()) return;
|
|
||||||
event.preventDefault();
|
|
||||||
this.dropTarget.set(target);
|
|
||||||
}
|
|
||||||
|
|
||||||
onDrop(event: DragEvent, stackId: string | null): void {
|
|
||||||
event.preventDefault();
|
|
||||||
const id = this.draggedId();
|
|
||||||
this.draggedId.set(null);
|
|
||||||
this.dropTarget.set(null);
|
|
||||||
const connection = this.store.connections().find((c) => c.id === id);
|
|
||||||
if (!connection || connection.stackId === stackId) return;
|
|
||||||
void this.store.upsert({ ...connection, stackId });
|
|
||||||
}
|
|
||||||
|
|
||||||
onDragEnd(): void {
|
|
||||||
this.draggedId.set(null);
|
|
||||||
this.dropTarget.set(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
import { Component, ElementRef, computed, effect, inject, signal, viewChild } from '@angular/core';
|
|
||||||
import { ContextMenuService, type MenuItem } from '@core/context-menu.service';
|
|
||||||
import { FocusService } from '@core/focus.service';
|
|
||||||
import { remPx } from '@core/metrics';
|
|
||||||
|
|
||||||
/** Rough per-item metrics in REM, used to size the menu and decide whether it
|
|
||||||
* must flip. Rem because the interface scale is a root font-size lever: the
|
|
||||||
* old `MENU_W = 200` was pinned into the DOM via [style.width.px], so the box
|
|
||||||
* stayed 200px while the text inside grew with the scale and overflowed.
|
|
||||||
* Values mirror the skinfix restatement of 7.css's menu metrics in 7like.css
|
|
||||||
* (item padding 0.25rem x2 + the ~0.9375rem line). */
|
|
||||||
const ITEM_H_REM = 1.5; // 24px @16
|
|
||||||
const DIVIDER_H_REM = 0.4375; // 7px @16: 2px bevel + 3px/2px margins
|
|
||||||
const MENU_W_REM = 12.5; // 200px @16
|
|
||||||
const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
|
|
||||||
|
|
||||||
/** One rendered <li>: separators collapse onto the item BEFORE them. */
|
|
||||||
interface MenuRow {
|
|
||||||
item: MenuItem;
|
|
||||||
hasDivider: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The panel right-click menu. Replaces electrobun's native ContextMenu, which
|
|
||||||
* is a no-op on Linux — see ContextMenuService for the why. Emits the same
|
|
||||||
* `ctx-*` / `col:*` action strings the native menu did.
|
|
||||||
*
|
|
||||||
* The markup is 7.css's menu contract EXACTLY — `ul[role=menu] >
|
|
||||||
* li[role=menuitem] > button` for actions, `> input[type=checkbox] + label`
|
|
||||||
* for the checkable column toggles, and dividers as `.has-divider` on the
|
|
||||||
* PRECEDING item (7.css draws them as an ::after bevel, not as a node). Get
|
|
||||||
* any of that wrong and 7.css silently styles nothing: an earlier version
|
|
||||||
* rendered div > button, concluded "7.css has no menu component", and
|
|
||||||
* hand-built a lookalike. It does have one; the selectors just never matched.
|
|
||||||
*
|
|
||||||
* `w7-ctx` is OURS, not 7.css's: `ul[role=menu]` carries `position: relative`
|
|
||||||
* in 7.css (for its gutter stripe and submenus), and the skin layer outranks
|
|
||||||
* Tailwind's `.fixed` — so the menu laid out in flow, at the bottom of the
|
|
||||||
* h-screen frame, off the viewport. The class scopes a `position: fixed`
|
|
||||||
* restatement in 7like.css to THIS menu; see scripts/menu-probe.ts.
|
|
||||||
*
|
|
||||||
* Keyboard follows the platform: arrows move REAL focus between items (7.css
|
|
||||||
* styles :focus-visible identically to :hover), Enter/Space on a button
|
|
||||||
* activate it natively; only labels need a synthetic click.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'app-context-menu',
|
|
||||||
template: `
|
|
||||||
@if (menu.request(); as req) {
|
|
||||||
<!-- click-outside catcher; mousedown so it beats the panel's own handlers -->
|
|
||||||
<div class="fixed inset-0 z-50" (mousedown)="close()" (contextmenu)="onBackdropContext($event)"></div>
|
|
||||||
<ul
|
|
||||||
#menuEl
|
|
||||||
role="menu"
|
|
||||||
tabindex="-1"
|
|
||||||
(keydown)="onKeydown($event)"
|
|
||||||
[style.left.px]="position().x"
|
|
||||||
[style.top.px]="position().y"
|
|
||||||
[style.width.rem]="menuWidthRem"
|
|
||||||
class="w7-ctx fixed z-50 outline-none"
|
|
||||||
>
|
|
||||||
@for (row of rows(); track $index) {
|
|
||||||
<!-- aria-disabled on the LI, not the button: that is where 7.css
|
|
||||||
hangs its disabled state (opacity + pointer-events: none). -->
|
|
||||||
<li
|
|
||||||
role="menuitem"
|
|
||||||
[class.has-divider]="row.hasDivider"
|
|
||||||
[attr.aria-disabled]="row.item.disabled ? true : null"
|
|
||||||
[title]="row.item.tooltip ?? ''"
|
|
||||||
>
|
|
||||||
@if (row.item.checked !== undefined) {
|
|
||||||
<input type="checkbox" [id]="'ctx-check-' + $index" [checked]="row.item.checked" tabindex="-1" />
|
|
||||||
<label [for]="'ctx-check-' + $index" tabindex="-1" (click)="pick(row.item, $event)">
|
|
||||||
{{ row.item.label }}
|
|
||||||
</label>
|
|
||||||
} @else {
|
|
||||||
<button type="button" (click)="pick(row.item)">
|
|
||||||
{{ row.item.label }}
|
|
||||||
@if (row.item.accelerator) {
|
|
||||||
<span>{{ row.item.accelerator }}</span>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
// Closing on scroll/blur matches native menu behavior: the anchor row moves
|
|
||||||
// out from under a menu that would otherwise float over stale content.
|
|
||||||
host: {
|
|
||||||
'(window:blur)': 'close()',
|
|
||||||
'(window:resize)': 'close()',
|
|
||||||
'(document:scroll)': 'close()',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export class ContextMenu {
|
|
||||||
readonly menu = inject(ContextMenuService);
|
|
||||||
private readonly focus = inject(FocusService);
|
|
||||||
private readonly menuEl = viewChild<ElementRef<HTMLUListElement>>('menuEl');
|
|
||||||
|
|
||||||
/** Rendered width AND math input are the same rem value, so the flip point
|
|
||||||
* and the box can never disagree — the shadlike menu's old bug. */
|
|
||||||
protected readonly menuWidthRem = MENU_W_REM;
|
|
||||||
|
|
||||||
/** Model items → rendered rows: a separator becomes has-divider on its predecessor. */
|
|
||||||
readonly rows = computed<MenuRow[]>(() => {
|
|
||||||
const items = this.menu.request()?.items ?? [];
|
|
||||||
const rows: MenuRow[] = [];
|
|
||||||
for (const item of items) {
|
|
||||||
if (item.type === 'separator') {
|
|
||||||
if (rows.length) rows[rows.length - 1].hasDivider = true;
|
|
||||||
} else {
|
|
||||||
rows.push({ item, hasDivider: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Click point, flipped/clamped so the menu never overflows the viewport.
|
|
||||||
* UI metrics convert through the live root font-size (remPx); the click
|
|
||||||
* point and window.inner* are both viewport CSS px already. */
|
|
||||||
readonly position = computed(() => {
|
|
||||||
const req = this.menu.request();
|
|
||||||
if (!req) return { x: 0, y: 0 };
|
|
||||||
const rows = this.rows();
|
|
||||||
const menuW = remPx(MENU_W_REM);
|
|
||||||
const height =
|
|
||||||
rows.length * remPx(ITEM_H_REM) +
|
|
||||||
rows.filter((r) => r.hasDivider).length * remPx(DIVIDER_H_REM) +
|
|
||||||
remPx(0.25); // ul padding 0.125rem x2
|
|
||||||
const x =
|
|
||||||
req.x + menuW > window.innerWidth - EDGE_GAP ? Math.max(EDGE_GAP, req.x - menuW) : req.x;
|
|
||||||
const y =
|
|
||||||
req.y + height > window.innerHeight - EDGE_GAP ? Math.max(EDGE_GAP, req.y - height) : req.y;
|
|
||||||
return { x, y };
|
|
||||||
});
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// Focus the menu once it exists so arrow keys / Escape reach it. Keyed on
|
|
||||||
// the viewChild signal, NOT on request(): under zoneless CD an effect can
|
|
||||||
// run before the @if has rendered the element.
|
|
||||||
effect(() => this.menuEl()?.nativeElement.focus());
|
|
||||||
}
|
|
||||||
|
|
||||||
close(): void {
|
|
||||||
if (!this.menu.request()) return;
|
|
||||||
this.menu.close();
|
|
||||||
this.focus.bump();
|
|
||||||
}
|
|
||||||
|
|
||||||
pick(item: MenuItem, event?: Event): void {
|
|
||||||
// labels: don't let the click also toggle the real checkbox — the fresh
|
|
||||||
// `checked` state comes back through settings when the menu reopens
|
|
||||||
event?.preventDefault();
|
|
||||||
// 7.css already blocks the pointer on [aria-disabled]; this covers the
|
|
||||||
// keyboard path, which routes through focus and never consults CSS
|
|
||||||
if (item.disabled) return;
|
|
||||||
if (!item.action) return;
|
|
||||||
const action = item.action;
|
|
||||||
this.close();
|
|
||||||
this.menu.onPick(action);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Right-clicking the backdrop closes rather than stacking a second menu. */
|
|
||||||
onBackdropContext(event: MouseEvent): void {
|
|
||||||
event.preventDefault();
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
onKeydown(event: KeyboardEvent): void {
|
|
||||||
switch (event.key) {
|
|
||||||
case 'Escape':
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
this.close();
|
|
||||||
break;
|
|
||||||
case 'ArrowDown':
|
|
||||||
event.preventDefault();
|
|
||||||
this.step(1);
|
|
||||||
break;
|
|
||||||
case 'ArrowUp':
|
|
||||||
event.preventDefault();
|
|
||||||
this.step(-1);
|
|
||||||
break;
|
|
||||||
case 'Enter':
|
|
||||||
case ' ': {
|
|
||||||
// buttons activate natively from their own focus; labels don't, so
|
|
||||||
// synthesize the click (which routes through pick via the template)
|
|
||||||
const active = document.activeElement;
|
|
||||||
if (active instanceof HTMLLabelElement && this.menuEl()?.nativeElement.contains(active)) {
|
|
||||||
event.preventDefault();
|
|
||||||
active.click();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Move REAL focus by one item, wrapping. 7.css styles :focus-visible the
|
|
||||||
* same as :hover, and keyboard-initiated programmatic focus counts as
|
|
||||||
* focus-visible, so the highlight comes from the framework for free.
|
|
||||||
*/
|
|
||||||
private step(delta: number): void {
|
|
||||||
const root = this.menuEl()?.nativeElement;
|
|
||||||
if (!root) return;
|
|
||||||
// disabled items are skipped, not merely unclickable: pointer-events:none
|
|
||||||
// does not make an element unfocusable, so arrows would still land on them
|
|
||||||
const targets = Array.from(
|
|
||||||
root.querySelectorAll<HTMLElement>(
|
|
||||||
'li:not([aria-disabled]) > button, li:not([aria-disabled]) > label',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (!targets.length) return;
|
|
||||||
const current = targets.indexOf(document.activeElement as HTMLElement);
|
|
||||||
const next = current < 0 && delta < 0 ? targets.length - 1 : (current + delta + targets.length) % targets.length;
|
|
||||||
targets[next].focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import { Component, computed, inject, input, signal } from '@angular/core';
|
|
||||||
import { I18nService } from '@core/i18n.service';
|
|
||||||
import { DialogsService, type ChmodRequest } from '@core/dialogs.service';
|
|
||||||
import { FocusService } from '@core/focus.service';
|
|
||||||
|
|
||||||
const ROLES = ['roleOwner', 'roleGroup', 'roleOthers'] as const;
|
|
||||||
const PERMS = ['r', 'w', 'x'] as const;
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-chmod-dialog',
|
|
||||||
template: `
|
|
||||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
|
|
||||||
<div
|
|
||||||
class="w-80 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
|
||||||
(mousedown)="$event.stopPropagation()"
|
|
||||||
(keydown.escape)="cancel()"
|
|
||||||
>
|
|
||||||
<h2 class="text-sm font-semibold">{{ i18n.t('dialogs.chmodTitle') }}</h2>
|
|
||||||
<p class="mt-1 truncate text-xs text-muted-foreground">{{ targetLabel() }}</p>
|
|
||||||
|
|
||||||
<table class="mt-3 w-full text-xs">
|
|
||||||
<thead>
|
|
||||||
<tr class="text-muted-foreground">
|
|
||||||
<th class="pb-1 text-left font-normal"></th>
|
|
||||||
<th class="pb-1 font-normal">{{ i18n.t('dialogs.read') }}</th>
|
|
||||||
<th class="pb-1 font-normal">{{ i18n.t('dialogs.write') }}</th>
|
|
||||||
<th class="pb-1 font-normal">{{ i18n.t('dialogs.execute') }}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (role of roles; track role; let r = $index) {
|
|
||||||
<tr>
|
|
||||||
<td class="py-1 pr-2 text-muted-foreground">{{ i18n.t('dialogs.' + role) }}</td>
|
|
||||||
@for (perm of perms; track perm; let p = $index) {
|
|
||||||
<td class="py-1 text-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
[checked]="hasBit(r, p)"
|
|
||||||
(change)="toggleBit(r, p)"
|
|
||||||
class="size-3.5 accent-primary"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
}
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div class="mt-3 flex items-center gap-2">
|
|
||||||
<label class="text-xs text-muted-foreground">{{ i18n.t('dialogs.octal') }}</label>
|
|
||||||
<input
|
|
||||||
[value]="octal()"
|
|
||||||
(input)="setOctal($any($event.target).value)"
|
|
||||||
maxlength="4"
|
|
||||||
spellcheck="false"
|
|
||||||
class="h-7 w-20 rounded-md border border-input bg-transparent px-2 font-mono text-xs outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
(click)="cancel()"
|
|
||||||
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
|
||||||
>
|
|
||||||
{{ i18n.t('common.cancel') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="submit()"
|
|
||||||
class="w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
{{ i18n.t('common.apply') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class ChmodDialog {
|
|
||||||
readonly i18n = inject(I18nService);
|
|
||||||
readonly request = input.required<ChmodRequest>();
|
|
||||||
|
|
||||||
readonly roles = ROLES;
|
|
||||||
readonly perms = PERMS;
|
|
||||||
|
|
||||||
private readonly dialogs = inject(DialogsService);
|
|
||||||
private readonly focus = inject(FocusService);
|
|
||||||
|
|
||||||
// mode is initialized lazily from the request on first read
|
|
||||||
private readonly modeOverride = signal<number | null>(null);
|
|
||||||
readonly mode = computed(() => this.modeOverride() ?? this.request().initialMode & 0o777);
|
|
||||||
readonly octal = computed(() => this.mode().toString(8).padStart(3, '0'));
|
|
||||||
|
|
||||||
targetLabel(): string {
|
|
||||||
const targets = this.request().targets;
|
|
||||||
return targets.length === 1
|
|
||||||
? targets[0]
|
|
||||||
: this.i18n.t('dialogs.chmodItems', { count: targets.length });
|
|
||||||
}
|
|
||||||
|
|
||||||
hasBit(role: number, perm: number): boolean {
|
|
||||||
return !!(this.mode() & (0o400 >> (role * 3 + perm)));
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleBit(role: number, perm: number): void {
|
|
||||||
this.modeOverride.set(this.mode() ^ (0o400 >> (role * 3 + perm)));
|
|
||||||
}
|
|
||||||
|
|
||||||
setOctal(text: string): void {
|
|
||||||
if (/^[0-7]{1,4}$/.test(text)) {
|
|
||||||
this.modeOverride.set(parseInt(text, 8) & 0o777);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
submit(): void {
|
|
||||||
const req = this.request();
|
|
||||||
const mode = this.mode();
|
|
||||||
this.close();
|
|
||||||
req.onConfirm(mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel(): void {
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
private close(): void {
|
|
||||||
this.dialogs.chmod.set(null);
|
|
||||||
this.focus.bump();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core';
|
|
||||||
import { I18nService } from '@core/i18n.service';
|
|
||||||
import { DialogsService, type ConfirmRequest } from '@core/dialogs.service';
|
|
||||||
import { FocusService } from '@core/focus.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-confirm-dialog',
|
|
||||||
template: `
|
|
||||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
|
|
||||||
<div
|
|
||||||
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
|
||||||
(mousedown)="$event.stopPropagation()"
|
|
||||||
(keydown.escape)="cancel()"
|
|
||||||
>
|
|
||||||
<h2 class="text-sm font-semibold">{{ request().title }}</h2>
|
|
||||||
<p class="mt-2 text-xs text-muted-foreground">{{ request().body }}</p>
|
|
||||||
<div class="mt-4 flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
(click)="cancel()"
|
|
||||||
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
|
||||||
>
|
|
||||||
{{ i18n.t('common.cancel') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
#confirmBtn
|
|
||||||
(click)="submit()"
|
|
||||||
[class]="
|
|
||||||
request().destructive
|
|
||||||
? 'w7-btn rounded-md bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground hover:bg-destructive/90'
|
|
||||||
: 'w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ request().confirmLabel }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class ConfirmDialog {
|
|
||||||
readonly i18n = inject(I18nService);
|
|
||||||
readonly request = input.required<ConfirmRequest>();
|
|
||||||
|
|
||||||
private readonly confirmBtn = viewChild.required<ElementRef<HTMLButtonElement>>('confirmBtn');
|
|
||||||
private readonly dialogs = inject(DialogsService);
|
|
||||||
private readonly focus = inject(FocusService);
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
afterNextRender(() => this.confirmBtn().nativeElement.focus());
|
|
||||||
}
|
|
||||||
|
|
||||||
submit(): void {
|
|
||||||
const req = this.request();
|
|
||||||
this.close();
|
|
||||||
req.onConfirm();
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel(): void {
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
private close(): void {
|
|
||||||
this.dialogs.confirm.set(null);
|
|
||||||
this.focus.bump();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import {
|
|
||||||
Component,
|
|
||||||
ElementRef,
|
|
||||||
afterNextRender,
|
|
||||||
computed,
|
|
||||||
inject,
|
|
||||||
input,
|
|
||||||
viewChild,
|
|
||||||
} from '@angular/core';
|
|
||||||
import { NgIcon } from '@ng-icons/core';
|
|
||||||
import { I18nService } from '@core/i18n.service';
|
|
||||||
import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '@core/dialogs.service';
|
|
||||||
import { FocusService } from '@core/focus.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-overwrite-dialog',
|
|
||||||
imports: [NgIcon],
|
|
||||||
template: `
|
|
||||||
<div
|
|
||||||
class="fixed inset-0 z-40 grid place-items-center bg-black/40"
|
|
||||||
(keydown.escape)="pick('cancel')"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-[26rem] rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-2">
|
|
||||||
<h2 class="min-w-0 text-sm font-semibold">
|
|
||||||
{{ isDir() ? i18n.t('dialogs.mergeFolder') : i18n.t('dialogs.replace') }}
|
|
||||||
<span class="font-mono">"{{ request().srcEntry.name }}"</span>?
|
|
||||||
</h2>
|
|
||||||
@if (request().total > 1) {
|
|
||||||
<span
|
|
||||||
class="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[0.625rem] font-medium text-muted-foreground"
|
|
||||||
>
|
|
||||||
{{ i18n.t('dialogs.ofTotal', { index: request().index, total: request().total }) }}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
|
||||||
@if (isDir()) {
|
|
||||||
{{ i18n.t('dialogs.overwriteDirBody') }}
|
|
||||||
} @else {
|
|
||||||
{{ i18n.t('dialogs.overwriteFileBody') }}
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- comparison -->
|
|
||||||
<div class="mt-3 grid grid-cols-2 gap-2">
|
|
||||||
@for (side of comparison(); track side.label) {
|
|
||||||
<div class="rounded-md border border-border p-2.5">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
|
|
||||||
{{ i18n.t('dialogs.' + side.label) }}
|
|
||||||
</span>
|
|
||||||
@if (side.newer) {
|
|
||||||
<span class="rounded-full bg-success/15 px-1.5 text-[0.625rem] font-medium text-success">
|
|
||||||
{{ i18n.t('dialogs.newer') }}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="mt-1.5 flex items-center gap-1.5 text-xs">
|
|
||||||
<ng-icon
|
|
||||||
[name]="isDir() ? 'mynaFolder' : 'mynaFile'"
|
|
||||||
size="0.9rem"
|
|
||||||
class="shrink-0 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<span class="font-mono tabular-nums">
|
|
||||||
{{ isDir() ? '—' : i18n.size(side.entry.size) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 font-mono text-[0.6875rem] tabular-nums text-muted-foreground">
|
|
||||||
{{ i18n.date(side.entry.mtime) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- actions -->
|
|
||||||
<div class="mt-4 flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
(click)="pick('cancel')"
|
|
||||||
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
{{ i18n.t('common.cancel') }}
|
|
||||||
</button>
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
@if (request().total > 1) {
|
|
||||||
<button
|
|
||||||
(click)="pick('skipAll')"
|
|
||||||
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
{{ i18n.t('dialogs.skipAll') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="pick('overwriteAll')"
|
|
||||||
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
{{ isDir() ? i18n.t('dialogs.mergeAll') : i18n.t('dialogs.replaceAll') }}
|
|
||||||
</button>
|
|
||||||
<div class="h-4 w-px bg-border"></div>
|
|
||||||
}
|
|
||||||
<button
|
|
||||||
(click)="pick('skip')"
|
|
||||||
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
|
||||||
>
|
|
||||||
{{ i18n.t('dialogs.skip') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
#primaryBtn
|
|
||||||
(click)="pick('overwrite')"
|
|
||||||
class="w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
{{ isDir() ? i18n.t('dialogs.merge') : i18n.t('dialogs.replace') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class OverwriteDialog {
|
|
||||||
readonly i18n = inject(I18nService);
|
|
||||||
readonly request = input.required<OverwriteRequest>();
|
|
||||||
|
|
||||||
|
|
||||||
private readonly dialogs = inject(DialogsService);
|
|
||||||
private readonly focus = inject(FocusService);
|
|
||||||
private readonly primaryBtn = viewChild.required<ElementRef<HTMLButtonElement>>('primaryBtn');
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
afterNextRender(() => this.primaryBtn().nativeElement.focus());
|
|
||||||
}
|
|
||||||
|
|
||||||
readonly isDir = computed(() => this.request().srcEntry.type === 'dir');
|
|
||||||
|
|
||||||
readonly comparison = computed(() => {
|
|
||||||
const { srcEntry, dstEntry } = this.request();
|
|
||||||
return [
|
|
||||||
{ label: 'source', entry: srcEntry, newer: srcEntry.mtime > dstEntry.mtime },
|
|
||||||
{ label: 'destination', entry: dstEntry, newer: dstEntry.mtime > srcEntry.mtime },
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
pick(choice: OverwriteChoice): void {
|
|
||||||
const req = this.request();
|
|
||||||
this.dialogs.overwrite.set(null);
|
|
||||||
this.focus.bump();
|
|
||||||
req.resolve(choice);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import {
|
|
||||||
Component,
|
|
||||||
ElementRef,
|
|
||||||
afterNextRender,
|
|
||||||
inject,
|
|
||||||
input,
|
|
||||||
signal,
|
|
||||||
viewChild,
|
|
||||||
} from '@angular/core';
|
|
||||||
import { I18nService } from '@core/i18n.service';
|
|
||||||
import { DialogsService, type PromptRequest } from '@core/dialogs.service';
|
|
||||||
import { FocusService } from '@core/focus.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-prompt-dialog',
|
|
||||||
template: `
|
|
||||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
|
|
||||||
<div
|
|
||||||
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
|
||||||
(mousedown)="$event.stopPropagation()"
|
|
||||||
>
|
|
||||||
<h2 class="text-sm font-semibold">{{ request().title }}</h2>
|
|
||||||
<input
|
|
||||||
#box
|
|
||||||
[value]="value()"
|
|
||||||
(input)="value.set($any($event.target).value)"
|
|
||||||
(keydown.enter)="submit()"
|
|
||||||
(keydown.escape)="cancel()"
|
|
||||||
[placeholder]="request().placeholder ?? ''"
|
|
||||||
spellcheck="false"
|
|
||||||
class="mt-3 h-8 w-full rounded-md border border-input bg-transparent px-2 text-sm outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
<div class="mt-4 flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
(click)="cancel()"
|
|
||||||
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
|
||||||
>
|
|
||||||
{{ i18n.t('common.cancel') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="submit()"
|
|
||||||
[disabled]="!value().trim()"
|
|
||||||
class="w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{{ request().confirmLabel }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class PromptDialog {
|
|
||||||
readonly i18n = inject(I18nService);
|
|
||||||
readonly request = input.required<PromptRequest>();
|
|
||||||
readonly value = signal('');
|
|
||||||
|
|
||||||
private readonly box = viewChild.required<ElementRef<HTMLInputElement>>('box');
|
|
||||||
private readonly dialogs = inject(DialogsService);
|
|
||||||
private readonly focus = inject(FocusService);
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
afterNextRender(() => {
|
|
||||||
this.value.set(this.request().initial ?? '');
|
|
||||||
const el = this.box().nativeElement;
|
|
||||||
el.focus();
|
|
||||||
el.select();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
submit(): void {
|
|
||||||
const value = this.value().trim();
|
|
||||||
if (!value) return;
|
|
||||||
const req = this.request();
|
|
||||||
this.close();
|
|
||||||
req.onConfirm(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel(): void {
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
private close(): void {
|
|
||||||
this.dialogs.prompt.set(null);
|
|
||||||
this.focus.bump();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
<div class="window glass w7-frame flex h-screen flex-col" [class.active]="windowFocused()">
|
|
||||||
@if (!ctx) {
|
|
||||||
<div class="flex flex-1 items-center justify-center">
|
|
||||||
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm">
|
|
||||||
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
|
|
||||||
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.noFileTitle') }}</p>
|
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
|
||||||
{{ i18n.t('editor.noFileBody') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<!-- The window is frameless. The X runs the SAME dirty prompt as ⌘W and
|
|
||||||
the Close button — never a raw window close. -->
|
|
||||||
<app-title-bar [title]="ctx.name + (dirty() ? ' *' : '')" (closeRequested)="requestClose()" />
|
|
||||||
<!-- Toolbar strip under the title bar. h-9/pl-3 are load-bearing: without a
|
|
||||||
height this row collapses onto the icon and the screen reads as broken. -->
|
|
||||||
<header
|
|
||||||
class="w7-toolbar flex h-9 shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2 pl-3"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaFileText" size="0.95rem" class="shrink-0 text-muted-foreground" />
|
|
||||||
<h1 class="min-w-0 truncate font-mono text-sm font-semibold">
|
|
||||||
{{ ctx.name }}{{ dirty() ? ' •' : '' }}
|
|
||||||
</h1>
|
|
||||||
<span class="shrink-0 text-[0.6875rem] text-muted-foreground">{{ ctx.sessionTitle }}</span>
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
<button
|
|
||||||
(click)="toggleWrap()"
|
|
||||||
[title]="i18n.t('editor.wrapHint', { alt: altLabel })"
|
|
||||||
class="rounded-md px-2 py-1 text-[0.6875rem] electrobun-webkit-app-region-no-drag hover:bg-accent"
|
|
||||||
[class.bg-accent]="wrap()"
|
|
||||||
[class.text-foreground]="wrap()"
|
|
||||||
[class.text-muted-foreground]="!wrap()"
|
|
||||||
>
|
|
||||||
{{ i18n.t('editor.wrap') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="openFind()"
|
|
||||||
[title]="i18n.t('editor.findHint', { mod: modLabel })"
|
|
||||||
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaSearch" size="0.85rem" />
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
@if (findOpen()) {
|
|
||||||
<div class="flex shrink-0 items-center gap-2 border-b border-border bg-chrome px-3 py-1.5">
|
|
||||||
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
|
||||||
<input
|
|
||||||
#findBox
|
|
||||||
[value]="findQuery()"
|
|
||||||
(input)="onFindInput($any($event.target).value)"
|
|
||||||
(keydown)="onFindKeydown($event)"
|
|
||||||
[placeholder]="i18n.t('editor.findPlaceholder')"
|
|
||||||
spellcheck="false"
|
|
||||||
class="h-6 w-56 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="text-[0.6875rem] tabular-nums"
|
|
||||||
[class.text-destructive]="findQuery() && !matches().length"
|
|
||||||
[class.text-muted-foreground]="!findQuery() || matches().length"
|
|
||||||
>
|
|
||||||
{{ countLabel() }}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
(click)="findStep(-1)"
|
|
||||||
[title]="i18n.t('editor.prevMatch')"
|
|
||||||
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaChevronUp" size="0.8rem" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="findStep(1)"
|
|
||||||
[title]="i18n.t('editor.nextMatch')"
|
|
||||||
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaChevronDown" size="0.8rem" />
|
|
||||||
</button>
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
<button
|
|
||||||
(click)="closeFind()"
|
|
||||||
[title]="i18n.t('editor.closeHint')"
|
|
||||||
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaX" size="0.8rem" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (sessionDead()) {
|
|
||||||
<div
|
|
||||||
class="flex shrink-0 items-center gap-2 border-b border-border bg-destructive/10 px-3 py-1.5 text-[0.6875rem] text-destructive"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaXCircle" size="0.85rem" class="shrink-0" />
|
|
||||||
{{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (loading()) {
|
|
||||||
<div class="flex flex-1 items-center justify-center">
|
|
||||||
<ng-icon name="mynaSpinner" size="1.25rem" class="animate-spin text-primary" />
|
|
||||||
</div>
|
|
||||||
} @else if (loadError(); as error) {
|
|
||||||
<div class="flex flex-1 items-center justify-center">
|
|
||||||
<div class="max-w-md rounded-lg border border-border bg-card p-6 text-center shadow-sm">
|
|
||||||
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
|
|
||||||
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.cantOpen', { name: ctx.name }) }}</p>
|
|
||||||
<p class="mt-1 text-xs text-muted-foreground">{{ error }}</p>
|
|
||||||
<button
|
|
||||||
(click)="discardAndClose()"
|
|
||||||
class="mt-4 rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
|
||||||
>
|
|
||||||
{{ i18n.t('editor.closeWindow') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="flex min-h-0 flex-1">
|
|
||||||
<!-- gutter and textarea share font/leading/vertical padding — that's
|
|
||||||
the whole alignment contract; keep the classes in lockstep -->
|
|
||||||
@if (!wrap()) {
|
|
||||||
<div
|
|
||||||
#gutter
|
|
||||||
aria-hidden="true"
|
|
||||||
[style.width.ch]="gutterDigits() + 3"
|
|
||||||
class="shrink-0 overflow-hidden border-r border-border py-4 pr-2 text-right font-mono text-xs leading-relaxed whitespace-pre text-muted-foreground select-none"
|
|
||||||
>{{ gutterText() }}</div>
|
|
||||||
}
|
|
||||||
<textarea
|
|
||||||
#box
|
|
||||||
[value]="text()"
|
|
||||||
(input)="onInput($any($event.target).value)"
|
|
||||||
(scroll)="onScroll()"
|
|
||||||
spellcheck="false"
|
|
||||||
[attr.wrap]="wrap() ? 'soft' : 'off'"
|
|
||||||
class="min-h-0 flex-1 resize-none overflow-auto bg-transparent px-4 py-4 font-mono text-xs leading-relaxed outline-none"
|
|
||||||
[class.whitespace-pre]="!wrap()"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="flex shrink-0 items-center gap-2 border-t border-border bg-chrome px-4 py-2">
|
|
||||||
<span class="text-[0.6875rem] text-muted-foreground">{{ i18n.t('editor.shortcuts', { mod: modLabel }) }}</span>
|
|
||||||
<span class="text-[0.6875rem] text-muted-foreground">·</span>
|
|
||||||
<span class="text-[0.6875rem] tabular-nums text-muted-foreground">
|
|
||||||
{{ i18n.t('editor.lines', { count: lineCount() }) }}
|
|
||||||
</span>
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
<button
|
|
||||||
(click)="requestClose()"
|
|
||||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
|
||||||
>
|
|
||||||
{{ i18n.t('editor.close') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="save()"
|
|
||||||
[disabled]="saving() || !dirty()"
|
|
||||||
class="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
@if (saving()) {
|
|
||||||
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
|
|
||||||
}
|
|
||||||
{{ i18n.t('common.save') }}
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- dirty-close prompt. Component-local on purpose: the shared confirm
|
|
||||||
dialog pulls in PanelsService, which must not boot in this window. -->
|
|
||||||
@if (closePrompt()) {
|
|
||||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancelClose()">
|
|
||||||
<div
|
|
||||||
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
|
||||||
(mousedown)="$event.stopPropagation()"
|
|
||||||
>
|
|
||||||
<h2 class="text-sm font-semibold">{{ i18n.t('editor.saveChangesTitle', { name: ctx.name }) }}</h2>
|
|
||||||
<p class="mt-1.5 text-xs text-muted-foreground">
|
|
||||||
{{ i18n.t('editor.saveChangesBody') }}
|
|
||||||
</p>
|
|
||||||
<div class="mt-4 flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
(click)="discardAndClose()"
|
|
||||||
class="rounded-md px-3 py-1.5 text-xs font-medium text-destructive hover:bg-accent"
|
|
||||||
>
|
|
||||||
{{ i18n.t('editor.dontSave') }}
|
|
||||||
</button>
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
<button
|
|
||||||
(click)="cancelClose()"
|
|
||||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
|
||||||
>
|
|
||||||
{{ i18n.t('common.cancel') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="saveAndClose()"
|
|
||||||
[disabled]="saving()"
|
|
||||||
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{{ i18n.t('common.save') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- STOPGAP: the frameless window has no WM resize borders, so we draw our
|
|
||||||
own handles. Delete with the rest of the hack — checklist in
|
|
||||||
window-resize/window-resize.ts. -->
|
|
||||||
<app-window-resize />
|
|
||||||
|
|
||||||
<app-toasts />
|
|
||||||
</div>
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
import {
|
|
||||||
Component,
|
|
||||||
DOCUMENT,
|
|
||||||
ElementRef,
|
|
||||||
afterNextRender,
|
|
||||||
computed,
|
|
||||||
effect,
|
|
||||||
inject,
|
|
||||||
signal,
|
|
||||||
viewChild,
|
|
||||||
} from '@angular/core';
|
|
||||||
import { NgIcon } from '@ng-icons/core';
|
|
||||||
import { I18nService } from '@core/i18n.service';
|
|
||||||
import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, MOD_LABEL } from '@core/platform';
|
|
||||||
import { RpcService } from '@core/rpc.service';
|
|
||||||
import { SettingsService } from '@core/settings.service';
|
|
||||||
import { ShortcutsService } from '@core/shortcuts.service';
|
|
||||||
import { ToastService } from '@core/toast.service';
|
|
||||||
import { TitleBar } from '../title-bar/title-bar';
|
|
||||||
import { Toasts } from '../toasts/toasts';
|
|
||||||
import { WindowResize } from '../window-resize/window-resize';
|
|
||||||
|
|
||||||
/** Debounce for mirroring the draft to bun (the traffic-light-close safety net). */
|
|
||||||
const DRAFT_DEBOUNCE_MS = 400;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Root component of an editor window (window.__APP_MODE === 'editor').
|
|
||||||
*
|
|
||||||
* Deliberately injects only Rpc/Toast/Settings: main-window root services
|
|
||||||
* (PanelsService, ConnectionsService, TransfersService — and the dialogs that
|
|
||||||
* inject them) have constructor side effects like creating workspaces and
|
|
||||||
* loading connections that must not run here.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'app-root',
|
|
||||||
imports: [NgIcon, Toasts, TitleBar, WindowResize],
|
|
||||||
templateUrl: './editor-window.html',
|
|
||||||
host: {
|
|
||||||
'(window:keydown)': 'onKeydown($event)',
|
|
||||||
'(window:contextmenu)': 'blockContextMenu($event)',
|
|
||||||
// Aero desaturates an unfocused window's chrome — see .window.active.
|
|
||||||
// blur already had to flush the draft; both run from the one binding
|
|
||||||
// (a duplicated key is a TS1117 build error, not a merge).
|
|
||||||
'(window:focus)': 'windowFocused.set(true)',
|
|
||||||
'(window:blur)': 'flushDraft(); windowFocused.set(false)',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export class EditorWindow {
|
|
||||||
readonly i18n = inject(I18nService);
|
|
||||||
|
|
||||||
/** Drives `.window.active` on this window's Aero frame. */
|
|
||||||
readonly windowFocused = signal(document.hasFocus());
|
|
||||||
readonly ctx = EDITOR_CONTEXT;
|
|
||||||
readonly modLabel = MOD_LABEL;
|
|
||||||
readonly altLabel = ALT_LABEL;
|
|
||||||
|
|
||||||
readonly loading = signal(true);
|
|
||||||
readonly loadError = signal<string | null>(null);
|
|
||||||
readonly text = signal('');
|
|
||||||
readonly savedContent = signal('');
|
|
||||||
readonly dirty = computed(() => this.text() !== this.savedContent());
|
|
||||||
readonly saving = signal(false);
|
|
||||||
readonly closePrompt = signal(false);
|
|
||||||
readonly sessionDead = signal(false);
|
|
||||||
/** Soft-wrap long lines. Hides the gutter: correct wrapped line numbers
|
|
||||||
* would need per-edit height measurement — not worth it without an editor lib. */
|
|
||||||
readonly wrap = signal(false);
|
|
||||||
readonly findOpen = signal(false);
|
|
||||||
readonly findQuery = signal('');
|
|
||||||
readonly activeMatch = signal(0);
|
|
||||||
|
|
||||||
/** Line count without allocating a split array on every keystroke. */
|
|
||||||
readonly lineCount = computed(() => {
|
|
||||||
const t = this.text();
|
|
||||||
let n = 1;
|
|
||||||
let i = -1;
|
|
||||||
while ((i = t.indexOf('\n', i + 1)) !== -1) n++;
|
|
||||||
return n;
|
|
||||||
});
|
|
||||||
readonly gutterText = computed(() =>
|
|
||||||
Array.from({ length: this.lineCount() }, (_, i) => i + 1).join('\n'),
|
|
||||||
);
|
|
||||||
readonly gutterDigits = computed(() => String(this.lineCount()).length);
|
|
||||||
|
|
||||||
/** Case-insensitive match start offsets for the find query. */
|
|
||||||
readonly matches = computed(() => {
|
|
||||||
const query = this.findQuery().toLowerCase();
|
|
||||||
if (!query) return [];
|
|
||||||
const hay = this.text().toLowerCase();
|
|
||||||
const out: number[] = [];
|
|
||||||
let i = hay.indexOf(query);
|
|
||||||
while (i !== -1) {
|
|
||||||
out.push(i);
|
|
||||||
i = hay.indexOf(query, i + query.length);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
});
|
|
||||||
readonly countLabel = computed(() =>
|
|
||||||
this.matches().length ? `${this.activeMatch() + 1}/${this.matches().length}` : '0/0',
|
|
||||||
);
|
|
||||||
|
|
||||||
private readonly box = viewChild<ElementRef<HTMLTextAreaElement>>('box');
|
|
||||||
private readonly gutter = viewChild<ElementRef<HTMLElement>>('gutter');
|
|
||||||
private readonly findBox = viewChild<ElementRef<HTMLInputElement>>('findBox');
|
|
||||||
private cachedCharWidth = 0;
|
|
||||||
private cachedCharFont = '';
|
|
||||||
private readonly rpc = inject(RpcService);
|
|
||||||
private readonly toast = inject(ToastService);
|
|
||||||
private readonly document = inject(DOCUMENT);
|
|
||||||
// injected for its side effects: fetches settings and applies theme/accent/
|
|
||||||
// text-size data attributes to this window's document root
|
|
||||||
private readonly settings = inject(SettingsService);
|
|
||||||
private readonly shortcuts = inject(ShortcutsService);
|
|
||||||
|
|
||||||
private draftTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
private lastSentDirty = false;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
const ctx = this.ctx;
|
|
||||||
if (!ctx) return; // opened outside the app (plain browser) — error card renders
|
|
||||||
|
|
||||||
const baseTitle = `${ctx.name} — ${ctx.sessionTitle}`;
|
|
||||||
effect(() => {
|
|
||||||
this.document.title = `${this.dirty() ? '• ' : ''}${baseTitle}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.rpc.onEditorCloseRequest(() => this.requestClose());
|
|
||||||
this.rpc.onSessionClosed((event) => {
|
|
||||||
if (event.sessionId === ctx.sessionId) this.sessionDead.set(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterNextRender(() => void this.load(ctx.sessionId, ctx.path));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async load(sessionId: string, path: string): Promise<void> {
|
|
||||||
const res = await this.rpc.request.readFile({ sessionId, path });
|
|
||||||
if (!res.ok) {
|
|
||||||
this.loadError.set(res.error);
|
|
||||||
this.loading.set(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.savedContent.set(res.content);
|
|
||||||
this.text.set(res.content);
|
|
||||||
this.loading.set(false);
|
|
||||||
queueMicrotask(() => this.box()?.nativeElement.focus());
|
|
||||||
}
|
|
||||||
|
|
||||||
blockContextMenu(event: Event): void {
|
|
||||||
blockNativeContextMenu(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
onInput(value: string): void {
|
|
||||||
this.text.set(value);
|
|
||||||
this.scheduleDraft();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Keep the line-number gutter locked to the textarea's scroll position. */
|
|
||||||
onScroll(): void {
|
|
||||||
const gutter = this.gutter()?.nativeElement;
|
|
||||||
if (gutter) gutter.scrollTop = this.box()!.nativeElement.scrollTop;
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleWrap(): void {
|
|
||||||
this.wrap.update((w) => !w);
|
|
||||||
queueMicrotask(() => this.onScroll());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- find ----
|
|
||||||
|
|
||||||
openFind(): void {
|
|
||||||
const el = this.box()?.nativeElement;
|
|
||||||
// prefill from the current selection when it's a sane single-line snippet
|
|
||||||
if (el && el.selectionStart !== el.selectionEnd) {
|
|
||||||
const selected = el.value.slice(el.selectionStart, el.selectionEnd);
|
|
||||||
if (selected.length < 200 && !selected.includes('\n')) this.findQuery.set(selected);
|
|
||||||
}
|
|
||||||
this.findOpen.set(true);
|
|
||||||
this.activeMatch.set(0);
|
|
||||||
queueMicrotask(() => {
|
|
||||||
const input = this.findBox()?.nativeElement;
|
|
||||||
input?.focus();
|
|
||||||
input?.select();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
closeFind(): void {
|
|
||||||
this.findOpen.set(false);
|
|
||||||
// selection was left on the match — the caret lands there
|
|
||||||
this.box()?.nativeElement.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
onFindInput(value: string): void {
|
|
||||||
this.findQuery.set(value);
|
|
||||||
const list = this.matches();
|
|
||||||
if (!list.length) return;
|
|
||||||
// jump to the first match at/after the caret, else wrap to the first
|
|
||||||
const from = this.box()?.nativeElement.selectionStart ?? 0;
|
|
||||||
const at = list.findIndex((start) => start >= from);
|
|
||||||
this.goTo(at >= 0 ? at : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
findStep(delta: 1 | -1): void {
|
|
||||||
const count = this.matches().length;
|
|
||||||
if (!count) return;
|
|
||||||
this.goTo((this.activeMatch() + delta + count) % count);
|
|
||||||
}
|
|
||||||
|
|
||||||
onFindKeydown(event: KeyboardEvent): void {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
event.preventDefault();
|
|
||||||
this.findStep(event.shiftKey ? -1 : 1);
|
|
||||||
} else if (event.key === 'Escape') {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
this.closeFind();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Select match `index` and scroll it into the middle of the viewport. */
|
|
||||||
private goTo(index: number): void {
|
|
||||||
const el = this.box()?.nativeElement;
|
|
||||||
const start = this.matches()[index];
|
|
||||||
if (!el || start === undefined) return;
|
|
||||||
this.activeMatch.set(index);
|
|
||||||
el.setSelectionRange(start, start + this.findQuery().length);
|
|
||||||
|
|
||||||
const text = this.text();
|
|
||||||
// Fallback derives from the element's own font size, not a hardcoded 20 —
|
|
||||||
// the interface scale moves the root font-size, so any px literal here
|
|
||||||
// would be a 16px-root assumption that miscentres at every other scale.
|
|
||||||
const style = getComputedStyle(el);
|
|
||||||
const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.625;
|
|
||||||
let line = 0;
|
|
||||||
let i = text.indexOf('\n');
|
|
||||||
while (i !== -1 && i < start) {
|
|
||||||
line++;
|
|
||||||
i = text.indexOf('\n', i + 1);
|
|
||||||
}
|
|
||||||
el.scrollTop = Math.max(0, line * lineHeight - el.clientHeight / 2);
|
|
||||||
if (!this.wrap()) {
|
|
||||||
const column = start - (text.lastIndexOf('\n', start - 1) + 1);
|
|
||||||
el.scrollLeft = Math.max(0, column * this.charWidth(el) - el.clientWidth / 2);
|
|
||||||
}
|
|
||||||
this.onScroll();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Monospace advance width, cached per computed FONT rather than per window:
|
|
||||||
* the interface scale changes the root font-size live, and a cache measured
|
|
||||||
* once would keep positioning finds with the old advance width. Keying on
|
|
||||||
* the font string makes it self-invalidating under any font change. */
|
|
||||||
private charWidth(el: HTMLTextAreaElement): number {
|
|
||||||
const font = getComputedStyle(el).font;
|
|
||||||
if (font !== this.cachedCharFont) {
|
|
||||||
const canvas = this.document.createElement('canvas').getContext('2d')!;
|
|
||||||
canvas.font = font;
|
|
||||||
this.cachedCharWidth = canvas.measureText('0').width || parseFloat(getComputedStyle(el).fontSize) * 0.6;
|
|
||||||
this.cachedCharFont = font;
|
|
||||||
}
|
|
||||||
return this.cachedCharWidth;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- draft mirror: bun holds the latest content for the native-close prompt ----
|
|
||||||
|
|
||||||
private scheduleDraft(): void {
|
|
||||||
if (this.dirty() !== this.lastSentDirty) {
|
|
||||||
// dirty flag flipped — tell bun immediately so the native title updates
|
|
||||||
this.flushDraft();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.draftTimer) clearTimeout(this.draftTimer);
|
|
||||||
this.draftTimer = setTimeout(() => this.flushDraft(), DRAFT_DEBOUNCE_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
flushDraft(): void {
|
|
||||||
if (!this.ctx || this.loading()) return;
|
|
||||||
if (this.draftTimer) {
|
|
||||||
clearTimeout(this.draftTimer);
|
|
||||||
this.draftTimer = null;
|
|
||||||
}
|
|
||||||
this.lastSentDirty = this.dirty();
|
|
||||||
this.rpc.send.editorState({ dirty: this.dirty(), content: this.text() });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- save / close ----
|
|
||||||
|
|
||||||
async save(): Promise<boolean> {
|
|
||||||
if (!this.dirty() || this.saving()) return true;
|
|
||||||
const content = this.text();
|
|
||||||
this.saving.set(true);
|
|
||||||
try {
|
|
||||||
const res = await this.rpc.request.editorSave({ content });
|
|
||||||
if (!res.ok) {
|
|
||||||
this.toast.error(res.error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
this.savedContent.set(content);
|
|
||||||
this.lastSentDirty = false; // bun record already cleaned by the handler
|
|
||||||
this.toast.success(this.i18n.t('toasts.saved', { name: this.ctx!.name }));
|
|
||||||
return true;
|
|
||||||
} finally {
|
|
||||||
this.saving.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ⌘W / Close button / bun's editorCloseRequest: prompt if dirty, else close. */
|
|
||||||
requestClose(): void {
|
|
||||||
if (this.dirty()) {
|
|
||||||
this.closePrompt.set(true);
|
|
||||||
} else {
|
|
||||||
void this.rpc.request.editorClose({});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveAndClose(): Promise<void> {
|
|
||||||
if (await this.save()) void this.rpc.request.editorClose({});
|
|
||||||
}
|
|
||||||
|
|
||||||
discardAndClose(): void {
|
|
||||||
void this.rpc.request.editorClose({});
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelClose(): void {
|
|
||||||
this.closePrompt.set(false);
|
|
||||||
queueMicrotask(() => this.box()?.nativeElement.focus());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- keyboard ----
|
|
||||||
|
|
||||||
onKeydown(event: KeyboardEvent): void {
|
|
||||||
// Chord shortcuts resolve through the shared keymap (ShortcutsService);
|
|
||||||
// Escape and Tab stay below because their meaning is THIS window's state
|
|
||||||
// (close prompt up? find bar open? caret in the textarea?), not a binding.
|
|
||||||
const match = this.shortcuts.resolve(event, 'editor');
|
|
||||||
if (match) {
|
|
||||||
// scale keys must not fire while typing — but the editor's own chords
|
|
||||||
// (save/find/wrap) are WANTED mid-typing, so the guard is per-shortcut
|
|
||||||
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
|
|
||||||
event.preventDefault();
|
|
||||||
switch (match.id) {
|
|
||||||
case 'save':
|
|
||||||
void this.save();
|
|
||||||
break;
|
|
||||||
case 'find':
|
|
||||||
this.openFind();
|
|
||||||
break;
|
|
||||||
case 'close-window':
|
|
||||||
// reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W
|
|
||||||
// and bun routes it back as editorCloseRequest
|
|
||||||
this.requestClose();
|
|
||||||
break;
|
|
||||||
case 'toggle-wrap':
|
|
||||||
this.toggleWrap();
|
|
||||||
break;
|
|
||||||
case 'scale-up':
|
|
||||||
this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
|
||||||
break;
|
|
||||||
case 'scale-down':
|
|
||||||
this.settings.setUiScale(this.settings.uiScale() - 0.1);
|
|
||||||
break;
|
|
||||||
case 'scale-reset':
|
|
||||||
this.settings.setUiScale(1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.key === 'Escape' && this.closePrompt()) {
|
|
||||||
event.preventDefault();
|
|
||||||
this.cancelClose();
|
|
||||||
} else if (event.key === 'Escape' && this.findOpen()) {
|
|
||||||
event.preventDefault();
|
|
||||||
this.closeFind();
|
|
||||||
} else if (event.key === 'Tab' && event.target === this.box()?.nativeElement) {
|
|
||||||
// keep Tab as an input character inside the editor
|
|
||||||
event.preventDefault();
|
|
||||||
this.insertTab();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private insertTab(): void {
|
|
||||||
const el = this.box()!.nativeElement;
|
|
||||||
// execCommand keeps the native undo stack intact where supported
|
|
||||||
if (!this.document.execCommand('insertText', false, '\t')) {
|
|
||||||
const { selectionStart, selectionEnd, value } = el;
|
|
||||||
el.value = `${value.slice(0, selectionStart)}\t${value.slice(selectionEnd)}`;
|
|
||||||
el.selectionStart = el.selectionEnd = selectionStart + 1;
|
|
||||||
}
|
|
||||||
this.onInput(el.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,349 +0,0 @@
|
|||||||
<!-- session strip.
|
|
||||||
w7-toolbar: these are Explorer command-bar verbs, not dialog push buttons,
|
|
||||||
so under the win7 skin they go flat with a hover glow instead of wearing
|
|
||||||
the full Aero slab — the same treatment the Connections button gets from
|
|
||||||
the tab-bar row. -->
|
|
||||||
<div class="w7-toolbar relative flex items-center gap-1.5 border-b border-border px-2 py-1">
|
|
||||||
<button
|
|
||||||
(click)="toggleMenu()"
|
|
||||||
class="flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1 text-(length:--path-font) font-medium hover:bg-accent"
|
|
||||||
[title]="i18n.t('panel.choosePanel')"
|
|
||||||
>
|
|
||||||
@if (panel().connecting()) {
|
|
||||||
<ng-icon name="mynaSpinner" class="shrink-0 animate-spin text-primary" size="var(--action-icon)" />
|
|
||||||
} @else {
|
|
||||||
<ng-icon
|
|
||||||
[name]="isRemote() ? 'mynaServers' : 'mynaHardDrive'"
|
|
||||||
[class]="isRemote() ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'"
|
|
||||||
size="var(--action-icon)"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
<span class="truncate">{{ isRemote() ? panel().title() : i18n.t('common.local') }}</span>
|
|
||||||
<ng-icon
|
|
||||||
name="mynaChevronDown"
|
|
||||||
class="shrink-0 text-muted-foreground"
|
|
||||||
size="calc(var(--action-icon) - 0.1rem)"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
|
|
||||||
<!-- panel actions: act on THIS panel. Frequent/destructive verbs grow text
|
|
||||||
labels when the panel is wide enough (container query). -->
|
|
||||||
<button
|
|
||||||
(click)="copyToOther()"
|
|
||||||
[title]="side() === 'left' ? i18n.t('panel.copyToRight') : i18n.t('panel.copyToLeft')"
|
|
||||||
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-primary"
|
|
||||||
>
|
|
||||||
<ng-icon [name]="copyIcon()" size="var(--action-icon)" />
|
|
||||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.copy') }}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="run('edit')"
|
|
||||||
[title]="i18n.t('panel.editFile')"
|
|
||||||
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaFileText" size="var(--action-icon)" />
|
|
||||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.edit') }}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="run('rename')"
|
|
||||||
[title]="i18n.t('panel.rename')"
|
|
||||||
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaEdit" size="var(--action-icon)" />
|
|
||||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.rename') }}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="run('mkdir')"
|
|
||||||
[title]="i18n.t('panel.newFolder')"
|
|
||||||
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaFolderPlus" size="var(--action-icon)" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="run('createFile')"
|
|
||||||
[title]="i18n.t('panel.newFile')"
|
|
||||||
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaFilePlus" size="var(--action-icon)" />
|
|
||||||
</button>
|
|
||||||
@if (chmodAvailable()) {
|
|
||||||
<button
|
|
||||||
(click)="run('chmod')"
|
|
||||||
[title]="i18n.t('panel.permissions')"
|
|
||||||
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaLock" size="var(--action-icon)" />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
<button
|
|
||||||
(click)="run('delete')"
|
|
||||||
[title]="i18n.t('panel.deleteHint')"
|
|
||||||
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-destructive"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaTrash" size="var(--action-icon)" />
|
|
||||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.delete') }}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
@if (menuOpen()) {
|
|
||||||
<div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div>
|
|
||||||
<div
|
|
||||||
class="has-scrollbar absolute top-full left-2 z-30 mt-1 max-h-80 w-64 overflow-y-auto rounded-lg border border-border bg-card py-1 text-card-foreground shadow-xl"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
(click)="pickLocal()"
|
|
||||||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
|
||||||
<span class="flex-1">{{ i18n.t('common.local') }}</span>
|
|
||||||
@if (!isRemote()) {
|
|
||||||
<ng-icon name="mynaCheck" size="0.85rem" class="shrink-0 text-primary" />
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Windows drive roots (empty list elsewhere) -->
|
|
||||||
@for (drive of drives(); track drive) {
|
|
||||||
<button
|
|
||||||
(click)="pickDrive(drive)"
|
|
||||||
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-6 text-left text-(length:--path-font) hover:bg-accent"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
|
||||||
<span class="flex-1 font-mono">{{ drive }}</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (connections.grouped().loose.length || connections.grouped().stacks.length) {
|
|
||||||
<div class="my-1 border-t border-border"></div>
|
|
||||||
}
|
|
||||||
@for (connection of connections.grouped().loose; track connection.id) {
|
|
||||||
<button
|
|
||||||
(click)="pickConnection(connection)"
|
|
||||||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
|
||||||
<span class="flex-1 truncate">{{ connection.name }}</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
@for (group of connections.grouped().stacks; track group.stack.id) {
|
|
||||||
<div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
|
|
||||||
{{ group.stack.name }}
|
|
||||||
</div>
|
|
||||||
@for (connection of group.connections; track connection.id) {
|
|
||||||
<button
|
|
||||||
(click)="pickConnection(connection)"
|
|
||||||
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
|
||||||
<span class="flex-1 truncate">{{ connection.name }}</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (connections.sshHosts().length) {
|
|
||||||
<div class="my-1 border-t border-border"></div>
|
|
||||||
<div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
|
|
||||||
~/.ssh/config
|
|
||||||
</div>
|
|
||||||
@for (host of connections.sshHosts(); track host.name) {
|
|
||||||
<button
|
|
||||||
(click)="pickSshHost(host)"
|
|
||||||
[disabled]="host.unsupported"
|
|
||||||
[title]="sshHostTitle(host)"
|
|
||||||
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent disabled:opacity-60 disabled:hover:bg-transparent"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
|
||||||
<span class="flex-1 truncate">{{ host.name }}</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- path bar. Also w7-toolbar: the up/refresh controls sit next to the action
|
|
||||||
row, so leaving them as Aero slabs would read as arbitrary. The class only
|
|
||||||
targets buttons — the path <input> keeps its 7.css textbox treatment. -->
|
|
||||||
<div class="w7-toolbar flex items-center gap-1 border-b border-border px-2 py-1.5">
|
|
||||||
<button
|
|
||||||
(click)="panel().up()"
|
|
||||||
[disabled]="blocked()"
|
|
||||||
[title]="i18n.t('panel.parentDir')"
|
|
||||||
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaArrowUp" size="1rem" />
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
[value]="pathDraft()"
|
|
||||||
(input)="pathDraft.set($any($event.target).value)"
|
|
||||||
(keydown.enter)="commitPath()"
|
|
||||||
(keydown.escape)="resetPath()"
|
|
||||||
(keydown)="$event.stopPropagation()"
|
|
||||||
[disabled]="blocked()"
|
|
||||||
spellcheck="false"
|
|
||||||
class="h-(--path-ctl-h) min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono text-(length:--path-font) outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
(click)="panel().refresh()"
|
|
||||||
[disabled]="blocked()"
|
|
||||||
[title]="i18n.t('panel.refresh')"
|
|
||||||
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
|
|
||||||
>
|
|
||||||
@if (panel().loading()) {
|
|
||||||
<ng-icon name="mynaSpinner" size="1rem" class="animate-spin" />
|
|
||||||
} @else {
|
|
||||||
<ng-icon name="mynaRefresh" size="1rem" />
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Column headers live INSIDE the table now (7.css makes thead sticky), so
|
|
||||||
there is no separate header row here — see the ListView below. -->
|
|
||||||
|
|
||||||
<!-- type-to-filter bar (appears while a query is active) -->
|
|
||||||
@if (panel().filter(); as query) {
|
|
||||||
<div class="flex items-center gap-1.5 border-b border-border bg-accent/50 px-2 py-1">
|
|
||||||
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
|
||||||
<span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span>
|
|
||||||
<span class="shrink-0 text-[0.6875rem] text-muted-foreground">
|
|
||||||
{{ i18n.t('panel.matches', { count: filterMatches() }) }}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
(click)="clearFilter()"
|
|
||||||
[title]="i18n.t('panel.clearFilter')"
|
|
||||||
class="grid size-5 shrink-0 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaX" size="0.8rem" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- file list -->
|
|
||||||
<div class="relative min-h-0 flex-1">
|
|
||||||
<div
|
|
||||||
#list
|
|
||||||
tabindex="0"
|
|
||||||
(keydown)="onKeydown($event)"
|
|
||||||
(mousedown)="activate()"
|
|
||||||
(contextmenu)="listContextMenu($event)"
|
|
||||||
class="has-scrollbar h-full overflow-y-auto outline-none"
|
|
||||||
>
|
|
||||||
@if (panel().connecting()) {
|
|
||||||
<div class="panel-fade-in grid h-full place-items-center">
|
|
||||||
<div class="flex flex-col items-center gap-3">
|
|
||||||
<ng-icon name="mynaSpinner" size="1.4rem" class="animate-spin text-primary" />
|
|
||||||
<span class="text-xs text-muted-foreground">
|
|
||||||
{{ i18n.t('panel.connectingTo', { name: panel().connectingTo() ?? '' }) }}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
(click)="cancelConnect()"
|
|
||||||
class="rounded-md border border-border px-3 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
{{ i18n.t('common.cancel') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<!-- 7.css ListView: a plain <table>. The header is <thead> (7.css makes it
|
|
||||||
position:sticky, which is why it moved in here), the sorted column is
|
|
||||||
.highlighted.indicator[.up], and the selection band is tr.highlighted.
|
|
||||||
Widths come from <colgroup> because 7.css sets table-layout:fixed.
|
|
||||||
`w-full` is Tailwind's job and safe here: 7.css never declares `width` on
|
|
||||||
table, so the `skin` layer has nothing to outrank `utilities` with.
|
|
||||||
Without it the fixed layout shrinks to content and leaves a dead grey
|
|
||||||
gutter down the right of the pane; with it the unsized first <col>
|
|
||||||
absorbs the slack, which is what makes Name the elastic column. -->
|
|
||||||
<table class="w-full" [class.is-active]="isActive()">
|
|
||||||
<colgroup>
|
|
||||||
<col />
|
|
||||||
@for (col of visibleColumns(); track col.key) {
|
|
||||||
<col [style.width]="col.width" />
|
|
||||||
}
|
|
||||||
</colgroup>
|
|
||||||
<thead>
|
|
||||||
<tr (contextmenu)="headerContextMenu($event)">
|
|
||||||
<th
|
|
||||||
(click)="panel().toggleSort('name')"
|
|
||||||
[class.highlighted]="panel().sortKey() === 'name'"
|
|
||||||
[class.indicator]="panel().sortKey() === 'name'"
|
|
||||||
[class.up]="panel().sortKey() === 'name' && panel().sortAsc()"
|
|
||||||
>
|
|
||||||
{{ i18n.t('columns.name') }}
|
|
||||||
</th>
|
|
||||||
@for (col of visibleColumns(); track col.key) {
|
|
||||||
<th
|
|
||||||
(click)="panel().toggleSort(col.key)"
|
|
||||||
[class.highlighted]="panel().sortKey() === col.key"
|
|
||||||
[class.indicator]="panel().sortKey() === col.key"
|
|
||||||
[class.up]="panel().sortKey() === col.key && panel().sortAsc()"
|
|
||||||
[style.text-align]="col.key === 'size' ? 'right' : 'left'"
|
|
||||||
>
|
|
||||||
{{ i18n.t('columns.' + col.key) }}
|
|
||||||
</th>
|
|
||||||
}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (entry of panel().sorted(); track entry.name; let i = $index) {
|
|
||||||
<tr
|
|
||||||
[attr.data-index]="i"
|
|
||||||
(click)="rowClick($event, i)"
|
|
||||||
(dblclick)="openAt(i)"
|
|
||||||
(contextmenu)="rowContextMenu($event, i)"
|
|
||||||
[class.highlighted]="isSelected(entry) || i === panel().cursor()"
|
|
||||||
[class.is-cursor]="i === panel().cursor() && isActive()"
|
|
||||||
>
|
|
||||||
<td>
|
|
||||||
<span class="flex min-w-0 items-center gap-1.5">
|
|
||||||
<ng-icon [name]="iconFor(entry)" size="0.95rem" [class]="iconClass(entry)" />
|
|
||||||
<span class="truncate" [class.italic]="entry.type === 'symlink'">{{ entry.name }}</span>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
@for (col of visibleColumns(); track col.key) {
|
|
||||||
@switch (col.key) {
|
|
||||||
@case ('size') {
|
|
||||||
<td style="text-align: right">{{ entry.type === 'dir' ? '—' : formatSize(entry.size) }}</td>
|
|
||||||
}
|
|
||||||
@case ('mtime') {
|
|
||||||
<td>{{ formatDate(entry.mtime) }}</td>
|
|
||||||
}
|
|
||||||
@case ('kind') {
|
|
||||||
<!-- fileKind is localized in file-panel.ts via i18n -->
|
|
||||||
<td>{{ fileKind(entry) }}</td>
|
|
||||||
}
|
|
||||||
@case ('rights') {
|
|
||||||
<td>{{ entry.name === '..' ? '' : formatMode(entry.mode) }}</td>
|
|
||||||
}
|
|
||||||
@case ('owner') {
|
|
||||||
<td>{{ entry.name === '..' ? '' : (entry.owner ?? '—') }}</td>
|
|
||||||
}
|
|
||||||
@case ('group') {
|
|
||||||
<td>{{ entry.name === '..' ? '' : (entry.group ?? '—') }}</td>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr>
|
|
||||||
<td [attr.colspan]="visibleColumns().length + 1" style="text-align: center; padding: 1.5rem">
|
|
||||||
{{ i18n.t('panel.emptyDir') }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
@if (panel().filter() && filterMatches() === 0) {
|
|
||||||
<tr>
|
|
||||||
<td [attr.colspan]="visibleColumns().length + 1" style="text-align: center; padding: 1.5rem">
|
|
||||||
{{ i18n.t('panel.noMatches') }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@if (panel().slowLoad() && !panel().connecting()) {
|
|
||||||
<div class="panel-fade-in absolute inset-0 z-10 grid place-items-center bg-background/60">
|
|
||||||
<ng-icon name="mynaSpinner" size="1.4rem" class="animate-spin text-primary" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@@ -1,355 +0,0 @@
|
|||||||
import {
|
|
||||||
Component,
|
|
||||||
ElementRef,
|
|
||||||
computed,
|
|
||||||
effect,
|
|
||||||
inject,
|
|
||||||
input,
|
|
||||||
signal,
|
|
||||||
viewChild,
|
|
||||||
} from '@angular/core';
|
|
||||||
import { NgIcon } from '@ng-icons/core';
|
|
||||||
import { I18nService } from '@core/i18n.service';
|
|
||||||
import type { ColumnKey, FileEntry, SavedConnection, SshConfigHost } from '@shared/types';
|
|
||||||
import { ConnectionsService } from '@core/connections.service';
|
|
||||||
import { ContextMenuService } from '@core/context-menu.service';
|
|
||||||
import { DialogsService } from '@core/dialogs.service';
|
|
||||||
import { FileActionsService } from '@core/file-actions.service';
|
|
||||||
import { fileGlyph, fileIconClass, fileKindKey } from '@core/file-icons';
|
|
||||||
import { formatMode } from '@core/format';
|
|
||||||
import { PanelsService, type Side, type SortKey } from '@core/panels.service';
|
|
||||||
import { IS_WINDOWS } from '@core/platform';
|
|
||||||
import { RpcService } from '@core/rpc.service';
|
|
||||||
import { SettingsService } from '@core/settings.service';
|
|
||||||
import { ShortcutsService } from '@core/shortcuts.service';
|
|
||||||
import { TransfersService } from '@core/transfers.service';
|
|
||||||
|
|
||||||
/** Optional columns in fixed display order, with their track widths. */
|
|
||||||
const COLUMN_DEFS: { key: ColumnKey; label: string; width: string }[] = [
|
|
||||||
{ key: 'size', label: 'Size', width: '5.5rem' },
|
|
||||||
{ key: 'mtime', label: 'Modified', width: '8.5rem' },
|
|
||||||
{ key: 'kind', label: 'Kind', width: '5.5rem' },
|
|
||||||
{ key: 'rights', label: 'Rights', width: '6.5rem' },
|
|
||||||
{ key: 'owner', label: 'Owner', width: '6rem' },
|
|
||||||
{ key: 'group', label: 'Group', width: '6rem' },
|
|
||||||
];
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-file-panel',
|
|
||||||
imports: [NgIcon],
|
|
||||||
templateUrl: './file-panel.html',
|
|
||||||
host: { class: '@container flex min-w-0 flex-1 flex-col overflow-hidden' },
|
|
||||||
})
|
|
||||||
export class FilePanel {
|
|
||||||
readonly i18n = inject(I18nService);
|
|
||||||
readonly side = input.required<Side>();
|
|
||||||
|
|
||||||
readonly panels = inject(PanelsService);
|
|
||||||
readonly actions = inject(FileActionsService);
|
|
||||||
readonly transfers = inject(TransfersService);
|
|
||||||
readonly connections = inject(ConnectionsService);
|
|
||||||
readonly dialogs = inject(DialogsService);
|
|
||||||
private readonly menu = inject(ContextMenuService);
|
|
||||||
private readonly settings = inject(SettingsService);
|
|
||||||
private readonly shortcuts = inject(ShortcutsService);
|
|
||||||
private readonly rpc = inject(RpcService);
|
|
||||||
readonly panel = computed(() => this.panels.get(this.side()));
|
|
||||||
readonly isActive = computed(() => this.panels.activeSide() === this.side());
|
|
||||||
readonly isRemote = computed(() => this.panel().sessionId() !== 'local');
|
|
||||||
/** Input is ignored while the panel is connecting or a listing is in flight. */
|
|
||||||
readonly blocked = computed(() => this.panel().busy());
|
|
||||||
/** The copy button points toward the other panel. */
|
|
||||||
readonly copyIcon = computed(() => (this.side() === 'left' ? 'mynaArrowRight' : 'mynaArrowLeft'));
|
|
||||||
|
|
||||||
readonly pathDraft = signal('');
|
|
||||||
readonly menuOpen = signal(false);
|
|
||||||
/** Windows drive roots for the source picker; always empty elsewhere. */
|
|
||||||
readonly drives = signal<string[]>([]);
|
|
||||||
/** chmod on Windows local files only flips the read-only bit — hide it. */
|
|
||||||
readonly chmodAvailable = computed(() => !(IS_WINDOWS && !this.isRemote()));
|
|
||||||
|
|
||||||
private readonly listEl = viewChild.required<ElementRef<HTMLDivElement>>('list');
|
|
||||||
private lastClickedIndex = 0;
|
|
||||||
|
|
||||||
// locale-reactive wrappers: reading i18n.locale() inside makes the file-list
|
|
||||||
// cells re-render on a language switch (see i18n.service reactive t())
|
|
||||||
readonly formatSize = (bytes: number): string => this.i18n.size(bytes);
|
|
||||||
readonly formatDate = (ms: number): string => this.i18n.date(ms);
|
|
||||||
readonly formatMode = formatMode;
|
|
||||||
readonly fileKind = (entry: FileEntry): string => {
|
|
||||||
const key = fileKindKey(entry);
|
|
||||||
return key ? this.i18n.t('kinds.' + key) : '';
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The optional columns currently shown, in canonical order. */
|
|
||||||
readonly visibleColumns = computed(() => {
|
|
||||||
const visible = this.settings.columns();
|
|
||||||
return COLUMN_DEFS.filter((c) => visible.includes(c.key));
|
|
||||||
});
|
|
||||||
|
|
||||||
headerContextMenu(event: MouseEvent): void {
|
|
||||||
event.preventDefault();
|
|
||||||
this.menu.open(event, this.menu.columnItems(this.settings.columns()));
|
|
||||||
}
|
|
||||||
|
|
||||||
pickDrive(drive: string): void {
|
|
||||||
this.menuOpen.set(false);
|
|
||||||
this.activate();
|
|
||||||
void this.panel().load(drive);
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
if (IS_WINDOWS && this.rpc.available) {
|
|
||||||
void this.rpc.request.listDrives({}).then((res) => {
|
|
||||||
if (res.ok) this.drives.set(res.drives);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// keep the editable path bar in sync with the actual cwd
|
|
||||||
effect(() => this.pathDraft.set(this.panel().cwd()));
|
|
||||||
// focus follows the active side (Tab switches panels, dialogs refocus on close)
|
|
||||||
effect(() => {
|
|
||||||
this.panels.focusTick();
|
|
||||||
if (this.isActive()) this.listEl().nativeElement.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
activate(): void {
|
|
||||||
this.panels.setActiveSide(this.side());
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelConnect(): void {
|
|
||||||
this.panels.cancelConnect(this.side());
|
|
||||||
}
|
|
||||||
|
|
||||||
copyToOther(): void {
|
|
||||||
if (this.blocked()) return;
|
|
||||||
this.activate();
|
|
||||||
void this.transfers.copyToOtherPanel(this.side());
|
|
||||||
}
|
|
||||||
|
|
||||||
run(action: 'edit' | 'rename' | 'mkdir' | 'createFile' | 'chmod' | 'delete'): void {
|
|
||||||
if (this.blocked()) return;
|
|
||||||
this.activate();
|
|
||||||
void this.actions[action](this.panel());
|
|
||||||
}
|
|
||||||
|
|
||||||
pickLocal(): void {
|
|
||||||
this.menuOpen.set(false);
|
|
||||||
void this.panels.disconnectPanel(this.side());
|
|
||||||
}
|
|
||||||
|
|
||||||
pickConnection(connection: SavedConnection): void {
|
|
||||||
this.menuOpen.set(false);
|
|
||||||
this.activate();
|
|
||||||
void this.panels.connectPanel(this.side(), connection, connection.name, connection.localDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleMenu(): void {
|
|
||||||
this.menuOpen.set(!this.menuOpen());
|
|
||||||
// re-read ~/.ssh/config on each open so external edits show up
|
|
||||||
if (this.menuOpen()) void this.connections.reloadSshHosts();
|
|
||||||
}
|
|
||||||
|
|
||||||
pickSshHost(host: SshConfigHost): void {
|
|
||||||
this.menuOpen.set(false);
|
|
||||||
this.activate();
|
|
||||||
void this.panels.connectPanel(
|
|
||||||
this.side(),
|
|
||||||
{
|
|
||||||
host: host.hostname,
|
|
||||||
port: host.port,
|
|
||||||
username: host.username,
|
|
||||||
auth: host.identityFile
|
|
||||||
? { kind: 'key', privateKeyPath: host.identityFile }
|
|
||||||
: { kind: 'agent' },
|
|
||||||
initialPath: null,
|
|
||||||
},
|
|
||||||
host.name,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
sshHostTitle(host: SshConfigHost): string {
|
|
||||||
if (host.unsupported === 'proxy') return 'Uses ProxyJump/ProxyCommand — not supported';
|
|
||||||
return `${host.username}@${host.hostname}:${host.port}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
iconFor(entry: FileEntry): string {
|
|
||||||
return fileGlyph(entry, this.settings.iconColors() === 'multi');
|
|
||||||
}
|
|
||||||
|
|
||||||
iconClass(entry: FileEntry): string {
|
|
||||||
return fileIconClass(entry, this.settings.iconColors() === 'multi');
|
|
||||||
}
|
|
||||||
|
|
||||||
isSelected(entry: FileEntry): boolean {
|
|
||||||
return this.panel().selected().has(entry.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearFilter(): void {
|
|
||||||
this.panel().setFilter('');
|
|
||||||
this.listEl().nativeElement.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
filterMatches(): number {
|
|
||||||
return this.panel()
|
|
||||||
.sorted()
|
|
||||||
.filter((e) => e.name !== '..').length;
|
|
||||||
}
|
|
||||||
|
|
||||||
commitPath(): void {
|
|
||||||
if (this.blocked()) return;
|
|
||||||
const value = this.pathDraft().trim();
|
|
||||||
if (value) void this.panel().load(value);
|
|
||||||
this.listEl().nativeElement.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
resetPath(): void {
|
|
||||||
this.pathDraft.set(this.panel().cwd());
|
|
||||||
this.listEl().nativeElement.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
rowClick(event: MouseEvent, index: number): void {
|
|
||||||
if (this.blocked()) return;
|
|
||||||
this.activate();
|
|
||||||
const panel = this.panel();
|
|
||||||
if (event.shiftKey) {
|
|
||||||
panel.selectRange(this.lastClickedIndex, index);
|
|
||||||
} else if (this.shortcuts.isMod(event)) {
|
|
||||||
const entry = panel.sorted()[index];
|
|
||||||
if (entry) panel.toggleSelect(entry.name);
|
|
||||||
} else {
|
|
||||||
panel.clearSelection();
|
|
||||||
}
|
|
||||||
panel.cursor.set(index);
|
|
||||||
this.lastClickedIndex = index;
|
|
||||||
}
|
|
||||||
|
|
||||||
rowContextMenu(event: MouseEvent, index: number): void {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
if (this.blocked()) return;
|
|
||||||
this.activate();
|
|
||||||
const panel = this.panel();
|
|
||||||
const entry = panel.sorted()[index] ?? null;
|
|
||||||
// standard behavior: keep the selection when right-clicking inside it
|
|
||||||
if (entry && !panel.selected().has(entry.name)) panel.clearSelection();
|
|
||||||
panel.cursor.set(index);
|
|
||||||
this.actions.openContextMenu(event, { side: this.side(), panel, entry });
|
|
||||||
}
|
|
||||||
|
|
||||||
listContextMenu(event: MouseEvent): void {
|
|
||||||
event.preventDefault();
|
|
||||||
if (this.blocked()) return;
|
|
||||||
this.activate();
|
|
||||||
this.actions.openContextMenu(event, { side: this.side(), panel: this.panel(), entry: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
openAt(index: number): void {
|
|
||||||
if (this.blocked()) return;
|
|
||||||
const entry = this.panel().sorted()[index];
|
|
||||||
if (entry) this.panel().open(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
onKeydown(event: KeyboardEvent): void {
|
|
||||||
if (this.blocked()) return;
|
|
||||||
const panel = this.panel();
|
|
||||||
const key = event.key;
|
|
||||||
|
|
||||||
if (key === 'Tab') {
|
|
||||||
event.preventDefault();
|
|
||||||
this.panels.setActiveSide(this.panels.otherSide(this.side()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.shortcuts.isMod(event) && key.toLowerCase() === 'a') {
|
|
||||||
event.preventDefault();
|
|
||||||
panel.selectAll();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// type-to-filter: printable characters build the query (Space only once a
|
|
||||||
// query exists — an empty-filter Space keeps its toggle-select meaning)
|
|
||||||
if (
|
|
||||||
key.length === 1 &&
|
|
||||||
!event.metaKey &&
|
|
||||||
!event.ctrlKey &&
|
|
||||||
!event.altKey &&
|
|
||||||
(key !== ' ' || panel.filter() !== '')
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
panel.setFilter(panel.filter() + key);
|
|
||||||
this.scrollCursorIntoView();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key === 'Backspace' && panel.filter() !== '') {
|
|
||||||
event.preventDefault();
|
|
||||||
panel.setFilter(panel.filter().slice(0, -1));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key === 'Escape' && panel.filter() !== '') {
|
|
||||||
event.preventDefault();
|
|
||||||
panel.setFilter('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (key) {
|
|
||||||
case 'ArrowDown':
|
|
||||||
panel.moveCursor(1);
|
|
||||||
break;
|
|
||||||
case 'ArrowUp':
|
|
||||||
panel.moveCursor(-1);
|
|
||||||
break;
|
|
||||||
case 'PageDown':
|
|
||||||
panel.moveCursor(20);
|
|
||||||
break;
|
|
||||||
case 'PageUp':
|
|
||||||
panel.moveCursor(-20);
|
|
||||||
break;
|
|
||||||
case 'Home':
|
|
||||||
panel.cursor.set(0);
|
|
||||||
break;
|
|
||||||
case 'End':
|
|
||||||
panel.cursor.set(Math.max(0, panel.sorted().length - 1));
|
|
||||||
break;
|
|
||||||
case ' ':
|
|
||||||
{
|
|
||||||
const entry = panel.sorted()[panel.cursor()];
|
|
||||||
if (entry) panel.toggleSelect(entry.name);
|
|
||||||
panel.moveCursor(1);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'Enter':
|
|
||||||
this.openAt(panel.cursor());
|
|
||||||
return void event.preventDefault();
|
|
||||||
case 'Backspace':
|
|
||||||
panel.up();
|
|
||||||
break;
|
|
||||||
case 'F2':
|
|
||||||
this.actions.rename(panel);
|
|
||||||
break;
|
|
||||||
case 'F4':
|
|
||||||
void this.actions.edit(panel);
|
|
||||||
break;
|
|
||||||
case 'F5':
|
|
||||||
void this.transfers.copyToOtherPanel(this.side());
|
|
||||||
break;
|
|
||||||
case 'F7':
|
|
||||||
this.actions.mkdir(panel);
|
|
||||||
break;
|
|
||||||
case 'F8':
|
|
||||||
case 'Delete':
|
|
||||||
this.actions.delete(panel);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
return; // let unhandled keys through
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
this.scrollCursorIntoView();
|
|
||||||
}
|
|
||||||
|
|
||||||
private scrollCursorIntoView(): void {
|
|
||||||
const row = this.listEl().nativeElement.querySelector(
|
|
||||||
`[data-index="${this.panel().cursor()}"]`,
|
|
||||||
);
|
|
||||||
row?.scrollIntoView({ block: 'nearest' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -1,17 +0,0 @@
|
|||||||
import { App } from './app';
|
|
||||||
import { ConnectionManager } from './connection-manager/connection-manager';
|
|
||||||
import { EditorWindow } from './editor-window/editor-window';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The three window roots of the Windows 7 skin, keyed by APP_MODE.
|
|
||||||
*
|
|
||||||
* Every skin exports this same shape, so main.ts selects a tree by skin and
|
|
||||||
* then a root by mode without knowing anything about either skin's internals.
|
|
||||||
* All three roots declare `selector: 'app-root'` — the single <app-root> in
|
|
||||||
* index.html hosts whichever one gets bootstrapped.
|
|
||||||
*/
|
|
||||||
export const SEVEN_ROOTS = {
|
|
||||||
main: App,
|
|
||||||
manager: ConnectionManager,
|
|
||||||
editor: EditorWindow,
|
|
||||||
} as const;
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { Component, computed, inject } from '@angular/core';
|
|
||||||
import { NgIcon } from '@ng-icons/core';
|
|
||||||
import { I18nService } from '@core/i18n.service';
|
|
||||||
import type { TransferSnapshot } from '@shared/types';
|
|
||||||
import { formatSpeed } from '@core/format';
|
|
||||||
import { PanelsService } from '@core/panels.service';
|
|
||||||
import { SettingsService } from '@core/settings.service';
|
|
||||||
import { TransfersService } from '@core/transfers.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-status-bar',
|
|
||||||
imports: [NgIcon],
|
|
||||||
template: `
|
|
||||||
<div
|
|
||||||
class="flex h-6 items-center justify-between gap-4 border-t border-border bg-chrome px-3 text-[0.6875rem] text-muted-foreground select-none"
|
|
||||||
>
|
|
||||||
<!-- transfers summary; click toggles the detailed list -->
|
|
||||||
<!-- w7-flat: this is a text toggle, not a push button. Without it 7.css
|
|
||||||
paints the full Aero slab and the status line reads as a stray
|
|
||||||
bordered button sitting in the status bar. -->
|
|
||||||
<button
|
|
||||||
(click)="transfers.expanded.set(!transfers.expanded())"
|
|
||||||
class="w7-flat flex min-w-0 items-center gap-1.5 hover:text-foreground"
|
|
||||||
[title]="i18n.t('transfer.showDetails')"
|
|
||||||
>
|
|
||||||
@if (transfers.currentJob(); as job) {
|
|
||||||
<ng-icon name="mynaSpinner" size="0.8rem" class="shrink-0 animate-spin text-primary" />
|
|
||||||
<span class="truncate">
|
|
||||||
{{ i18n.t('transfer.copying', { name: job.name, percent: percent(job), speed: speedSuffix(job) }) }}
|
|
||||||
@if (transfers.queuedCount(); as queued) {
|
|
||||||
· {{ i18n.t('transfer.queued', { count: queued }) }}
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
} @else if (transfers.failedJobs().length; as failed) {
|
|
||||||
<ng-icon name="mynaXCircle" size="0.8rem" class="shrink-0 text-destructive" />
|
|
||||||
<span class="text-destructive">
|
|
||||||
{{ i18n.t('transfer.failed', { count: failed }) }}
|
|
||||||
</span>
|
|
||||||
} @else if (transfers.hasJobs()) {
|
|
||||||
<ng-icon name="mynaCheckCircle" size="0.8rem" class="shrink-0 text-success" />
|
|
||||||
<span>{{ i18n.t('transfer.finished') }}</span>
|
|
||||||
} @else {
|
|
||||||
<span>{{ i18n.t('transfer.ready') }}</span>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- active panel info -->
|
|
||||||
<div class="flex shrink-0 items-center gap-1.5">
|
|
||||||
<ng-icon
|
|
||||||
[name]="activePanel().sessionId() === 'local' ? 'mynaHardDrive' : 'mynaServers'"
|
|
||||||
size="0.8rem"
|
|
||||||
[class.text-primary]="activePanel().sessionId() !== 'local'"
|
|
||||||
/>
|
|
||||||
<span class="max-w-56 truncate">
|
|
||||||
{{ activePanel().sessionId() === 'local' ? i18n.t('common.local') : activePanel().title() }}
|
|
||||||
</span>
|
|
||||||
<span>· {{ i18n.t('transfer.items', { count: activePanel().sorted().length }) }}</span>
|
|
||||||
@if (activePanel().selected().size; as count) {
|
|
||||||
<span>· {{ i18n.t('transfer.selected', { count }) }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class StatusBar {
|
|
||||||
readonly i18n = inject(I18nService);
|
|
||||||
readonly transfers = inject(TransfersService);
|
|
||||||
readonly panels = inject(PanelsService);
|
|
||||||
readonly settings = inject(SettingsService);
|
|
||||||
readonly activePanel = computed(() => this.panels.get(this.panels.activeSide()));
|
|
||||||
|
|
||||||
percent(job: TransferSnapshot): number {
|
|
||||||
if (!job.totalBytes) return 0;
|
|
||||||
return Math.min(100, Math.round((job.doneBytes / job.totalBytes) * 100));
|
|
||||||
}
|
|
||||||
|
|
||||||
speedSuffix(job: TransferSnapshot): string {
|
|
||||||
const speed = this.transfers.speeds().get(job.id);
|
|
||||||
return speed ? ` · ${formatSpeed(speed, this.settings.speedUnit())}` : '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,621 +0,0 @@
|
|||||||
import { Component, ElementRef, computed, effect, inject, signal, viewChild } from '@angular/core';
|
|
||||||
import { NgIcon } from '@ng-icons/core';
|
|
||||||
import { BRANDING } from '@shared/branding';
|
|
||||||
import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
|
|
||||||
import type { AccentTone } from '@shared/types';
|
|
||||||
import { ContextMenuService } from '@core/context-menu.service';
|
|
||||||
import { I18nService } from '@core/i18n.service';
|
|
||||||
import { PanelsService, type Workspace } from '@core/panels.service';
|
|
||||||
import { APP_SKIN, MOD_LABEL } from '@core/platform';
|
|
||||||
import { RpcService } from '@core/rpc.service';
|
|
||||||
import { SettingsService } from '@core/settings.service';
|
|
||||||
import { UpdaterService } from '@core/updater.service';
|
|
||||||
|
|
||||||
const SKINS = ['modern', 'win7'] as const;
|
|
||||||
const THEMES = ['system', 'light', 'dark'] as const;
|
|
||||||
const DARK_TONES = ['dark', 'darker'] as const;
|
|
||||||
// Swatch colors are fixed mid-tones so every dot reads on both light and dark
|
|
||||||
// cards (the live --primary values would vanish against one of them). Names are
|
|
||||||
// translated via options.accentNames.<value>.
|
|
||||||
const ACCENTS: readonly { value: AccentTone; swatch: string }[] = [
|
|
||||||
{ value: 'graphite', swatch: 'oklch(0.552 0.016 285.9)' },
|
|
||||||
{ value: 'blue', swatch: 'oklch(0.65 0.17 255)' },
|
|
||||||
{ value: 'indigo', swatch: 'oklch(0.65 0.19 277)' },
|
|
||||||
{ value: 'purple', swatch: 'oklch(0.65 0.17 305)' },
|
|
||||||
{ value: 'rose', swatch: 'oklch(0.65 0.17 8)' },
|
|
||||||
{ value: 'orange', swatch: 'oklch(0.68 0.17 50)' },
|
|
||||||
{ value: 'green', swatch: 'oklch(0.65 0.15 150)' },
|
|
||||||
{ value: 'teal', swatch: 'oklch(0.65 0.12 185)' },
|
|
||||||
];
|
|
||||||
const SPEED_UNITS = [
|
|
||||||
{ value: 'MBps', label: 'MB/s' },
|
|
||||||
{ value: 'Mbps', label: 'Mbps' },
|
|
||||||
] as const;
|
|
||||||
const ICON_COLOR_MODES = ['plain', 'multi'] as const;
|
|
||||||
const TRANSFER_MODES = ['single', 'multi'] as const;
|
|
||||||
const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-tab-bar',
|
|
||||||
imports: [NgIcon],
|
|
||||||
template: `
|
|
||||||
<!-- The workspace strip, directly under the title bar. Draggable so the
|
|
||||||
empty stretch between the tabs and the right-hand tools still moves the
|
|
||||||
frameless window. ONE class attribute: a second one is silently dropped
|
|
||||||
by the template parser, which is exactly how this row lost its height. -->
|
|
||||||
<div
|
|
||||||
class="w7-toolbar relative flex h-9 items-center gap-0.5 border-b border-border bg-chrome pt-1 pr-1.5 pl-1.5 electrobun-webkit-app-region-drag"
|
|
||||||
>
|
|
||||||
<!-- 7.css's real tab component. The contract is exact: menu[role=tablist]
|
|
||||||
for the strip and a button[aria-selected] per tab — 7.css then draws
|
|
||||||
the joined inactive tabs and raises the active one (white fill, no
|
|
||||||
bottom border, pulled 2px down over the row's border so it reads as
|
|
||||||
joined to the content). Everything the hand-rolled version painted
|
|
||||||
(bg-accent, the absolutely-positioned bg-primary underline) is gone:
|
|
||||||
it was a lookalike competing with the framework.
|
|
||||||
NOTE the selector is "menu[role=tablist] button" — a DESCENDANT, so
|
|
||||||
any button in here becomes a tab. The "+" and the right-hand tools
|
|
||||||
deliberately live OUTSIDE this element.
|
|
||||||
(No backticks in this template: it is a TS template literal.) -->
|
|
||||||
<menu
|
|
||||||
#tabScroll
|
|
||||||
role="tablist"
|
|
||||||
class="flex min-w-0 items-center overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
||||||
>
|
|
||||||
@for (ws of panels.workspaces(); track ws.id; let i = $index) {
|
|
||||||
<!-- No ✕ inside the tab: real Win7 tab controls had none, and a button
|
|
||||||
nested in a button is invalid HTML. Closing is Ctrl/Cmd+W or
|
|
||||||
middle-click (auxclick), so a pointer path survives. -->
|
|
||||||
<button
|
|
||||||
role="tab"
|
|
||||||
[attr.aria-selected]="ws.id === panels.activeWorkspaceId()"
|
|
||||||
[attr.data-tab-id]="ws.id"
|
|
||||||
(click)="panels.setActiveWorkspace(ws.id)"
|
|
||||||
(auxclick)="onAuxClick($event, ws.id)"
|
|
||||||
(contextmenu)="onTabContextMenu($event, ws, i)"
|
|
||||||
class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5 electrobun-webkit-app-region-no-drag"
|
|
||||||
[class.opacity-50]="draggedId() === ws.id"
|
|
||||||
[style.box-shadow]="dropIndicatorShadow(i)"
|
|
||||||
[title]="panels.workspaceName(ws)"
|
|
||||||
draggable="true"
|
|
||||||
(dragstart)="onDragStart($event, ws.id)"
|
|
||||||
(dragover)="onDragOver($event, i)"
|
|
||||||
(drop)="onDrop($event, i)"
|
|
||||||
(dragend)="onDragEnd()"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
[name]="panels.workspaceIsRemote(ws) ? 'mynaServers' : 'mynaHardDrive'"
|
|
||||||
size="0.85rem"
|
|
||||||
class="shrink-0"
|
|
||||||
/>
|
|
||||||
<span class="truncate">{{ panels.workspaceName(ws) }}</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</menu>
|
|
||||||
<button
|
|
||||||
(click)="panels.newWorkspace()"
|
|
||||||
[title]="i18n.t('tabBar.newTab', { mod: modLabel })"
|
|
||||||
class="ml-0.5 grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaPlus" size=".8rem" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
|
|
||||||
<!-- auto-update tool: one accent pill that announces the update, becomes its
|
|
||||||
own progress bar while downloading, then turns into "Restart to apply" -->
|
|
||||||
@if (updater.state() !== 'idle' && updater.state() !== 'error') {
|
|
||||||
<button
|
|
||||||
(click)="onUpdateClick()"
|
|
||||||
[disabled]="updater.state() === 'downloading'"
|
|
||||||
[title]="i18n.t('tabBar.updateTooltip', { version: updater.version() })"
|
|
||||||
class="relative mr-1 grid h-6 place-items-center overflow-hidden rounded-md px-2.5 text-[0.6875rem] font-medium bg-primary text-primary-foreground electrobun-webkit-app-region-no-drag hover:opacity-90 disabled:cursor-default"
|
|
||||||
>
|
|
||||||
@if (updater.state() === 'downloading') {
|
|
||||||
<span
|
|
||||||
class="absolute inset-y-0 left-0 bg-primary-foreground/25 transition-[width] duration-200"
|
|
||||||
[style.width.%]="updater.progress()"
|
|
||||||
></span>
|
|
||||||
}
|
|
||||||
<span class="relative flex items-center gap-1.5">
|
|
||||||
@switch (updater.state()) {
|
|
||||||
@case ('available') {
|
|
||||||
<ng-icon name="mynaDownload" size=".8rem" />{{ i18n.t('tabBar.update') }}
|
|
||||||
}
|
|
||||||
@case ('downloading') {
|
|
||||||
<ng-icon name="mynaSpinner" size=".8rem" class="animate-spin" />{{ i18n.t('tabBar.updating') }} {{ updater.progress() }}%
|
|
||||||
}
|
|
||||||
@case ('ready') {
|
|
||||||
<ng-icon name="mynaRefresh" size=".8rem" />{{ i18n.t('tabBar.restartToUpdate') }}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- connection manager (own native window) -->
|
|
||||||
<button
|
|
||||||
(click)="openManager()"
|
|
||||||
[title]="i18n.t('tabBar.manageConnections')"
|
|
||||||
class="grid h-6 px-2 py-1 place-items-center rounded-md text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<!-- <ng-icon name="mynaCog" size="0.9rem" /> -->
|
|
||||||
{{ i18n.t('tabBar.connections') }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- global options -->
|
|
||||||
<button
|
|
||||||
(click)="settingsOpen.set(!settingsOpen())"
|
|
||||||
[title]="i18n.t('tabBar.options')"
|
|
||||||
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaDots" size="1.5rem" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
@if (settingsOpen()) {
|
|
||||||
<div class="fixed inset-0 z-20" (click)="settingsOpen.set(false)"></div>
|
|
||||||
<div
|
|
||||||
class="absolute top-full right-1.5 z-30 mt-1 w-70 rounded-lg border border-border bg-card p-3 text-card-foreground shadow-xl electrobun-webkit-app-region-no-drag"
|
|
||||||
>
|
|
||||||
<h3 class="text-xs font-semibold">{{ i18n.t('options.title') }}</h3>
|
|
||||||
|
|
||||||
<div class="mt-3 flex items-center justify-between gap-2">
|
|
||||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.language') }}</span>
|
|
||||||
<select
|
|
||||||
(change)="settings.setLocale($any($event.target).value)"
|
|
||||||
class="h-7 rounded-md border border-input bg-background px-1.5 text-[0.6875rem] outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
>
|
|
||||||
<option value="system" [selected]="settings.locale() === 'system'">
|
|
||||||
{{ i18n.t('options.system') }}
|
|
||||||
</option>
|
|
||||||
@for (loc of localeOptions; track loc) {
|
|
||||||
<option [value]="loc" [selected]="settings.locale() === loc">
|
|
||||||
{{ localeLabels[loc] }}
|
|
||||||
</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Skin: a LAUNCH-TIME choice. It swaps the whole component tree
|
|
||||||
(ui/src/app/skins/) and the native frame, and neither can change
|
|
||||||
on a live window — the tree is bootstrapped from the preload-baked
|
|
||||||
__APP_SKIN and titleBarStyle/transparent are BrowserWindow
|
|
||||||
constructor options. Clicking only saves; the running UI is left
|
|
||||||
alone. Highlight the SAVED value so the click registers, and show
|
|
||||||
the pending hint whenever it differs from the booted skin. -->
|
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
||||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.skin.label') }}</span>
|
|
||||||
<div class="flex rounded-md border border-border p-0.5">
|
|
||||||
@for (s of skins; track s) {
|
|
||||||
<button
|
|
||||||
(click)="settings.setSkin(s)"
|
|
||||||
[title]="i18n.t('options.skin.restartHint')"
|
|
||||||
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
|
||||||
[class.bg-accent]="settings.skin() === s"
|
|
||||||
[class.text-foreground]="settings.skin() === s"
|
|
||||||
[class.text-muted-foreground]="settings.skin() !== s"
|
|
||||||
>
|
|
||||||
{{ i18n.t('options.skin.' + s) }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@if (skinPending()) {
|
|
||||||
<p class="mt-1 text-right text-[0.625rem] text-muted-foreground">
|
|
||||||
{{ i18n.t('options.skin.restartHint') }}
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
||||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.theme.label') }}</span>
|
|
||||||
<div class="flex rounded-md border border-border p-0.5">
|
|
||||||
@for (theme of themes; track theme) {
|
|
||||||
<button
|
|
||||||
(click)="settings.setTheme(theme)"
|
|
||||||
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
|
||||||
[class.bg-accent]="settings.theme() === theme"
|
|
||||||
[class.text-foreground]="settings.theme() === theme"
|
|
||||||
[class.text-muted-foreground]="settings.theme() !== theme"
|
|
||||||
>
|
|
||||||
{{ i18n.t('options.theme.' + theme) }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (settings.effectiveDark()) {
|
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
||||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.tone.label') }}</span>
|
|
||||||
<div class="flex rounded-md border border-border p-0.5">
|
|
||||||
@for (tone of darkTones; track tone) {
|
|
||||||
<button
|
|
||||||
(click)="settings.setDarkTone(tone)"
|
|
||||||
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
|
||||||
[class.bg-accent]="settings.darkTone() === tone"
|
|
||||||
[class.text-foreground]="settings.darkTone() === tone"
|
|
||||||
[class.text-muted-foreground]="settings.darkTone() !== tone"
|
|
||||||
>
|
|
||||||
{{ i18n.t('options.tone.' + tone) }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
||||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.accent.label') }}</span>
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
@for (tone of accents; track tone.value) {
|
|
||||||
<button
|
|
||||||
(click)="settings.setAccent(tone.value)"
|
|
||||||
[title]="i18n.t('options.accentNames.' + tone.value)"
|
|
||||||
class="size-3 rounded-full border border-foreground/15"
|
|
||||||
[style.background-color]="tone.swatch"
|
|
||||||
[class.ring-2]="settings.accent() === tone.value"
|
|
||||||
[class.ring-foreground]="settings.accent() === tone.value"
|
|
||||||
[class.ring-offset-1]="settings.accent() === tone.value"
|
|
||||||
[class.ring-offset-card]="settings.accent() === tone.value"
|
|
||||||
></button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
||||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.fileIcons.label') }}</span>
|
|
||||||
<div class="flex rounded-md border border-border p-0.5">
|
|
||||||
@for (mode of iconColorModes; track mode) {
|
|
||||||
<button
|
|
||||||
(click)="settings.setIconColors(mode)"
|
|
||||||
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
|
||||||
[class.bg-accent]="settings.iconColors() === mode"
|
|
||||||
[class.text-foreground]="settings.iconColors() === mode"
|
|
||||||
[class.text-muted-foreground]="settings.iconColors() !== mode"
|
|
||||||
>
|
|
||||||
{{ i18n.t('options.fileIcons.' + mode) }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Interface scale: a continuous stepper, 50%..400% in 10-point
|
|
||||||
steps. One lever moves the whole UI (root font-size; everything
|
|
||||||
is rem), which is also why this popover itself grows with it. -->
|
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
||||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.interface.label') }}</span>
|
|
||||||
<div class="flex items-center rounded-md border border-border p-0.5">
|
|
||||||
<button
|
|
||||||
(click)="settings.setUiScale(settings.uiScale() - 0.1)"
|
|
||||||
[disabled]="settings.uiScale() <= 0.5"
|
|
||||||
[title]="i18n.t('options.interface.smaller')"
|
|
||||||
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
|
|
||||||
>
|
|
||||||
−
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="settings.setUiScale(1)"
|
|
||||||
[title]="i18n.t('options.interface.reset')"
|
|
||||||
class="w-11 rounded px-1 py-0.5 text-center text-[0.6875rem] tabular-nums hover:bg-accent"
|
|
||||||
>
|
|
||||||
{{ scalePercent() }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="settings.setUiScale(settings.uiScale() + 0.1)"
|
|
||||||
[disabled]="settings.uiScale() >= 4"
|
|
||||||
[title]="i18n.t('options.interface.larger')"
|
|
||||||
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
||||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.speedUnit.label') }}</span>
|
|
||||||
<div class="flex rounded-md border border-border p-0.5">
|
|
||||||
@for (unit of speedUnits; track unit.value) {
|
|
||||||
<button
|
|
||||||
(click)="settings.setSpeedUnit(unit.value)"
|
|
||||||
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
|
||||||
[class.bg-accent]="settings.speedUnit() === unit.value"
|
|
||||||
[class.text-foreground]="settings.speedUnit() === unit.value"
|
|
||||||
[class.text-muted-foreground]="settings.speedUnit() !== unit.value"
|
|
||||||
>
|
|
||||||
{{ unit.label }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
||||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.transfers.label') }}</span>
|
|
||||||
<div class="flex rounded-md border border-border p-0.5">
|
|
||||||
@for (mode of transferModes; track mode) {
|
|
||||||
<button
|
|
||||||
(click)="settings.setTransferMode(mode)"
|
|
||||||
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
|
||||||
[class.bg-accent]="settings.transferMode() === mode"
|
|
||||||
[class.text-foreground]="settings.transferMode() === mode"
|
|
||||||
[class.text-muted-foreground]="settings.transferMode() !== mode"
|
|
||||||
>
|
|
||||||
{{ i18n.t('options.transfers.' + mode) }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (settings.transferMode() === 'multi') {
|
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2 pl-3">
|
|
||||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.atOnce') }}</span>
|
|
||||||
<div class="flex rounded-md border border-border p-0.5">
|
|
||||||
@for (count of concurrencyChoices; track count) {
|
|
||||||
<button
|
|
||||||
(click)="settings.setTransferConcurrency(count)"
|
|
||||||
class="rounded px-2 py-0.5 text-[0.6875rem] tabular-nums"
|
|
||||||
[class.bg-accent]="settings.transferConcurrency() === count"
|
|
||||||
[class.text-foreground]="settings.transferConcurrency() === count"
|
|
||||||
[class.text-muted-foreground]="settings.transferConcurrency() !== count"
|
|
||||||
>
|
|
||||||
{{ count }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
||||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.editor.label') }}</span>
|
|
||||||
<div class="flex rounded-md border border-border p-0.5">
|
|
||||||
<button
|
|
||||||
(click)="settings.setEditor('builtin')"
|
|
||||||
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
|
||||||
[class.bg-accent]="settings.editor() === 'builtin'"
|
|
||||||
[class.text-foreground]="settings.editor() === 'builtin'"
|
|
||||||
[class.text-muted-foreground]="settings.editor() !== 'builtin'"
|
|
||||||
>
|
|
||||||
{{ i18n.t('options.editor.builtin') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="settings.setEditor('custom')"
|
|
||||||
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
|
||||||
[class.bg-accent]="settings.editor() === 'custom'"
|
|
||||||
[class.text-foreground]="settings.editor() === 'custom'"
|
|
||||||
[class.text-muted-foreground]="settings.editor() !== 'custom'"
|
|
||||||
>
|
|
||||||
{{ i18n.t('options.editor.custom') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (settings.editor() === 'custom') {
|
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2 pl-3">
|
|
||||||
<span class="min-w-0 truncate text-xs text-muted-foreground" [title]="settings.editorApp()">
|
|
||||||
{{ editorAppName() }}
|
|
||||||
</span>
|
|
||||||
<div class="flex shrink-0 items-center gap-1">
|
|
||||||
<button
|
|
||||||
(click)="pickEditorApp()"
|
|
||||||
class="rounded-md border border-border px-2 py-0.5 text-[0.6875rem] hover:bg-accent"
|
|
||||||
>
|
|
||||||
{{ i18n.t('common.choose') }}
|
|
||||||
</button>
|
|
||||||
@if (settings.editorApp()) {
|
|
||||||
<button
|
|
||||||
(click)="settings.setEditorApp('')"
|
|
||||||
[title]="i18n.t('options.resetDefault')"
|
|
||||||
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaX" size="0.7rem" />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
||||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.finishedTransfers.label') }}</span>
|
|
||||||
<div class="flex rounded-md border border-border p-0.5">
|
|
||||||
<button
|
|
||||||
(click)="settings.setAutoClearCompleted(true)"
|
|
||||||
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
|
||||||
[class.bg-accent]="settings.autoClearCompleted()"
|
|
||||||
[class.text-foreground]="settings.autoClearCompleted()"
|
|
||||||
[class.text-muted-foreground]="!settings.autoClearCompleted()"
|
|
||||||
>
|
|
||||||
{{ i18n.t('options.finishedTransfers.autoClear') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="settings.setAutoClearCompleted(false)"
|
|
||||||
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
|
||||||
[class.bg-accent]="!settings.autoClearCompleted()"
|
|
||||||
[class.text-foreground]="!settings.autoClearCompleted()"
|
|
||||||
[class.text-muted-foreground]="settings.autoClearCompleted()"
|
|
||||||
>
|
|
||||||
{{ i18n.t('options.finishedTransfers.keep') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Auto-refresh is always on for now. To expose a toggle, uncomment
|
|
||||||
the breadcrumbs in types.ts + settings.service, then this row:
|
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
||||||
<span class="text-xs text-muted-foreground">Auto-refresh</span>
|
|
||||||
<div class="flex rounded-md border border-border p-0.5">
|
|
||||||
<button (click)="settings.setAutoRefresh(true)" class="rounded px-2 py-0.5 text-[0.6875rem]"
|
|
||||||
[class.bg-accent]="settings.autoRefresh()"
|
|
||||||
[class.text-foreground]="settings.autoRefresh()"
|
|
||||||
[class.text-muted-foreground]="!settings.autoRefresh()">On</button>
|
|
||||||
<button (click)="settings.setAutoRefresh(false)" class="rounded px-2 py-0.5 text-[0.6875rem]"
|
|
||||||
[class.bg-accent]="!settings.autoRefresh()"
|
|
||||||
[class.text-foreground]="!settings.autoRefresh()"
|
|
||||||
[class.text-muted-foreground]="settings.autoRefresh()">Off</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
-->
|
|
||||||
|
|
||||||
<!-- about footer -->
|
|
||||||
<button
|
|
||||||
(click)="showAbout()"
|
|
||||||
class="mt-3 w-full border-t border-border pt-2 text-center text-[0.6875rem] text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
{{ appName }} · {{ buildLabel }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class TabBar {
|
|
||||||
readonly panels = inject(PanelsService);
|
|
||||||
readonly settings = inject(SettingsService);
|
|
||||||
readonly i18n = inject(I18nService);
|
|
||||||
readonly updater = inject(UpdaterService);
|
|
||||||
private readonly contextMenu = inject(ContextMenuService);
|
|
||||||
private readonly rpc = inject(RpcService);
|
|
||||||
readonly settingsOpen = signal(false);
|
|
||||||
readonly localeOptions = SUPPORTED;
|
|
||||||
readonly localeLabels = LOCALE_LABELS;
|
|
||||||
readonly modLabel = MOD_LABEL;
|
|
||||||
readonly appName = BRANDING.appName;
|
|
||||||
readonly buildLabel = BRANDING.buildLabel;
|
|
||||||
/** What the window was BUILT with, not the current setting — the frameless
|
|
||||||
* chrome only changes on reopen, so the hint compares against this. */
|
|
||||||
readonly skins = SKINS;
|
|
||||||
/**
|
|
||||||
* The saved skin differs from the one this window actually booted, so the
|
|
||||||
* choice is waiting on a restart. APP_SKIN is the preload-baked value that
|
|
||||||
* picked the running component tree; settings.skin() is merely persisted.
|
|
||||||
*/
|
|
||||||
readonly skinPending = computed(() => this.settings.skin() !== APP_SKIN);
|
|
||||||
readonly themes = THEMES;
|
|
||||||
readonly darkTones = DARK_TONES;
|
|
||||||
readonly accents = ACCENTS;
|
|
||||||
readonly iconColorModes = ICON_COLOR_MODES;
|
|
||||||
readonly speedUnits = SPEED_UNITS;
|
|
||||||
/** "100%" for the stepper readout; the value itself lives in settings. */
|
|
||||||
readonly scalePercent = computed(() => `${Math.round(this.settings.uiScale() * 100)}%`);
|
|
||||||
readonly transferModes = TRANSFER_MODES;
|
|
||||||
readonly concurrencyChoices = CONCURRENCY_CHOICES;
|
|
||||||
|
|
||||||
// drag & drop reordering
|
|
||||||
readonly draggedId = signal<number | null>(null);
|
|
||||||
readonly dropTarget = signal<{ index: number; before: boolean } | null>(null);
|
|
||||||
|
|
||||||
private readonly tabScroll = viewChild.required<ElementRef<HTMLMenuElement>>('tabScroll');
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// keep the active tab visible when the strip overflows
|
|
||||||
effect(() => {
|
|
||||||
const id = this.panels.activeWorkspaceId();
|
|
||||||
this.tabScroll()
|
|
||||||
.nativeElement.querySelector(`[data-tab-id="${id}"]`)
|
|
||||||
?.scrollIntoView({ inline: 'nearest', block: 'nearest' });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
close(event: MouseEvent, id: number): void {
|
|
||||||
event.stopPropagation();
|
|
||||||
this.panels.closeWorkspace(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Middle-click closes the tab — the pointer path that replaced the per-tab ✕.
|
|
||||||
* `auxclick` is the non-primary-button click event; guard on button 1 so the
|
|
||||||
* right button (which also fires auxclick) never closes anything. Refuses the
|
|
||||||
* last tab, matching closeWorkspace's own floor.
|
|
||||||
*/
|
|
||||||
onAuxClick(event: MouseEvent, id: number): void {
|
|
||||||
if (event.button !== 1) return;
|
|
||||||
event.preventDefault();
|
|
||||||
if (this.panels.workspaces().length > 1) this.close(event, id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Right-click a tab. The menu targets THIS tab, not the active one — its
|
|
||||||
* actions all carry the id — so right-clicking a background tab does not
|
|
||||||
* yank focus to it first, the way Explorer and every browser behave.
|
|
||||||
*/
|
|
||||||
onTabContextMenu(event: MouseEvent, workspace: Workspace, index: number): void {
|
|
||||||
event.preventDefault();
|
|
||||||
this.contextMenu.open(
|
|
||||||
event,
|
|
||||||
this.contextMenu.tabItems({
|
|
||||||
id: workspace.id,
|
|
||||||
index,
|
|
||||||
tabCount: this.panels.workspaces().length,
|
|
||||||
isRemote: this.panels.workspaceIsRemote(workspace),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */
|
|
||||||
editorAppName(): string {
|
|
||||||
const path = this.settings.editorApp();
|
|
||||||
if (!path) return this.i18n.t('options.systemDefault');
|
|
||||||
const base = path.split('/').filter(Boolean).pop() ?? path;
|
|
||||||
return base.replace(/\.app$/i, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
openManager(): void {
|
|
||||||
void this.rpc.request.openConnectionManager({});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Update pill: first click downloads, second click (when ready) applies + relaunches. */
|
|
||||||
onUpdateClick(): void {
|
|
||||||
const state = this.updater.state();
|
|
||||||
if (state === 'available') void this.updater.download();
|
|
||||||
else if (state === 'ready') this.updater.apply();
|
|
||||||
}
|
|
||||||
|
|
||||||
showAbout(): void {
|
|
||||||
void this.rpc.request.showAbout({});
|
|
||||||
}
|
|
||||||
|
|
||||||
async pickEditorApp(): Promise<void> {
|
|
||||||
const res = await this.rpc.request.pickEditorApp({});
|
|
||||||
if (res.ok && res.path) this.settings.setEditorApp(res.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Insertion line at the drop position (inset shadow: no layout shift). */
|
|
||||||
dropIndicatorShadow(index: number): string | null {
|
|
||||||
const target = this.dropTarget();
|
|
||||||
if (!target || target.index !== index || this.draggedId() === null) return null;
|
|
||||||
return target.before
|
|
||||||
? 'inset 2px 0 0 var(--primary)'
|
|
||||||
: 'inset -2px 0 0 var(--primary)';
|
|
||||||
}
|
|
||||||
|
|
||||||
onDragStart(event: DragEvent, id: number): void {
|
|
||||||
this.draggedId.set(id);
|
|
||||||
// WebKit refuses to start a drag without data
|
|
||||||
event.dataTransfer?.setData('text/plain', String(id));
|
|
||||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
|
||||||
}
|
|
||||||
|
|
||||||
onDragOver(event: DragEvent, index: number): void {
|
|
||||||
if (this.draggedId() === null) return;
|
|
||||||
event.preventDefault();
|
|
||||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
|
||||||
const el = event.currentTarget as HTMLElement;
|
|
||||||
const before = event.clientX < el.getBoundingClientRect().left + el.offsetWidth / 2;
|
|
||||||
this.dropTarget.set({ index, before });
|
|
||||||
}
|
|
||||||
|
|
||||||
onDrop(event: DragEvent, index: number): void {
|
|
||||||
event.preventDefault();
|
|
||||||
const id = this.draggedId();
|
|
||||||
const target = this.dropTarget();
|
|
||||||
this.onDragEnd();
|
|
||||||
if (id === null || !target) return;
|
|
||||||
const list = this.panels.workspaces();
|
|
||||||
const fromIndex = list.findIndex((w) => w.id === id);
|
|
||||||
if (fromIndex < 0) return;
|
|
||||||
let toIndex = target.before ? index : index + 1;
|
|
||||||
if (fromIndex < toIndex) toIndex--; // account for removal before insert
|
|
||||||
if (toIndex !== fromIndex) this.panels.moveWorkspace(id, toIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
onDragEnd(): void {
|
|
||||||
this.draggedId.set(null);
|
|
||||||
this.dropTarget.set(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { Component, inject, input, output, signal } from '@angular/core';
|
|
||||||
import { RpcService } from '@core/rpc.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Aero title bar. Rendered by all three window kinds. The native frame is
|
|
||||||
* gone on every platform (titleBarStyle 'hidden'), so this row IS the window's
|
|
||||||
* chrome.
|
|
||||||
*
|
|
||||||
* The markup is 7.css's contract, not ours: `.title-bar` > `.title-bar-text` +
|
|
||||||
* `.title-bar-controls` > buttons keyed by `aria-label` (Minimize / Maximize /
|
|
||||||
* Restore / Close each get their own glyph). Nothing here is styled with
|
|
||||||
* Tailwind on purpose — the whole row comes from @layer skin.
|
|
||||||
*
|
|
||||||
* `.title-bar` lives on the HOST element, not on an inner div. 7.css couples
|
|
||||||
* the glass treatment with a DIRECT-child selector — `.window.glass >
|
|
||||||
* .title-bar` — and an inner div puts <app-title-bar> in between, so the rule
|
|
||||||
* never matched: the title bar rendered as the opaque fallback with its own
|
|
||||||
* border, visibly disconnected from the frame, instead of the translucent
|
|
||||||
* streaked glass that fuses with it.
|
|
||||||
*
|
|
||||||
* Dragging rides on electrobun's preload drag-region classes. Two things
|
|
||||||
* electrobun does NOT provide and this component implements:
|
|
||||||
* - double-click to maximize (it only wires mousedown/mouseup)
|
|
||||||
* - the maximize/restore glyph state, which has no event; bun derives it
|
|
||||||
* from the native resize and pushes `windowState` (see trackMaximizeState).
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'app-title-bar',
|
|
||||||
host: {
|
|
||||||
class: 'title-bar electrobun-webkit-app-region-drag',
|
|
||||||
// Published so the FRAME can react: a maximized Win7 window has square
|
|
||||||
// corners. The window root reads this off its own child via :has() rather
|
|
||||||
// than subscribing to windowState a second time — see styles.css.
|
|
||||||
'[class.is-maximized]': 'maximized()',
|
|
||||||
'(dblclick)': 'toggle()',
|
|
||||||
},
|
|
||||||
template: `
|
|
||||||
<div class="title-bar-text">{{ title() }}</div>
|
|
||||||
<div class="title-bar-controls electrobun-webkit-app-region-no-drag">
|
|
||||||
@if (resizable()) {
|
|
||||||
<button aria-label="Minimize" (click)="minimize()"></button>
|
|
||||||
<button
|
|
||||||
[attr.aria-label]="maximized() ? 'Restore' : 'Maximize'"
|
|
||||||
(click)="toggle()"
|
|
||||||
></button>
|
|
||||||
}
|
|
||||||
<button aria-label="Close" (click)="closeRequested.emit()"></button>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class TitleBar {
|
|
||||||
readonly title = input('');
|
|
||||||
/** The manager window is a fixed-size modal — it gets Close only. */
|
|
||||||
readonly resizable = input(true);
|
|
||||||
/** Hosts decide what closing means (quit / dirty prompt / managerClose). */
|
|
||||||
readonly closeRequested = output<void>();
|
|
||||||
|
|
||||||
protected readonly maximized = signal(false);
|
|
||||||
|
|
||||||
private readonly rpc = inject(RpcService);
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
if (!this.rpc.available) return;
|
|
||||||
// No maximize event exists, so seed from a one-shot read and then follow
|
|
||||||
// the resize-derived push (which also covers Aero Snap and Win+Up).
|
|
||||||
void this.rpc.request.windowIsMaximized({}).then((res) => {
|
|
||||||
if (res.ok) this.maximized.set(res.maximized);
|
|
||||||
});
|
|
||||||
this.rpc.onWindowState((maximized) => this.maximized.set(maximized));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected minimize(): void {
|
|
||||||
void this.rpc.request.windowMinimize({});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected toggle(): void {
|
|
||||||
void this.rpc.request.windowToggleMaximize({}).then((res) => {
|
|
||||||
if (res.ok) this.maximized.set(res.maximized);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
|
||||||
import { NgIcon } from '@ng-icons/core';
|
|
||||||
import { ToastService } from '@core/toast.service';
|
|
||||||
|
|
||||||
/** Toast stack overlay; shared by the main window and editor windows. */
|
|
||||||
@Component({
|
|
||||||
selector: 'app-toasts',
|
|
||||||
imports: [NgIcon],
|
|
||||||
template: `
|
|
||||||
<div class="pointer-events-none fixed right-3 bottom-8 z-50 flex w-80 flex-col gap-2">
|
|
||||||
@for (t of toast.toasts(); track t.id) {
|
|
||||||
<div
|
|
||||||
(mouseenter)="toast.pause(t.id)"
|
|
||||||
(mouseleave)="toast.resume(t.id)"
|
|
||||||
class="group pointer-events-auto relative flex items-start gap-2 overflow-hidden rounded-lg border border-border bg-card p-3 text-xs text-card-foreground shadow-lg"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
[name]="toastIcon(t.kind)"
|
|
||||||
size="1rem"
|
|
||||||
[class]="t.kind === 'error' ? 'shrink-0 text-destructive' : 'shrink-0 text-success'"
|
|
||||||
/>
|
|
||||||
<span class="min-w-0 flex-1 wrap-break-word">{{ t.text }}</span>
|
|
||||||
<button
|
|
||||||
(click)="toast.dismiss(t.id)"
|
|
||||||
class="shrink-0 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaX" size="0.9rem" />
|
|
||||||
</button>
|
|
||||||
<!-- auto-dismiss countdown; freezes with the timer while hovered -->
|
|
||||||
<div
|
|
||||||
class="toast-countdown absolute inset-x-0 bottom-0 h-0.5 origin-left"
|
|
||||||
[class.bg-destructive]="t.kind === 'error'"
|
|
||||||
[class.bg-success]="t.kind === 'success'"
|
|
||||||
[class.bg-primary]="t.kind === 'info'"
|
|
||||||
[style.animation-duration.ms]="t.ttlMs"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class Toasts {
|
|
||||||
readonly toast = inject(ToastService);
|
|
||||||
|
|
||||||
toastIcon(kind: 'error' | 'info' | 'success'): string {
|
|
||||||
if (kind === 'error') return 'mynaXCircle';
|
|
||||||
if (kind === 'success') return 'mynaCheckCircle';
|
|
||||||
return 'mynaCheck';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
@if (transfers.expanded() && transfers.hasJobs()) {
|
|
||||||
<div class="flex items-center justify-between border-t border-border bg-chrome px-3 py-1 text-[0.6875rem] text-muted-foreground">
|
|
||||||
<span>
|
|
||||||
{{ i18n.t('transfer.summary', { active: transfers.activeJobs().length, finished: transfers.finishedJobs().length }) }}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
(click)="transfers.clearFinished()"
|
|
||||||
[disabled]="!transfers.finishedJobs().length"
|
|
||||||
class="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaTrash" size="0.75rem" /> {{ i18n.t('transfer.clearFinished') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="has-scrollbar max-h-40 overflow-y-auto border-t border-border bg-chrome">
|
|
||||||
@for (job of transfers.jobs(); track job.id) {
|
|
||||||
<div class="flex items-center gap-2 px-3 py-1.5 text-xs">
|
|
||||||
<ng-icon [name]="stateIcon(job)" size="0.95rem" [class]="stateClass(job)" />
|
|
||||||
<span class="w-40 shrink-0 truncate font-medium" [title]="job.srcPath">{{ job.name }}</span>
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="truncate text-[0.6875rem] text-muted-foreground">{{ progressLabel(job) }}</div>
|
|
||||||
@if (isActive(job)) {
|
|
||||||
<!-- role+aria are 7.css's hook for the Win7 progress bar
|
|
||||||
([role=progressbar] > div); under the modern skin they are
|
|
||||||
inert and the Tailwind classes below still draw the bar. -->
|
|
||||||
<div
|
|
||||||
role="progressbar"
|
|
||||||
aria-valuemin="0"
|
|
||||||
aria-valuemax="100"
|
|
||||||
[attr.aria-valuenow]="jobPercent(job)"
|
|
||||||
class="mt-0.5 h-1 overflow-hidden rounded-full bg-muted"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="h-full rounded-full bg-primary transition-[width] duration-100"
|
|
||||||
[style.width.%]="jobPercent(job)"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@if (isActive(job)) {
|
|
||||||
<button
|
|
||||||
(click)="transfers.cancel(job.id)"
|
|
||||||
[title]="i18n.t('transfer.cancel')"
|
|
||||||
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-destructive"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaX" size="0.9rem" />
|
|
||||||
</button>
|
|
||||||
} @else {
|
|
||||||
<button
|
|
||||||
(click)="transfers.dismiss(job.id)"
|
|
||||||
[title]="i18n.t('transfer.dismiss')"
|
|
||||||
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaX" size="0.9rem" />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
|
||||||
import { NgIcon } from '@ng-icons/core';
|
|
||||||
import type { TransferSnapshot } from '@shared/types';
|
|
||||||
import { formatEta } from '@core/format';
|
|
||||||
import { I18nService } from '@core/i18n.service';
|
|
||||||
import { SettingsService } from '@core/settings.service';
|
|
||||||
import { TransfersService } from '@core/transfers.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-transfer-bar',
|
|
||||||
imports: [NgIcon],
|
|
||||||
templateUrl: './transfer-bar.html',
|
|
||||||
})
|
|
||||||
export class TransferBar {
|
|
||||||
readonly i18n = inject(I18nService);
|
|
||||||
readonly transfers = inject(TransfersService);
|
|
||||||
readonly settings = inject(SettingsService);
|
|
||||||
readonly formatSize = (bytes: number): string => this.i18n.size(bytes);
|
|
||||||
|
|
||||||
/** "14.2 MB/s · 0:42 left" while a job is moving bytes. */
|
|
||||||
speedLabel(job: TransferSnapshot): string {
|
|
||||||
if (job.state !== 'running') return '';
|
|
||||||
const speed = this.transfers.speeds().get(job.id);
|
|
||||||
if (!speed) return '';
|
|
||||||
const parts = [this.i18n.speed(speed, this.settings.speedUnit())];
|
|
||||||
const remaining = job.totalBytes - job.doneBytes;
|
|
||||||
if (remaining > 0 && speed > 0) {
|
|
||||||
parts.push(this.i18n.t('transfer.left', { eta: formatEta(remaining / speed) }));
|
|
||||||
}
|
|
||||||
return parts.join(' · ');
|
|
||||||
}
|
|
||||||
|
|
||||||
stateIcon(job: TransferSnapshot): string {
|
|
||||||
switch (job.state) {
|
|
||||||
case 'completed':
|
|
||||||
return 'mynaCheckCircle';
|
|
||||||
case 'failed':
|
|
||||||
case 'cancelled':
|
|
||||||
return 'mynaXCircle';
|
|
||||||
default:
|
|
||||||
return 'mynaSpinner';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stateClass(job: TransferSnapshot): string {
|
|
||||||
switch (job.state) {
|
|
||||||
case 'completed':
|
|
||||||
return 'shrink-0 text-success';
|
|
||||||
case 'failed':
|
|
||||||
return 'shrink-0 text-destructive';
|
|
||||||
case 'cancelled':
|
|
||||||
return 'shrink-0 text-muted-foreground';
|
|
||||||
default:
|
|
||||||
return 'shrink-0 animate-spin text-primary';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isActive(job: TransferSnapshot): boolean {
|
|
||||||
return job.state === 'queued' || job.state === 'scanning' || job.state === 'running';
|
|
||||||
}
|
|
||||||
|
|
||||||
jobPercent(job: TransferSnapshot): number {
|
|
||||||
if (!job.totalBytes) return job.state === 'completed' ? 100 : 0;
|
|
||||||
return Math.min(100, (job.doneBytes / job.totalBytes) * 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
progressLabel(job: TransferSnapshot): string {
|
|
||||||
if (job.state === 'queued') return this.i18n.t('transfer.queuedState');
|
|
||||||
if (job.state === 'scanning') return this.i18n.t('transfer.scanning');
|
|
||||||
if (job.state === 'failed')
|
|
||||||
return `${job.currentFile ? `${job.currentFile} — ` : ''}${job.error ?? this.i18n.t('transfer.failedState')}`;
|
|
||||||
if (job.state === 'cancelled') return this.i18n.t('transfer.cancelled');
|
|
||||||
if (job.state === 'completed')
|
|
||||||
return this.i18n.t('transfer.completedFiles', {
|
|
||||||
count: job.doneFiles,
|
|
||||||
size: this.i18n.size(job.doneBytes),
|
|
||||||
});
|
|
||||||
const speed = this.speedLabel(job);
|
|
||||||
const base = `${job.currentFile} · ${this.i18n.size(job.doneBytes)} / ${this.i18n.size(job.totalBytes)}`;
|
|
||||||
return speed ? `${base} · ${speed}` : base;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
/**
|
|
||||||
* Frame arithmetic for the synthesized window resize. Deliberately a separate,
|
|
||||||
* DEPENDENCY-FREE module: `scripts/resize-math-probe.ts` imports it directly to
|
|
||||||
* assert all eight edges without booting Angular or opening a window, which is
|
|
||||||
* the only automated coverage this feature can have (pointer drags cannot be
|
|
||||||
* synthesized headlessly on this machine).
|
|
||||||
*
|
|
||||||
* Part of the resize stopgap — see the removal checklist in window-resize.ts.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface Frame {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compass point of the grabbed edge or corner. */
|
|
||||||
export type ResizeEdge = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every edge the arithmetic supports — NOT what the UI mounts. The window
|
|
||||||
* currently offers only the bottom-right grip (`window-resize.ts` explains why
|
|
||||||
* the others were removed: an invisible handle over a caption button steals its
|
|
||||||
* hover). This stays complete because the origin-moving cases are the subtle
|
|
||||||
* ones and the probe is where they are proven; restoring a side is then one
|
|
||||||
* entry in the component, against already-tested math.
|
|
||||||
*/
|
|
||||||
export const RESIZE_EDGES: readonly ResizeEdge[] = ['n', 's', 'w', 'e', 'nw', 'ne', 'sw', 'se'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Floor for the synthesized resize. electrobun has no min-size window option,
|
|
||||||
* so this is enforced here or not at all. Chosen to keep both panels usable
|
|
||||||
* with their toolbars intact; the interface scale can still make the CONTENT
|
|
||||||
* too big for it, which is a separate (pre-existing) problem.
|
|
||||||
*/
|
|
||||||
export const MIN_WIDTH = 640;
|
|
||||||
export const MIN_HEIGHT = 420;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The frame a drag should produce.
|
|
||||||
*
|
|
||||||
* `dx`/`dy` are SCREEN-space deltas from where the drag began (see the note on
|
|
||||||
* screenX in window-resize.ts). North and west edges move the window's origin
|
|
||||||
* as well as its size — that is the whole reason this returns a full frame
|
|
||||||
* rather than a size, and why `setSize` alone would be wrong.
|
|
||||||
*
|
|
||||||
* The clamp holds the ANCHORED edge still: dragging the west edge east past the
|
|
||||||
* minimum must park `x` where the window is exactly MIN_WIDTH wide, not let it
|
|
||||||
* keep travelling while the width sits pinned — that would walk the window
|
|
||||||
* across the screen while it appears frozen.
|
|
||||||
*
|
|
||||||
* Results are rounded: fractional frames make the native setter jitter by a
|
|
||||||
* pixel between ticks.
|
|
||||||
*/
|
|
||||||
export function resizeFrame(
|
|
||||||
edge: ResizeEdge,
|
|
||||||
start: Frame,
|
|
||||||
dx: number,
|
|
||||||
dy: number,
|
|
||||||
min: { width: number; height: number } = { width: MIN_WIDTH, height: MIN_HEIGHT },
|
|
||||||
): Frame {
|
|
||||||
let { x, y, width, height } = start;
|
|
||||||
|
|
||||||
if (edge.includes('e')) width = start.width + dx;
|
|
||||||
if (edge.includes('w')) {
|
|
||||||
width = start.width - dx;
|
|
||||||
x = start.x + dx;
|
|
||||||
}
|
|
||||||
if (edge.includes('s')) height = start.height + dy;
|
|
||||||
if (edge.includes('n')) {
|
|
||||||
height = start.height - dy;
|
|
||||||
y = start.y + dy;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (width < min.width) {
|
|
||||||
// west drag: the east edge is the anchor, so x is derived from it
|
|
||||||
if (edge.includes('w')) x = start.x + start.width - min.width;
|
|
||||||
width = min.width;
|
|
||||||
}
|
|
||||||
if (height < min.height) {
|
|
||||||
if (edge.includes('n')) y = start.y + start.height - min.height;
|
|
||||||
height = min.height;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: Math.round(x),
|
|
||||||
y: Math.round(y),
|
|
||||||
width: Math.round(width),
|
|
||||||
height: Math.round(height),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
|
||||||
import { RpcService } from '@core/rpc.service';
|
|
||||||
import { type Frame, type ResizeEdge, resizeFrame } from './resize-math';
|
|
||||||
|
|
||||||
/* ============================================================================
|
|
||||||
* ⚠️ STOPGAP — DELETE THIS WHOLE FEATURE WHEN ELECTROBUN SHIPS WINDOW RESIZE
|
|
||||||
* ============================================================================
|
|
||||||
*
|
|
||||||
* WHY IT EXISTS
|
|
||||||
*
|
|
||||||
* The win7 skin is frameless: `chromeOptions()` (src/bun/windows.ts) passes
|
|
||||||
* `titleBarStyle: "hidden"` so 7.css can draw the Aero caption itself, and on
|
|
||||||
* Linux the GTK wrapper honours that with `gtk_window_set_decorated(false)`.
|
|
||||||
* Undecorating the window also removes the window manager's resize borders and
|
|
||||||
* corner grips — so all three win7 windows became completely unresizable, on
|
|
||||||
* every platform. (The modern skin keeps the native frame and is unaffected;
|
|
||||||
* it must never mount this component.)
|
|
||||||
*
|
|
||||||
* Electrobun 1.18.1 offers nothing to re-enable. Verified against the SHIPPED
|
|
||||||
* binary rather than assumed:
|
|
||||||
*
|
|
||||||
* - libNativeWrapper.so exports `startWindowMove` / `stopWindowMove` and no
|
|
||||||
* resize counterpart whatsoever. Its only `resize*` exports are
|
|
||||||
* `resizeWebview` / `resizeAutoSizingWebviewsInWindow`, internal webview
|
|
||||||
* plumbing unrelated to the window frame.
|
|
||||||
* - It imports `gtk_window_begin_move_drag` but NOT
|
|
||||||
* `gtk_window_begin_resize_drag`, and not `gtk_window_set_resizable`.
|
|
||||||
* - `dist/api/bun/preload/dragRegions.ts` implements moving only: there is no
|
|
||||||
* `app-region: resize`, no per-edge variant.
|
|
||||||
* - `BrowserWindow` has no `setResizable`, and its constructor options carry
|
|
||||||
* no `resizable` / `minWidth` / `minHeight`.
|
|
||||||
*
|
|
||||||
* The window is still PROGRAMMATICALLY resizable — nothing ever calls
|
|
||||||
* `gtk_window_set_resizable(false)`, and `setFrame`/`getFrame` work. So only
|
|
||||||
* the input affordance is missing, and that is exactly what this synthesizes:
|
|
||||||
* a bottom-right grip → pointer drag → `windowSetFrame` RPC →
|
|
||||||
* `BrowserWindow.setFrame`. One corner, not eight handles — see GRIP_BOX for
|
|
||||||
* why the others had to go.
|
|
||||||
*
|
|
||||||
* WHAT IT COSTS (i.e. why it should die as soon as it can)
|
|
||||||
*
|
|
||||||
* Every drag tick is an RPC hop plus a synchronous FFI call, where a real WM
|
|
||||||
* grab is a compositor-side operation — so it is jerkier than a native resize
|
|
||||||
* and always will be. Minimum size is enforced in JS because electrobun has no
|
|
||||||
* min-size option. The grip occupies real estate INSIDE the window that a
|
|
||||||
* native frame would have provided outside it — which is also why there is only
|
|
||||||
* one of them: an invisible handle over a control steals that control's hover
|
|
||||||
* (see GRIP_BOX). A real WM resize border has neither problem.
|
|
||||||
*
|
|
||||||
* ────────────────────────────────────────────────────────────────────────────
|
|
||||||
* REMOVAL CHECKLIST — when upstream ships a resize gesture, delete ALL of:
|
|
||||||
*
|
|
||||||
* 1. this directory (window-resize.ts + resize-math.ts)
|
|
||||||
* 2. its three mount points: `<app-window-resize />` and the `WindowResize`
|
|
||||||
* entry in `imports:` for skins/7like/{app, connection-manager/
|
|
||||||
* connection-manager, editor-window/editor-window}
|
|
||||||
* 3. the `.w7-resize` rule in skins/7like/7like.css (@layer skinfix)
|
|
||||||
* 4. `windowGetFrame` / `windowSetFrame` in src/shared/rpc.ts
|
|
||||||
* 5. their handlers in `windowControlHandlers` (src/bun/rpc-handlers.ts)
|
|
||||||
* 6. scripts/check-electrobun-resize.ts and scripts/resize-math-probe.ts
|
|
||||||
* 7. the check-script call in package.json's `postinstall`
|
|
||||||
* 8. the resize paragraphs in docs/{gotchas,platform-notes,architecture}.md
|
|
||||||
*
|
|
||||||
* Then adopt whatever upstream provides — most likely a `no-drag`-style CSS
|
|
||||||
* class or a `startWindowResize(edge)` preload call, which needs handles for
|
|
||||||
* hit-testing but none of the frame arithmetic below.
|
|
||||||
*
|
|
||||||
* HOW WE FIND OUT: `scripts/check-electrobun-resize.ts` inspects the installed
|
|
||||||
* electrobun for exactly those symbols and prints this checklist when they
|
|
||||||
* appear. It runs from `postinstall`, so an electrobun bump surfaces it without
|
|
||||||
* anyone remembering to look.
|
|
||||||
* ============================================================================
|
|
||||||
*
|
|
||||||
* IMPLEMENTATION NOTES
|
|
||||||
*
|
|
||||||
* - `electrobun-webkit-app-region-no-drag` on every handle is LOAD-BEARING, not
|
|
||||||
* decoration. `dragRegions.ts` starts a window MOVE from a `mousedown`
|
|
||||||
* listener on `document` in the bubble phase; it never checks
|
|
||||||
* `defaultPrevented`, so `preventDefault()` does not stop it, and a
|
|
||||||
* `pointerdown` handler does not either (the compatibility `mousedown` still
|
|
||||||
* fires afterwards, and stopping a POINTER event does nothing to a MOUSE
|
|
||||||
* event). `isAppRegionDrag` tests for the no-drag class first and
|
|
||||||
* short-circuits, so the class is the only reliable suppressor. Without it
|
|
||||||
* every resize drag would also drag the window.
|
|
||||||
* - Deltas come from `screenX/screenY`, never `clientX/clientY`. Resizing from
|
|
||||||
* the north or west edge moves the window's origin, which moves the webview's
|
|
||||||
* client origin under a stationary pointer — client coords would feed that
|
|
||||||
* movement back into the next delta and the window would run away.
|
|
||||||
* - The frame is read once per drag, asynchronously, but the pointer origin is
|
|
||||||
* recorded synchronously in the same handler: a move that arrives before the
|
|
||||||
* frame does is simply ignored, and no delta is measured from a stale origin.
|
|
||||||
* - At most one setFrame is in flight, with the newest target coalesced behind
|
|
||||||
* it. Firing one RPC per pointermove would queue faster than it drains and
|
|
||||||
* the window would keep resizing after the mouse stopped.
|
|
||||||
* - Handles are hidden when maximized by CSS alone (7like.css reads the title
|
|
||||||
* bar's own `is-maximized` through `:has()`), so this component never
|
|
||||||
* subscribes to `windowState`. That matters: RpcService's push registries
|
|
||||||
* have no `off()`, so every subscription is permanent.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ONE handle: the bottom-right grip, exactly where Win7 put its resize gripper.
|
|
||||||
*
|
|
||||||
* A full eight-handle set was tried and reverted. The handles are invisible and
|
|
||||||
* sit ON TOP of the frame (the overlay is above the title bar in paint order),
|
|
||||||
* so the north strip ran the full width across the caption buttons and the
|
|
||||||
* north-east square sat on the close button's own top-right corner. Crossing
|
|
||||||
* one stole `:hover` from the button underneath, and since 7.css cross-fades
|
|
||||||
* the caption glow, a stolen hover reads as the glow flickering or sticking —
|
|
||||||
* "weird artifacts on the buttons", with no visual bug anywhere in the CSS.
|
|
||||||
*
|
|
||||||
* The bottom-right corner is the one place that overlaps nothing: it lands in
|
|
||||||
* the 6px gutter that `.window-body { margin: 0 6px 6px }` leaves below and
|
|
||||||
* right of the content. `resizeFrame` stays general (and the probe still covers
|
|
||||||
* all eight edges) so restoring a side is one entry here — but any new handle
|
|
||||||
* must be checked against what it covers first. South, east and west are the
|
|
||||||
* innocent ones; north and both top corners are not.
|
|
||||||
*
|
|
||||||
* 1rem, not the 0.75rem the corners had: this is now the only way to resize the
|
|
||||||
* window, so the target earns the extra pixels. Rem, so it scales with the
|
|
||||||
* interface scale like everything else.
|
|
||||||
*/
|
|
||||||
const GRIP_EDGE: ResizeEdge = 'se';
|
|
||||||
const GRIP_BOX = 'right-0 bottom-0 size-4 cursor-nwse-resize';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-window-resize',
|
|
||||||
template: `
|
|
||||||
<div
|
|
||||||
class="pointer-events-auto absolute touch-none electrobun-webkit-app-region-no-drag {{ gripBox }}"
|
|
||||||
(pointerdown)="onPointerDown($event)"
|
|
||||||
(pointermove)="onPointerMove($event)"
|
|
||||||
(pointerup)="onPointerEnd()"
|
|
||||||
(pointercancel)="onPointerEnd()"
|
|
||||||
></div>
|
|
||||||
`,
|
|
||||||
// The overlay spans the frame but is transparent to the pointer; only the
|
|
||||||
// handles inside it take hits. `w7-resize` is what the maximized rule hides.
|
|
||||||
host: { class: 'w7-resize pointer-events-none absolute inset-0 z-10' },
|
|
||||||
})
|
|
||||||
export class WindowResize {
|
|
||||||
protected readonly gripBox = GRIP_BOX;
|
|
||||||
|
|
||||||
private readonly rpc = inject(RpcService);
|
|
||||||
|
|
||||||
/** Null except during a drag. Set only once the frame read resolves. */
|
|
||||||
private drag: { screenX: number; screenY: number; frame: Frame } | null = null;
|
|
||||||
/** Newest target frame not yet sent, if a send is already in flight. */
|
|
||||||
private pending: Frame | null = null;
|
|
||||||
private sending = false;
|
|
||||||
|
|
||||||
onPointerDown(event: PointerEvent): void {
|
|
||||||
if (!this.rpc.available || event.button !== 0) return;
|
|
||||||
(event.target as HTMLElement).setPointerCapture(event.pointerId);
|
|
||||||
// captured synchronously — the frame read below is a round trip
|
|
||||||
const screenX = event.screenX;
|
|
||||||
const screenY = event.screenY;
|
|
||||||
void this.rpc.request.windowGetFrame({}).then((res) => {
|
|
||||||
if (!res.ok) return;
|
|
||||||
this.drag = {
|
|
||||||
screenX,
|
|
||||||
screenY,
|
|
||||||
frame: { x: res.x, y: res.y, width: res.width, height: res.height },
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onPointerMove(event: PointerEvent): void {
|
|
||||||
const drag = this.drag;
|
|
||||||
if (!drag) return; // moved before the frame arrived, or not dragging
|
|
||||||
this.pending = resizeFrame(
|
|
||||||
GRIP_EDGE,
|
|
||||||
drag.frame,
|
|
||||||
event.screenX - drag.screenX,
|
|
||||||
event.screenY - drag.screenY,
|
|
||||||
);
|
|
||||||
void this.flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
onPointerEnd(): void {
|
|
||||||
this.drag = null; // capture releases itself on pointerup/cancel
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drain `pending`, one request at a time. Re-entrant calls return
|
|
||||||
* immediately; the running loop picks up whatever they left behind. */
|
|
||||||
private async flush(): Promise<void> {
|
|
||||||
if (this.sending) return;
|
|
||||||
this.sending = true;
|
|
||||||
try {
|
|
||||||
while (this.pending) {
|
|
||||||
const frame = this.pending;
|
|
||||||
this.pending = null;
|
|
||||||
await this.rpc.request.windowSetFrame(frame);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
this.sending = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,12 +3,9 @@ import { ConnectionManager } from './connection-manager/connection-manager';
|
|||||||
import { EditorWindow } from './editor-window/editor-window';
|
import { EditorWindow } from './editor-window/editor-window';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The three window roots of the modern (shadcn-style) skin, keyed by APP_MODE.
|
* The three window roots, keyed by APP_MODE — main.ts picks one. There is no
|
||||||
*
|
* title-bar component: the native window frame is kept (hiddenInset on macOS,
|
||||||
* Same shape as SEVEN_ROOTS, which is the point: main.ts picks a tree by skin
|
* which is what IS_MAC_CHROME accounts for).
|
||||||
* and then a root by mode, without knowing anything about either skin. This
|
|
||||||
* skin has no title-bar component — it defers to the native window frame
|
|
||||||
* (hiddenInset on macOS), which is exactly what IS_MAC_CHROME accounts for.
|
|
||||||
*/
|
*/
|
||||||
export const SHAD_ROOTS = {
|
export const SHAD_ROOTS = {
|
||||||
main: App,
|
main: App,
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
/* ==========================================================================
|
|
||||||
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,12 +6,11 @@ import type { AccentTone } from '@shared/types';
|
|||||||
import { ContextMenuService } from '@core/context-menu.service';
|
import { ContextMenuService } from '@core/context-menu.service';
|
||||||
import { I18nService } from '@core/i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import { PanelsService, type Workspace } from '@core/panels.service';
|
import { PanelsService, type Workspace } from '@core/panels.service';
|
||||||
import { APP_SKIN, IS_MAC_CHROME, MOD_LABEL } from '@core/platform';
|
import { IS_MAC_CHROME, MOD_LABEL } from '@core/platform';
|
||||||
import { RpcService } from '@core/rpc.service';
|
import { RpcService } from '@core/rpc.service';
|
||||||
import { SettingsService } from '@core/settings.service';
|
import { SettingsService } from '@core/settings.service';
|
||||||
import { UpdaterService } from '@core/updater.service';
|
import { UpdaterService } from '@core/updater.service';
|
||||||
|
|
||||||
const SKINS = ['modern', 'win7'] as const;
|
|
||||||
const THEMES = ['system', 'light', 'dark'] as const;
|
const THEMES = ['system', 'light', 'dark'] as const;
|
||||||
const DARK_TONES = ['dark', 'darker'] as const;
|
const DARK_TONES = ['dark', 'darker'] as const;
|
||||||
// Swatch colors are fixed mid-tones so every dot reads on both light and dark
|
// Swatch colors are fixed mid-tones so every dot reads on both light and dark
|
||||||
@@ -182,36 +181,6 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Skin: a LAUNCH-TIME choice. It swaps the whole component tree
|
|
||||||
(ui/src/app/skins/) and the native frame, and neither can change
|
|
||||||
on a live window — the tree is bootstrapped from the preload-baked
|
|
||||||
__APP_SKIN and titleBarStyle/transparent are BrowserWindow
|
|
||||||
constructor options. Clicking only saves; the running UI is left
|
|
||||||
alone. Highlight the SAVED value so the click registers, and show
|
|
||||||
the pending hint whenever it differs from the booted skin. -->
|
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
||||||
<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">
|
<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>
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.theme.label') }}</span>
|
||||||
<div class="flex rounded-md border border-border p-0.5">
|
<div class="flex rounded-md border border-border p-0.5">
|
||||||
@@ -484,13 +453,6 @@ export class TabBar {
|
|||||||
readonly modLabel = MOD_LABEL;
|
readonly modLabel = MOD_LABEL;
|
||||||
readonly appName = BRANDING.appName;
|
readonly appName = BRANDING.appName;
|
||||||
readonly buildLabel = BRANDING.buildLabel;
|
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 themes = THEMES;
|
||||||
readonly darkTones = DARK_TONES;
|
readonly darkTones = DARK_TONES;
|
||||||
readonly accents = ACCENTS;
|
readonly accents = ACCENTS;
|
||||||
|
|||||||
+13
-6
@@ -6,13 +6,20 @@
|
|||||||
<base href="/" />
|
<base href="/" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
|
<!-- Pre-stylesheet paint: the window background must be right BEFORE
|
||||||
|
styles.css parses or new windows flash white/inverted. Only the
|
||||||
|
html background decision lives here — values must stay in sync with
|
||||||
|
the corresponding --background tokens in styles.css. The data-*
|
||||||
|
attributes are baked in by the bun preload (buildPreload in
|
||||||
|
src/bun/windows.ts) so forced themes are correct at first paint. -->
|
||||||
<style>
|
<style>
|
||||||
/* The native window is transparent (chromeOptions), so the page itself
|
html { background: oklch(1 0 0); }
|
||||||
must not paint a background — the Aero frame in styles.css is the only
|
html[data-theme='dark'] { background: oklch(0.2178 0.002 107); }
|
||||||
opaque surface, and its radius/drop shadow need to fall outside it.
|
html[data-theme='dark'][data-dark-tone='darker'] { background: oklch(0 0 0); }
|
||||||
Windows are created hidden and revealed on dom-ready, so there is no
|
@media (prefers-color-scheme: dark) {
|
||||||
unthemed flash to guard against here any more. */
|
html:not([data-theme='light']) { background: oklch(0.2178 0.002 107); }
|
||||||
html.win7, html.win7 body { background: transparent; }
|
html:not([data-theme='light'])[data-dark-tone='darker'] { background: oklch(0 0 0); }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+4
-14
@@ -1,19 +1,9 @@
|
|||||||
import { bootstrapApplication } from '@angular/platform-browser';
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
import { appConfig } from '@core/app.config';
|
import { appConfig } from '@core/app.config';
|
||||||
import { APP_MODE, APP_SKIN } from '@core/platform';
|
import { APP_MODE } from '@core/platform';
|
||||||
import { SEVEN_ROOTS } from './app/skins/7like';
|
|
||||||
import { SHAD_ROOTS } from './app/skins/shadlike';
|
import { SHAD_ROOTS } from './app/skins/shadlike';
|
||||||
|
|
||||||
// One bundle, two skins x three window kinds. Both globals are baked into the
|
// One bundle, three window kinds: the preload global picks the root component.
|
||||||
// preload (src/bun/windows.ts buildPreload) because both are needed before the
|
const root =
|
||||||
// first paint: settings arrive over an async RPC, far too late to choose a
|
APP_MODE === 'editor' ? SHAD_ROOTS.editor : APP_MODE === 'manager' ? SHAD_ROOTS.manager : SHAD_ROOTS.main;
|
||||||
// component tree without flashing the wrong UI.
|
|
||||||
//
|
|
||||||
// Every skin's barrel exports the same { main, manager, editor } shape, so this
|
|
||||||
// file never learns anything about either tree's internals. Both are imported
|
|
||||||
// statically and ship in one bundle — the inactive skin costs bundle size but
|
|
||||||
// no runtime work, which is the right trade for a local desktop app and keeps
|
|
||||||
// bootstrap synchronous.
|
|
||||||
const tree = APP_SKIN === 'win7' ? SEVEN_ROOTS : SHAD_ROOTS;
|
|
||||||
const root = APP_MODE === 'editor' ? tree.editor : APP_MODE === 'manager' ? tree.manager : tree.main;
|
|
||||||
bootstrapApplication(root, appConfig).catch((err) => console.error(err));
|
bootstrapApplication(root, appConfig).catch((err) => console.error(err));
|
||||||
|
|||||||
+2
-20
@@ -1,21 +1,4 @@
|
|||||||
/* Layer order — declared BEFORE the imports so it wins (a @layer statement is
|
|
||||||
one of the only two rule types allowed above @import). `skin` sorts after
|
|
||||||
`utilities`, so every 7.css rule outranks every Tailwind utility on the
|
|
||||||
elements it targets; that inversion is what lets the win7 skin restyle
|
|
||||||
~1900 inline utility classes without touching the templates. `skinfix` is
|
|
||||||
above both, and uses `revert-layer` to hand individual properties back to
|
|
||||||
`utilities` where 7.css's Win7-dialog metrics are wrong for this app.
|
|
||||||
Tailwind re-declares the first four names, a no-op once ordered here.
|
|
||||||
See the "Windows 7 skin" section at the bottom of this file. */
|
|
||||||
@layer theme, base, components, utilities, skin, skinfix;
|
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
/* The two skin stylesheets. Both ship in one bundle and are scoped by the
|
|
||||||
`.win7` class on <html>, exactly as when they lived in this file — only one
|
|
||||||
skin's rules can match at a time. They sit here because CSS requires every
|
|
||||||
@import to precede all other rules; layer assignment is fixed by the @layer
|
|
||||||
statement above, not by this order. */
|
|
||||||
@import "./app/skins/shadlike/shadlike.css";
|
|
||||||
@import "./app/skins/7like/7like.css";
|
|
||||||
|
|
||||||
/* shadcn-style theme tokens. Light stays the zinc family; the dark neutrals are a
|
/* shadcn-style theme tokens. Light stays the zinc family; the dark neutrals are a
|
||||||
warm gray family (hue ~107) anchored on #1a1a19 — see the dark blocks below.
|
warm gray family (hue ~107) anchored on #1a1a19 — see the dark blocks below.
|
||||||
@@ -192,9 +175,8 @@
|
|||||||
/* Panel-chrome metrics: file list, path bar, action buttons, source picker.
|
/* Panel-chrome metrics: file list, path bar, action buttons, source picker.
|
||||||
ALL REM, deliberately — the interface scale (settings.uiScale) works by
|
ALL REM, deliberately — the interface scale (settings.uiScale) works by
|
||||||
setting the root font-size, so every rem value in the app scales from that
|
setting the root font-size, so every rem value in the app scales from that
|
||||||
one lever. A px value here would freeze while everything around it grew,
|
one lever. A px value here would freeze while everything around it grew.
|
||||||
which is exactly what happened to the win7 boxes before stage 1c. There is
|
There is only one tier now: the old data-text compact/large tiers are gone, replaced
|
||||||
only one tier now: the old data-text compact/large tiers are gone, replaced
|
|
||||||
by the continuous scale. Values are the old default tier verbatim
|
by the continuous scale. Values are the old default tier verbatim
|
||||||
(13px -> 0.8125rem etc. at the 16px root), so scale 1.0 is pixel-identical
|
(13px -> 0.8125rem etc. at the 16px root), so scale 1.0 is pixel-identical
|
||||||
to the pre-scale app. */
|
to the pre-scale app. */
|
||||||
|
|||||||
Reference in New Issue
Block a user