From c4364c10512d7fa712a2cce5005fecc966f1a473 Mon Sep 17 00:00:00 2001 From: Jokin Date: Sun, 16 Aug 2026 20:50:42 +0200 Subject: [PATCH] Fix the dead win7 context menu, flatten the selection, add a tab menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-click did nothing under the win7 skin, and then swallowed the next click. 7.css declares `position: relative` on ul[role=menu] and the `skin` layer outranks `utilities`, so Tailwind's `.fixed` lost: [style.left/top.px] became flow offsets from the menu's static position — the last child of the h-screen frame — and every menu rendered below the viewport, unreachable behind overflow:hidden. Its backdrop is a plain
, unmatched by 7.css, so that stayed genuinely fixed and ate the following click. Regressed in 0ae2a62, when the menu adopted 7.css's real markup. Restated `position: fixed` in skinfix, scoped to a new `w7-ctx` marker so 7.css's own menus keep theirs. scripts/menu-probe.ts is the regression test: it renders the menu at declared coordinates, pixel-scans whether it landed there, and carries a marker-less control menu (the bug verbatim) as its own discriminator. A resting pixel diff cannot see this class of bug. Selection band goes flat: --w7-sel-bg/-bd (#cbe8f6/#26a0da) is what Explorer actually painted, where 7.css reused --w7-el-grad-h, its Aero element-hover ramp. That var is left alone — buttons and tabs still want the gloss. The unfocused pane gets the grey idle band; the band still never disappears with focus, it only weakens. Harness diff confirms the change touches selection rows and nothing else, in both themes. New workspace-tab right-click menu in both skins: New Tab, Close Tab, Close Other Tabs, Close Tabs to the Right, Disconnect. The target id travels inside the action string because the right-clicked tab is not necessarily the active one. Illegal entries are disabled rather than omitted, the way Win7 greyed out — and since 7.css's [aria-disabled] handling is CSS-only, the keyboard paths check the flag themselves. Co-Authored-By: Claude --- docs/architecture.md | 34 ++++- docs/gotchas.md | 30 +++- scripts/menu-probe.ts | 143 +++++++++++++++++++ src/shared/i18n/de.ts | 5 + src/shared/i18n/en.ts | 5 + src/shared/i18n/es.ts | 5 + src/shared/i18n/eu.ts | 5 + src/shared/i18n/fr.ts | 5 + src/shared/i18n/ja.ts | 5 + src/shared/i18n/zh.ts | 5 + ui/src/app/core/context-menu.service.ts | 56 +++++++- ui/src/app/core/panels.service.ts | 40 ++++++ ui/src/app/skins/7like/7like.css | 62 ++++++-- ui/src/app/skins/7like/app.ts | 33 +++++ ui/src/app/skins/7like/context-menu.ts | 28 +++- ui/src/app/skins/7like/tab-bar/tab-bar.ts | 23 ++- ui/src/app/skins/shadlike/app.ts | 33 +++++ ui/src/app/skins/shadlike/context-menu.ts | 12 +- ui/src/app/skins/shadlike/tab-bar/tab-bar.ts | 23 ++- 19 files changed, 524 insertions(+), 28 deletions(-) create mode 100644 scripts/menu-probe.ts diff --git a/docs/architecture.md b/docs/architecture.md index e2e2e1a..ce37b9a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -256,18 +256,29 @@ 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). - `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always against an explicit panel; `menuTarget` remembers the right-clicked row. -- `context-menu.service.ts` + `context-menu.ts` — the panel right-click menus, - in HTML. Electrobun's native `showContextMenu` is a no-op on Linux, so it was +- `context-menu.service.ts` + `context-menu.ts` — the panel and tab right-click + menus, in HTML. Electrobun's native `showContextMenu` is a no-op on Linux, so it was retired on ALL platforms rather than kept as a second code path (see platform-notes.md). The service builds the item list (row / empty-space menu, and the header column picker) and holds the open request as a signal; the component renders it at the pointer with edge-flip clamping, arrow/Enter/Escape keys, and close-on-blur/resize/scroll. Picked items are emitted verbatim as - `ctx-*` / `col:*` strings through `RpcService.emitMenuAction`, so they land in - the SAME `App.handleMenuAction` dispatcher the macOS app menubar uses — one - handler, two sources. The three shell actions the webview cannot perform + `ctx-*` / `col:*` / `tab:*` strings through `RpcService.emitMenuAction`, so they + land in the SAME `App.handleMenuAction` dispatcher the macOS app menubar uses — + one handler, two sources. The three shell actions the webview cannot perform (reveal, open-with-default, copy path) go back to bun via the `shellItemAction` RPC, which is all that remains of the old native menu. + `tabItems()` builds the workspace-tab menu (`tab:new`, and + `tab:close|close-others|close-right|disconnect:`), served by + `PanelsService.closeOtherWorkspaces` / `closeWorkspacesToRight` / + `disconnectWorkspace`. **The target id travels inside the action string**: the + right-clicked tab is not necessarily the active one, so nothing on that path + may fall back to `activeWorkspaceId`. Illegal entries are `disabled`, not + omitted — Win7 greys out, and a menu whose items move between right-clicks is + worse than one with a dead row. 7.css hangs that state on + `li[role=menuitem][aria-disabled]` (opacity + `pointer-events: none`), which + the keyboard path never consults — so `pick()` and the arrow-key walker check + the flag themselves, in both skins. - `file-icons.ts` — extension → category map driving glyphs, colors (fixed `--icon-*` tokens, never accent-tinted) and the Kind column. - `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`, @@ -417,6 +428,14 @@ 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/-bd` = `#cbe8f6`/`#26a0da`), not 7.css's `--w7-el-grad-h` — that +var is its Aero element-hover ramp and stays reserved for buttons and tabs, +which still want the gloss. The unfocused pane gets `--w7-sel-bg-idle/-bd-idle`, +Explorer's grey inactive selection; the band never disappears with focus, only +weakens (the commander convention above). All four are dark-overridden in BOTH +dark blocks. + **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 @@ -426,7 +445,10 @@ 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`. +menu.css's hardcoded light literals are restated for dark, in `skinfix` — plus +one structural override: `.w7-ctx` restores `position: fixed`, which 7.css's own +`position: relative` on `ul[role=menu]` had been silently winning (see +gotchas.md; `scripts/menu-probe.ts` is the regression test). **Font**: Segoe UI is absent off Windows and not redistributable. Noto Sans — the substitute 7.css itself names — is vendored (OFL-1.1, from diff --git a/docs/gotchas.md b/docs/gotchas.md index 4b6554d..d7c1c44 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -160,6 +160,22 @@ touching the adjacent code. `: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 `
`, 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. - **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 @@ -231,7 +247,19 @@ touching the adjacent code. Kill first (`pkill -f dev-linux-x64`), confirm with `pgrep -cf 'dev-linux-x64/miniSCP-dev/bin/launcher'`, then launch and capture. Note `pkill -f 'electrobun dev'` also matches the shell running it and kills - your own command (exit 144) — match on the build path instead. + your own command (exit 144) — match on the build path instead. That is only + half the trap: `pkill -f dev-linux-x64` does the same thing, because the + pattern appears in the killing command's OWN argv. Break the literal with a + character class — `pkill -f 'dev-linux-x[6]4'` — the way `ps | grep [x]yz` + has always worked. +- **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; diff --git a/scripts/menu-probe.ts b/scripts/menu-probe.ts new file mode 100644 index 0000000..1b533c7 --- /dev/null +++ b/scripts/menu-probe.ts @@ -0,0 +1,143 @@ +// Programmatic POSITIONING 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. +// +// 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`); + +// ---- 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) => ` + `; + +// The frame reproduces the app's: .window.w7-frame is a 100vh flex column and +// 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 = ` + + + + +
+
+
miniSCP
+
+
+
+ ${menu(MENU_X, true)} + ${menu(CONTROL_X, false)} +
+`; + +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 { + 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; +} + +// 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"}`, +); +console.log(`\n[menu-probe] image: ${out}`); + +if (placed === 0) { + console.log("[menu-probe] the context menu is positioned in FLOW again — see 7like.css .w7-ctx"); + process.exit(2); +} +console.log("[menu-probe] clean"); diff --git a/src/shared/i18n/de.ts b/src/shared/i18n/de.ts index 911d772..c534172 100644 --- a/src/shared/i18n/de.ts +++ b/src/shared/i18n/de.ts @@ -294,6 +294,11 @@ export const de = { copyPath: "Pfad kopieren", delete: "Löschen", columns: "Spalten", + tabNew: "Neuer Tab", + tabClose: "Tab schließen", + tabCloseOthers: "Andere Tabs schließen", + tabCloseRight: "Tabs rechts schließen", + tabDisconnect: "Trennen", trashTooltip: "Finders Wiederherstellen ist für von hier gelöschte Elemente nicht verfügbar", }, errors: { diff --git a/src/shared/i18n/en.ts b/src/shared/i18n/en.ts index 68cf2be..37ba13e 100644 --- a/src/shared/i18n/en.ts +++ b/src/shared/i18n/en.ts @@ -298,6 +298,11 @@ export const en = { copyPath: "Copy Path", delete: "Delete", columns: "Columns", + tabNew: "New Tab", + tabClose: "Close Tab", + tabCloseOthers: "Close Other Tabs", + tabCloseRight: "Close Tabs to the Right", + tabDisconnect: "Disconnect", trashTooltip: "Finder's Restore is not available for items trashed from here", }, errors: { diff --git a/src/shared/i18n/es.ts b/src/shared/i18n/es.ts index f371359..54c50c9 100644 --- a/src/shared/i18n/es.ts +++ b/src/shared/i18n/es.ts @@ -294,6 +294,11 @@ export const es = { copyPath: "Copiar ruta", delete: "Eliminar", columns: "Columnas", + tabNew: "Nueva pestaña", + tabClose: "Cerrar pestaña", + tabCloseOthers: "Cerrar las demás pestañas", + tabCloseRight: "Cerrar pestañas a la derecha", + tabDisconnect: "Desconectar", trashTooltip: "La opción Restaurar de Finder no está disponible para elementos enviados a la papelera desde aquí", }, errors: { diff --git a/src/shared/i18n/eu.ts b/src/shared/i18n/eu.ts index 166c6ec..e6a1353 100644 --- a/src/shared/i18n/eu.ts +++ b/src/shared/i18n/eu.ts @@ -296,6 +296,11 @@ export const eu = { copyPath: "Kopiatu bidea", delete: "Ezabatu", columns: "Zutabeak", + tabNew: "Fitxa berria", + tabClose: "Itxi fitxa", + tabCloseOthers: "Itxi beste fitxak", + tabCloseRight: "Itxi eskuineko fitxak", + tabDisconnect: "Deskonektatu", trashTooltip: "Finder-en «Jarri berriro» ez dago erabilgarri hemendik zakarrontzira botatako elementuentzat", }, errors: { diff --git a/src/shared/i18n/fr.ts b/src/shared/i18n/fr.ts index 444b6fb..8904362 100644 --- a/src/shared/i18n/fr.ts +++ b/src/shared/i18n/fr.ts @@ -294,6 +294,11 @@ export const fr = { copyPath: "Copier le chemin", delete: "Supprimer", columns: "Colonnes", + tabNew: "Nouvel onglet", + tabClose: "Fermer l'onglet", + tabCloseOthers: "Fermer les autres onglets", + tabCloseRight: "Fermer les onglets à droite", + tabDisconnect: "Déconnecter", trashTooltip: "La restauration du Finder n'est pas disponible pour les éléments mis à la corbeille d'ici", }, errors: { diff --git a/src/shared/i18n/ja.ts b/src/shared/i18n/ja.ts index e7a561e..7a8c60f 100644 --- a/src/shared/i18n/ja.ts +++ b/src/shared/i18n/ja.ts @@ -294,6 +294,11 @@ export const ja = { copyPath: "パスをコピー", delete: "削除", columns: "列", + tabNew: "新しいタブ", + tabClose: "タブを閉じる", + tabCloseOthers: "他のタブを閉じる", + tabCloseRight: "右側のタブを閉じる", + tabDisconnect: "切断", trashTooltip: "ここからゴミ箱に入れた項目は Finder の「戻す」を利用できません", }, errors: { diff --git a/src/shared/i18n/zh.ts b/src/shared/i18n/zh.ts index 6485dd4..dd62e44 100644 --- a/src/shared/i18n/zh.ts +++ b/src/shared/i18n/zh.ts @@ -294,6 +294,11 @@ export const zh = { copyPath: "复制路径", delete: "删除", columns: "列", + tabNew: "新建标签页", + tabClose: "关闭标签页", + tabCloseOthers: "关闭其他标签页", + tabCloseRight: "关闭右侧标签页", + tabDisconnect: "断开连接", trashTooltip: "从此处移入废纸篓的项目无法使用访达的放回原处功能", }, errors: { diff --git a/ui/src/app/core/context-menu.service.ts b/ui/src/app/core/context-menu.service.ts index b91fe2f..3546552 100644 --- a/ui/src/app/core/context-menu.service.ts +++ b/ui/src/app/core/context-menu.service.ts @@ -1,7 +1,7 @@ import { Injectable, inject, signal } from '@angular/core'; import type { ColumnKey, EntryType } from '@shared/types'; import { I18nService } from './i18n.service'; -import { IS_MAC, IS_WINDOWS } from './platform'; +import { IS_MAC, IS_WINDOWS, MOD_LABEL } from './platform'; import { RpcService } from './rpc.service'; /** Optional list columns, in display order (Name is fixed). */ @@ -10,12 +10,15 @@ const COLUMN_KEYS: ColumnKey[] = ['size', 'mtime', 'kind', 'rights', 'owner', 'g export interface MenuItem { type?: 'separator'; label?: string; - /** Emitted verbatim to the action dispatcher (`ctx-*` / `col:*`). */ + /** Emitted verbatim to the action dispatcher (`ctx-*` / `col:*` / `tab:*`). */ action?: string; checked?: boolean; tooltip?: string; /** Shortcut hint, rendered right-aligned. */ accelerator?: string; + /** Greyed and unclickable. Win7 menus disable rather than hide, so the set + * of commands stays in the same place from one right-click to the next. */ + disabled?: boolean; } export interface MenuRequest { @@ -24,6 +27,16 @@ export interface MenuRequest { items: MenuItem[]; } +export interface TabMenuContext { + /** The right-clicked tab (NOT necessarily the active one). */ + id: number; + /** Position in the strip, for "close to the right". */ + index: number; + tabCount: number; + /** Any pane in this tab holds a live remote session. */ + isRemote: boolean; +} + export interface RowMenuContext { path: string; name: string; @@ -114,6 +127,45 @@ export class ContextMenuService { return items; } + /** + * The workspace-tab menu. Actions carry the target tab's id, because the + * right-clicked tab is not necessarily the active one — nothing here may + * assume `activeWorkspace`. + * + * Everything illegal is DISABLED, not omitted: a menu whose items move + * between right-clicks is a Win7 sin, and "Close Tab" greyed out explains + * why the last tab cannot be closed better than its absence does. + * + * Duplicate/Rename/Save-session are deliberately absent until tabs have an + * identity to duplicate (a stored connection id) — the sessions round. + */ + tabItems(ctx: TabMenuContext): MenuItem[] { + const t = (key: string) => this.i18n.t(key); + const last = ctx.tabCount <= 1; + return [ + { label: t('ctx.tabNew'), action: 'tab:new', accelerator: `${MOD_LABEL}T` }, + { type: 'separator' }, + { + label: t('ctx.tabClose'), + action: `tab:close:${ctx.id}`, + accelerator: `${MOD_LABEL}W`, + disabled: last, + }, + { label: t('ctx.tabCloseOthers'), action: `tab:close-others:${ctx.id}`, disabled: last }, + { + label: t('ctx.tabCloseRight'), + action: `tab:close-right:${ctx.id}`, + disabled: ctx.index >= ctx.tabCount - 1, + }, + { type: 'separator' }, + { + label: t('ctx.tabDisconnect'), + action: `tab:disconnect:${ctx.id}`, + disabled: !ctx.isRemote, + }, + ]; + } + /** Column picker for the list headers: checkable toggles per column. */ columnItems(visible: ColumnKey[]): MenuItem[] { return COLUMN_KEYS.map((key) => ({ diff --git a/ui/src/app/core/panels.service.ts b/ui/src/app/core/panels.service.ts index 907e8cd..b90f7e2 100644 --- a/ui/src/app/core/panels.service.ts +++ b/ui/src/app/core/panels.service.ts @@ -398,6 +398,46 @@ export class PanelsService { this.refocus(); } + /** + * Close every tab except one — the tab menu's "Close Other Tabs". + * + * Snapshot the ids FIRST: closeWorkspace mutates the list on every call, so + * iterating the live signal would skip half of them. It also refuses the + * last tab, which is exactly the wanted floor here. + */ + closeOtherWorkspaces(id: number): void { + for (const other of this.workspaces().map((w) => w.id)) { + if (other !== id) this.closeWorkspace(other); + } + this.setActiveWorkspace(id); + } + + /** Close every tab to the right of one, in visual order. */ + closeWorkspacesToRight(id: number): void { + const list = this.workspaces(); + const index = list.findIndex((w) => w.id === id); + if (index < 0) return; + for (const target of list.slice(index + 1).map((w) => w.id)) { + this.closeWorkspace(target); + } + } + + /** + * Drop both of a tab's remote sessions, keeping the tab itself. Unlike + * `disconnectPanel` this targets a tab by id, because the right-clicked tab + * is not necessarily the active one. + */ + async disconnectWorkspace(id: number): Promise { + const workspace = this.workspaces().find((w) => w.id === id); + if (!workspace) return; + for (const panel of [workspace.left, workspace.right]) { + const sessionId = panel.sessionId(); + if (sessionId === 'local') continue; + await this.rpc.request.disconnect({ sessionId }); + this.resetToLocal(panel); + } + } + setActiveWorkspace(id: number): void { if (this.activeWorkspaceId() !== id) { this.activeWorkspaceId.set(id); diff --git a/ui/src/app/skins/7like/7like.css b/ui/src/app/skins/7like/7like.css index 577eddc..5d307a5 100644 --- a/ui/src/app/skins/7like/7like.css +++ b/ui/src/app/skins/7like/7like.css @@ -128,6 +128,18 @@ --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-bd: #26a0da; + --w7-sel-bg-idle: #dedede; + --w7-sel-bd-idle: #c9c9c9; /* 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; @@ -188,6 +200,10 @@ --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-bd: #4a8cbe; + --w7-sel-bg-idle: #3a3a3a; + --w7-sel-bd-idle: #4c4c4c; --w7-tab-c: var(--w7-el-c); --w7-mn-c: var(--w7-el-c); --w7-mn-bd: var(--w7-el-bd-d); @@ -245,7 +261,11 @@ --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-tab-c: var(--w7-el-c); + --w7-sel-bg: #2a4a63; + --w7-sel-bd: #4a8cbe; + --w7-sel-bg-idle: #3a3a3a; + --w7-sel-bd-idle: #4c4c4c; + --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; @@ -561,8 +581,8 @@ html.win7 body { border-right: none; } .win7 table > tbody > tr.highlighted { - background: var(--w7-li-bg-hl); - border-color: var(--w7-li-bd-hl); + background: var(--w7-sel-bg-idle); + border-color: var(--w7-sel-bd-idle); } /* ---- Window frame ---- @@ -645,7 +665,7 @@ html.win7 body { border: 1px solid transparent; } .win7 table > tbody > tr.highlighted { - border-color: var(--w7-li-bd-hl); + border-color: var(--w7-sel-bd-idle); } /* Pane-focus model (commander convention, matching Win7 Explorer): @@ -653,15 +673,19 @@ html.win7 body { 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 -> strong Aero band (below) - - unfocused pane -> 7.css's own tr.highlighted, which IS Win7's - inactive-selection wash — faint by design, not a bug. + - 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. */ + focus rect over the selected item. + FLAT, not a gradient: 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. Explorer used one solid fill and a solid border. Keep the + 1px border and 7.css's 3px radius — those ARE Explorer's selection box. */ .win7 table.is-active > tbody > tr.highlighted { - background: var(--w7-el-grad-h); - border-color: var(--w7-el-bd-h); + background: var(--w7-sel-bg); + border-color: var(--w7-sel-bd); } /* One dotted rectangle around the ROW; per cell (tr > *) draws a dotted box @@ -684,6 +708,24 @@ html.win7 body { 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
, 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); } diff --git a/ui/src/app/skins/7like/app.ts b/ui/src/app/skins/7like/app.ts index 1152432..cb69d6c 100644 --- a/ui/src/app/skins/7like/app.ts +++ b/ui/src/app/skins/7like/app.ts @@ -143,6 +143,10 @@ export class App { this.settings.toggleColumn(action.slice('col:'.length) as ColumnKey); return; } + if (action.startsWith('tab:')) { + this.handleTabAction(action.slice('tab:'.length)); + return; + } if (action.startsWith('open-stack:')) { const id = action.slice('open-stack:'.length); let stackConnections = this.connections.connections().filter((c) => c.stackId === id); @@ -219,6 +223,35 @@ export class App { } /** 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; diff --git a/ui/src/app/skins/7like/context-menu.ts b/ui/src/app/skins/7like/context-menu.ts index 0e51edb..cbe0ffe 100644 --- a/ui/src/app/skins/7like/context-menu.ts +++ b/ui/src/app/skins/7like/context-menu.ts @@ -33,6 +33,12 @@ interface MenuRow { * 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. @@ -51,10 +57,17 @@ interface MenuRow { [style.left.px]="position().x" [style.top.px]="position().y" [style.width.rem]="menuWidthRem" - class="fixed z-50 outline-none" + class="w7-ctx fixed z-50 outline-none" > @for (row of rows(); track $index) { -
  • + +
  • @if (row.item.checked !== undefined) {