Fix the dead win7 context menu, flatten the selection, add a tab menu
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 <div>, 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 <noreply@anthropic.com>
This commit is contained in:
+28
-6
@@ -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).
|
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 right-click menus,
|
- `context-menu.service.ts` + `context-menu.ts` — the panel and tab right-click
|
||||||
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,
|
||||||
and the header column picker) and holds the open request as a signal; the
|
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
|
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
|
keys, and close-on-blur/resize/scroll. Picked items are emitted verbatim as
|
||||||
`ctx-*` / `col:*` strings through `RpcService.emitMenuAction`, so they land in
|
`ctx-*` / `col:*` / `tab:*` strings through `RpcService.emitMenuAction`, so they
|
||||||
the SAME `App.handleMenuAction` dispatcher the macOS app menubar uses — one
|
land in the SAME `App.handleMenuAction` dispatcher the macOS app menubar uses —
|
||||||
handler, two sources. The three shell actions the webview cannot perform
|
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`
|
(reveal, open-with-default, copy path) go back to bun via the `shellItemAction`
|
||||||
RPC, which is all that remains of the old native menu.
|
RPC, which is all that remains of the old native menu.
|
||||||
|
`tabItems()` builds the workspace-tab menu (`tab:new`, and
|
||||||
|
`tab:close|close-others|close-right|disconnect:<id>`), served by
|
||||||
|
`PanelsService.closeOtherWorkspaces` / `closeWorkspacesToRight` /
|
||||||
|
`disconnectWorkspace`. **The target id travels inside the action string**: the
|
||||||
|
right-clicked tab is not necessarily the active one, so nothing on that path
|
||||||
|
may fall back to `activeWorkspaceId`. Illegal entries are `disabled`, not
|
||||||
|
omitted — Win7 greys out, and a menu whose items move between right-clicks is
|
||||||
|
worse than one with a dead row. 7.css hangs that state on
|
||||||
|
`li[role=menuitem][aria-disabled]` (opacity + `pointer-events: none`), which
|
||||||
|
the keyboard path never consults — so `pick()` and the arrow-key walker check
|
||||||
|
the flag themselves, in both skins.
|
||||||
- `file-icons.ts` — extension → category map driving glyphs, colors
|
- `file-icons.ts` — extension → category map driving glyphs, colors
|
||||||
(fixed `--icon-*` tokens, never accent-tinted) and the Kind column.
|
(fixed `--icon-*` tokens, never accent-tinted) and the Kind column.
|
||||||
- `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`,
|
- `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`,
|
||||||
@@ -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
|
`:has(> .title-bar.is-maximized)`, which `title-bar.ts` publishes from its
|
||||||
existing `maximized` signal rather than subscribing to `windowState` twice.
|
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 —
|
**Context menu**: pure 7.css via its real menu contract —
|
||||||
`ul[role=menu] > li[role=menuitem] > button` for actions,
|
`ul[role=menu] > li[role=menuitem] > button` for actions,
|
||||||
`input[type=checkbox] + label` for the checkable column toggles, dividers as
|
`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
|
one (`dist/gui/menu.css`), including the gutter stripe, disabled state and
|
||||||
submenu arrows. Keyboard moves REAL focus between items (7.css styles
|
submenu arrows. Keyboard moves REAL focus between items (7.css styles
|
||||||
`:focus-visible` like `:hover`); Enter/Space activate buttons natively. Only
|
`: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 —
|
**Font**: Segoe UI is absent off Windows and not redistributable. Noto Sans —
|
||||||
the substitute 7.css itself names — is vendored (OFL-1.1, from
|
the substitute 7.css itself names — is vendored (OFL-1.1, from
|
||||||
|
|||||||
+29
-1
@@ -160,6 +160,22 @@ touching the adjacent code.
|
|||||||
`:root.win7` and overridden in **both** dark blocks (the `[data-theme]`
|
`:root.win7` and overridden in **both** dark blocks (the `[data-theme]`
|
||||||
attribute one *and* the `prefers-color-scheme` one; system dark sets no
|
attribute one *and* the `prefers-color-scheme` one; system dark sets no
|
||||||
attribute).
|
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.
|
||||||
- **Its selectors are exact, and a near-miss looks exactly like "7.css doesn't
|
- **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
|
support this".** `menu[role=tablist] button` is a DESCENDANT selector, so
|
||||||
every button inside the strip becomes a tab — the `+` and the right-hand
|
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
|
Kill first (`pkill -f dev-linux-x64`), confirm with
|
||||||
`pgrep -cf 'dev-linux-x64/miniSCP-dev/bin/launcher'`, then launch and capture.
|
`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
|
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
|
- **X11 capture cannot answer "is this pixel transparent?"** for the frameless
|
||||||
window. `import -window` grabs the window's own pixels with no compositing,
|
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;
|
so a transparent corner and an opaque black one both come out black;
|
||||||
|
|||||||
@@ -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) => `
|
||||||
|
<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">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="${styles}">
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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");
|
||||||
@@ -294,6 +294,11 @@ export const de = {
|
|||||||
copyPath: "Pfad kopieren",
|
copyPath: "Pfad kopieren",
|
||||||
delete: "Löschen",
|
delete: "Löschen",
|
||||||
columns: "Spalten",
|
columns: "Spalten",
|
||||||
|
tabNew: "Neuer Tab",
|
||||||
|
tabClose: "Tab schließen",
|
||||||
|
tabCloseOthers: "Andere Tabs schließen",
|
||||||
|
tabCloseRight: "Tabs rechts schließen",
|
||||||
|
tabDisconnect: "Trennen",
|
||||||
trashTooltip: "Finders Wiederherstellen ist für von hier gelöschte Elemente nicht verfügbar",
|
trashTooltip: "Finders Wiederherstellen ist für von hier gelöschte Elemente nicht verfügbar",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
|
|||||||
@@ -298,6 +298,11 @@ export const en = {
|
|||||||
copyPath: "Copy Path",
|
copyPath: "Copy Path",
|
||||||
delete: "Delete",
|
delete: "Delete",
|
||||||
columns: "Columns",
|
columns: "Columns",
|
||||||
|
tabNew: "New Tab",
|
||||||
|
tabClose: "Close Tab",
|
||||||
|
tabCloseOthers: "Close Other Tabs",
|
||||||
|
tabCloseRight: "Close Tabs to the Right",
|
||||||
|
tabDisconnect: "Disconnect",
|
||||||
trashTooltip: "Finder's Restore is not available for items trashed from here",
|
trashTooltip: "Finder's Restore is not available for items trashed from here",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
|
|||||||
@@ -294,6 +294,11 @@ export const es = {
|
|||||||
copyPath: "Copiar ruta",
|
copyPath: "Copiar ruta",
|
||||||
delete: "Eliminar",
|
delete: "Eliminar",
|
||||||
columns: "Columnas",
|
columns: "Columnas",
|
||||||
|
tabNew: "Nueva pestaña",
|
||||||
|
tabClose: "Cerrar pestaña",
|
||||||
|
tabCloseOthers: "Cerrar las demás pestañas",
|
||||||
|
tabCloseRight: "Cerrar pestañas a la derecha",
|
||||||
|
tabDisconnect: "Desconectar",
|
||||||
trashTooltip: "La opción Restaurar de Finder no está disponible para elementos enviados a la papelera desde aquí",
|
trashTooltip: "La opción Restaurar de Finder no está disponible para elementos enviados a la papelera desde aquí",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
|
|||||||
@@ -296,6 +296,11 @@ export const eu = {
|
|||||||
copyPath: "Kopiatu bidea",
|
copyPath: "Kopiatu bidea",
|
||||||
delete: "Ezabatu",
|
delete: "Ezabatu",
|
||||||
columns: "Zutabeak",
|
columns: "Zutabeak",
|
||||||
|
tabNew: "Fitxa berria",
|
||||||
|
tabClose: "Itxi fitxa",
|
||||||
|
tabCloseOthers: "Itxi beste fitxak",
|
||||||
|
tabCloseRight: "Itxi eskuineko fitxak",
|
||||||
|
tabDisconnect: "Deskonektatu",
|
||||||
trashTooltip: "Finder-en «Jarri berriro» ez dago erabilgarri hemendik zakarrontzira botatako elementuentzat",
|
trashTooltip: "Finder-en «Jarri berriro» ez dago erabilgarri hemendik zakarrontzira botatako elementuentzat",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
|
|||||||
@@ -294,6 +294,11 @@ export const fr = {
|
|||||||
copyPath: "Copier le chemin",
|
copyPath: "Copier le chemin",
|
||||||
delete: "Supprimer",
|
delete: "Supprimer",
|
||||||
columns: "Colonnes",
|
columns: "Colonnes",
|
||||||
|
tabNew: "Nouvel onglet",
|
||||||
|
tabClose: "Fermer l'onglet",
|
||||||
|
tabCloseOthers: "Fermer les autres onglets",
|
||||||
|
tabCloseRight: "Fermer les onglets à droite",
|
||||||
|
tabDisconnect: "Déconnecter",
|
||||||
trashTooltip: "La restauration du Finder n'est pas disponible pour les éléments mis à la corbeille d'ici",
|
trashTooltip: "La restauration du Finder n'est pas disponible pour les éléments mis à la corbeille d'ici",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
|
|||||||
@@ -294,6 +294,11 @@ export const ja = {
|
|||||||
copyPath: "パスをコピー",
|
copyPath: "パスをコピー",
|
||||||
delete: "削除",
|
delete: "削除",
|
||||||
columns: "列",
|
columns: "列",
|
||||||
|
tabNew: "新しいタブ",
|
||||||
|
tabClose: "タブを閉じる",
|
||||||
|
tabCloseOthers: "他のタブを閉じる",
|
||||||
|
tabCloseRight: "右側のタブを閉じる",
|
||||||
|
tabDisconnect: "切断",
|
||||||
trashTooltip: "ここからゴミ箱に入れた項目は Finder の「戻す」を利用できません",
|
trashTooltip: "ここからゴミ箱に入れた項目は Finder の「戻す」を利用できません",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
|
|||||||
@@ -294,6 +294,11 @@ export const zh = {
|
|||||||
copyPath: "复制路径",
|
copyPath: "复制路径",
|
||||||
delete: "删除",
|
delete: "删除",
|
||||||
columns: "列",
|
columns: "列",
|
||||||
|
tabNew: "新建标签页",
|
||||||
|
tabClose: "关闭标签页",
|
||||||
|
tabCloseOthers: "关闭其他标签页",
|
||||||
|
tabCloseRight: "关闭右侧标签页",
|
||||||
|
tabDisconnect: "断开连接",
|
||||||
trashTooltip: "从此处移入废纸篓的项目无法使用访达的放回原处功能",
|
trashTooltip: "从此处移入废纸篓的项目无法使用访达的放回原处功能",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, inject, signal } from '@angular/core';
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
import type { ColumnKey, EntryType } from '@shared/types';
|
import type { ColumnKey, EntryType } from '@shared/types';
|
||||||
import { I18nService } from './i18n.service';
|
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';
|
import { RpcService } from './rpc.service';
|
||||||
|
|
||||||
/** Optional list columns, in display order (Name is fixed). */
|
/** 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 {
|
export interface MenuItem {
|
||||||
type?: 'separator';
|
type?: 'separator';
|
||||||
label?: string;
|
label?: string;
|
||||||
/** Emitted verbatim to the action dispatcher (`ctx-*` / `col:*`). */
|
/** Emitted verbatim to the action dispatcher (`ctx-*` / `col:*` / `tab:*`). */
|
||||||
action?: string;
|
action?: string;
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
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
|
||||||
|
* of commands stays in the same place from one right-click to the next. */
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MenuRequest {
|
export interface MenuRequest {
|
||||||
@@ -24,6 +27,16 @@ export interface MenuRequest {
|
|||||||
items: MenuItem[];
|
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 {
|
export interface RowMenuContext {
|
||||||
path: string;
|
path: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -114,6 +127,45 @@ export class ContextMenuService {
|
|||||||
return items;
|
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. */
|
/** Column picker for the list headers: checkable toggles per column. */
|
||||||
columnItems(visible: ColumnKey[]): MenuItem[] {
|
columnItems(visible: ColumnKey[]): MenuItem[] {
|
||||||
return COLUMN_KEYS.map((key) => ({
|
return COLUMN_KEYS.map((key) => ({
|
||||||
|
|||||||
@@ -398,6 +398,46 @@ export class PanelsService {
|
|||||||
this.refocus();
|
this.refocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close every tab except one — the tab menu's "Close Other Tabs".
|
||||||
|
*
|
||||||
|
* Snapshot the ids FIRST: closeWorkspace mutates the list on every call, so
|
||||||
|
* iterating the live signal would skip half of them. It also refuses the
|
||||||
|
* last tab, which is exactly the wanted floor here.
|
||||||
|
*/
|
||||||
|
closeOtherWorkspaces(id: number): void {
|
||||||
|
for (const other of this.workspaces().map((w) => w.id)) {
|
||||||
|
if (other !== id) this.closeWorkspace(other);
|
||||||
|
}
|
||||||
|
this.setActiveWorkspace(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close every tab to the right of one, in visual order. */
|
||||||
|
closeWorkspacesToRight(id: number): void {
|
||||||
|
const list = this.workspaces();
|
||||||
|
const index = list.findIndex((w) => w.id === id);
|
||||||
|
if (index < 0) return;
|
||||||
|
for (const target of list.slice(index + 1).map((w) => w.id)) {
|
||||||
|
this.closeWorkspace(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop both of a tab's remote sessions, keeping the tab itself. Unlike
|
||||||
|
* `disconnectPanel` this targets a tab by id, because the right-clicked tab
|
||||||
|
* is not necessarily the active one.
|
||||||
|
*/
|
||||||
|
async disconnectWorkspace(id: number): Promise<void> {
|
||||||
|
const workspace = this.workspaces().find((w) => w.id === id);
|
||||||
|
if (!workspace) return;
|
||||||
|
for (const panel of [workspace.left, workspace.right]) {
|
||||||
|
const sessionId = panel.sessionId();
|
||||||
|
if (sessionId === 'local') continue;
|
||||||
|
await this.rpc.request.disconnect({ sessionId });
|
||||||
|
this.resetToLocal(panel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setActiveWorkspace(id: number): void {
|
setActiveWorkspace(id: number): void {
|
||||||
if (this.activeWorkspaceId() !== id) {
|
if (this.activeWorkspaceId() !== id) {
|
||||||
this.activeWorkspaceId.set(id);
|
this.activeWorkspaceId.set(id);
|
||||||
|
|||||||
@@ -128,6 +128,18 @@
|
|||||||
--w7-lv-head-grad: linear-gradient(180deg, #fff 45%, #fafafa 0, #f0f0f0);
|
--w7-lv-head-grad: linear-gradient(180deg, #fff 45%, #fafafa 0, #f0f0f0);
|
||||||
--w7-lv-head-bd: #d7d7d7;
|
--w7-lv-head-bd: #d7d7d7;
|
||||||
--w7-lv-cell-bd: #eee;
|
--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,
|
/* Tabs (tabs.css). 7.css hardcodes `color: #222` on every tablist button,
|
||||||
outside its own var system, which is unreadable on the dark surface. */
|
outside its own var system, which is unreadable on the dark surface. */
|
||||||
--w7-tab-c: #222;
|
--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-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-head-bd: var(--w7-el-bd-d);
|
||||||
--w7-lv-cell-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-tab-c: var(--w7-el-c);
|
||||||
--w7-mn-c: var(--w7-el-c);
|
--w7-mn-c: var(--w7-el-c);
|
||||||
--w7-mn-bd: var(--w7-el-bd-d);
|
--w7-mn-bd: var(--w7-el-bd-d);
|
||||||
@@ -245,6 +261,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-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-head-bd: var(--w7-el-bd-d);
|
||||||
--w7-lv-cell-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-tab-c: var(--w7-el-c);
|
||||||
--w7-mn-c: var(--w7-el-c);
|
--w7-mn-c: var(--w7-el-c);
|
||||||
--w7-mn-bd: var(--w7-el-bd-d);
|
--w7-mn-bd: var(--w7-el-bd-d);
|
||||||
@@ -561,8 +581,8 @@ html.win7 body {
|
|||||||
border-right: none;
|
border-right: none;
|
||||||
}
|
}
|
||||||
.win7 table > tbody > tr.highlighted {
|
.win7 table > tbody > tr.highlighted {
|
||||||
background: var(--w7-li-bg-hl);
|
background: var(--w7-sel-bg-idle);
|
||||||
border-color: var(--w7-li-bd-hl);
|
border-color: var(--w7-sel-bd-idle);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Window frame ----
|
/* ---- Window frame ----
|
||||||
@@ -645,7 +665,7 @@ html.win7 body {
|
|||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
.win7 table > tbody > tr.highlighted {
|
.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):
|
/* 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
|
tr.highlighted covers "selected via Space" AND "cursor row" (the template
|
||||||
ORs them). What changes with pane focus is the band's strength, never
|
ORs them). What changes with pane focus is the band's strength, never
|
||||||
whether it exists:
|
whether it exists:
|
||||||
- focused pane -> strong Aero band (below)
|
- focused pane -> the flat blue selection (below)
|
||||||
- unfocused pane -> 7.css's own tr.highlighted, which IS Win7's
|
- unfocused pane -> the grey idle band, which is what Explorer drew
|
||||||
inactive-selection wash — faint by design, not a bug.
|
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
|
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
|
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 {
|
.win7 table.is-active > tbody > tr.highlighted {
|
||||||
background: var(--w7-el-grad-h);
|
background: var(--w7-sel-bg);
|
||||||
border-color: var(--w7-el-bd-h);
|
border-color: var(--w7-sel-bd);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* One dotted rectangle around the ROW; per cell (tr > *) draws a dotted box
|
/* 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);
|
color: var(--w7-mn-c);
|
||||||
border-color: var(--w7-mn-bd);
|
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 {
|
.win7 ul[role='menu']:before {
|
||||||
box-shadow: var(--w7-mn-stripe);
|
box-shadow: var(--w7-mn-stripe);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,10 @@ export class App {
|
|||||||
this.settings.toggleColumn(action.slice('col:'.length) as ColumnKey);
|
this.settings.toggleColumn(action.slice('col:'.length) as ColumnKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action.startsWith('tab:')) {
|
||||||
|
this.handleTabAction(action.slice('tab:'.length));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (action.startsWith('open-stack:')) {
|
if (action.startsWith('open-stack:')) {
|
||||||
const id = action.slice('open-stack:'.length);
|
const id = action.slice('open-stack:'.length);
|
||||||
let stackConnections = this.connections.connections().filter((c) => c.stackId === id);
|
let stackConnections = this.connections.connections().filter((c) => c.stackId === id);
|
||||||
@@ -219,6 +223,35 @@ export class App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Context-menu actions apply to the row/panel remembered at right-click time. */
|
/** Context-menu actions apply to the row/panel remembered at right-click time. */
|
||||||
|
/**
|
||||||
|
* Workspace-tab menu actions. The target id travels INSIDE the action string
|
||||||
|
* because the right-clicked tab is not necessarily the active one — nothing
|
||||||
|
* here may fall back to activeWorkspaceId.
|
||||||
|
*/
|
||||||
|
private handleTabAction(action: string): void {
|
||||||
|
if (action === 'new') {
|
||||||
|
this.panels.newWorkspace();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = /^([a-z-]+):(\d+)$/.exec(action);
|
||||||
|
if (!parsed) return;
|
||||||
|
const id = Number(parsed[2]);
|
||||||
|
switch (parsed[1]) {
|
||||||
|
case 'close':
|
||||||
|
this.panels.closeWorkspace(id);
|
||||||
|
break;
|
||||||
|
case 'close-others':
|
||||||
|
this.panels.closeOtherWorkspaces(id);
|
||||||
|
break;
|
||||||
|
case 'close-right':
|
||||||
|
this.panels.closeWorkspacesToRight(id);
|
||||||
|
break;
|
||||||
|
case 'disconnect':
|
||||||
|
void this.panels.disconnectWorkspace(id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private handleContextAction(action: string): void {
|
private handleContextAction(action: string): void {
|
||||||
const target = this.actions.menuTarget;
|
const target = this.actions.menuTarget;
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ interface MenuRow {
|
|||||||
* rendered div > button, concluded "7.css has no menu component", and
|
* rendered div > button, concluded "7.css has no menu component", and
|
||||||
* hand-built a lookalike. It does have one; the selectors just never matched.
|
* 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
|
* Keyboard follows the platform: arrows move REAL focus between items (7.css
|
||||||
* styles :focus-visible identically to :hover), Enter/Space on a button
|
* styles :focus-visible identically to :hover), Enter/Space on a button
|
||||||
* activate it natively; only labels need a synthetic click.
|
* activate it natively; only labels need a synthetic click.
|
||||||
@@ -51,10 +57,17 @@ interface MenuRow {
|
|||||||
[style.left.px]="position().x"
|
[style.left.px]="position().x"
|
||||||
[style.top.px]="position().y"
|
[style.top.px]="position().y"
|
||||||
[style.width.rem]="menuWidthRem"
|
[style.width.rem]="menuWidthRem"
|
||||||
class="fixed z-50 outline-none"
|
class="w7-ctx fixed z-50 outline-none"
|
||||||
>
|
>
|
||||||
@for (row of rows(); track $index) {
|
@for (row of rows(); track $index) {
|
||||||
<li role="menuitem" [class.has-divider]="row.hasDivider" [title]="row.item.tooltip ?? ''">
|
<!-- 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) {
|
@if (row.item.checked !== undefined) {
|
||||||
<input type="checkbox" [id]="'ctx-check-' + $index" [checked]="row.item.checked" tabindex="-1" />
|
<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)">
|
<label [for]="'ctx-check-' + $index" tabindex="-1" (click)="pick(row.item, $event)">
|
||||||
@@ -140,6 +153,9 @@ export class ContextMenu {
|
|||||||
// labels: don't let the click also toggle the real checkbox — the fresh
|
// labels: don't let the click also toggle the real checkbox — the fresh
|
||||||
// `checked` state comes back through settings when the menu reopens
|
// `checked` state comes back through settings when the menu reopens
|
||||||
event?.preventDefault();
|
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;
|
if (!item.action) return;
|
||||||
const action = item.action;
|
const action = item.action;
|
||||||
this.close();
|
this.close();
|
||||||
@@ -189,7 +205,13 @@ export class ContextMenu {
|
|||||||
private step(delta: number): void {
|
private step(delta: number): void {
|
||||||
const root = this.menuEl()?.nativeElement;
|
const root = this.menuEl()?.nativeElement;
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
const targets = Array.from(root.querySelectorAll<HTMLElement>('li > button, li > label'));
|
// 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;
|
if (!targets.length) return;
|
||||||
const current = targets.indexOf(document.activeElement as HTMLElement);
|
const current = targets.indexOf(document.activeElement as HTMLElement);
|
||||||
const next = current < 0 && delta < 0 ? targets.length - 1 : (current + delta + targets.length) % targets.length;
|
const next = current < 0 && delta < 0 ? targets.length - 1 : (current + delta + targets.length) % targets.length;
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { NgIcon } from '@ng-icons/core';
|
|||||||
import { BRANDING } from '@shared/branding';
|
import { BRANDING } from '@shared/branding';
|
||||||
import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
|
import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
|
||||||
import type { AccentTone } from '@shared/types';
|
import type { AccentTone } from '@shared/types';
|
||||||
|
import { ContextMenuService } from '@core/context-menu.service';
|
||||||
import { I18nService } from '@core/i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import { PanelsService } from '@core/panels.service';
|
import { PanelsService, type Workspace } from '@core/panels.service';
|
||||||
import { APP_SKIN, MOD_LABEL } from '@core/platform';
|
import { APP_SKIN, 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';
|
||||||
@@ -71,6 +72,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
[attr.data-tab-id]="ws.id"
|
[attr.data-tab-id]="ws.id"
|
||||||
(click)="panels.setActiveWorkspace(ws.id)"
|
(click)="panels.setActiveWorkspace(ws.id)"
|
||||||
(auxclick)="onAuxClick($event, 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="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"
|
[class.opacity-50]="draggedId() === ws.id"
|
||||||
[style.box-shadow]="dropIndicatorShadow(i)"
|
[style.box-shadow]="dropIndicatorShadow(i)"
|
||||||
@@ -467,6 +469,7 @@ export class TabBar {
|
|||||||
readonly settings = inject(SettingsService);
|
readonly settings = inject(SettingsService);
|
||||||
readonly i18n = inject(I18nService);
|
readonly i18n = inject(I18nService);
|
||||||
readonly updater = inject(UpdaterService);
|
readonly updater = inject(UpdaterService);
|
||||||
|
private readonly contextMenu = inject(ContextMenuService);
|
||||||
private readonly rpc = inject(RpcService);
|
private readonly rpc = inject(RpcService);
|
||||||
readonly settingsOpen = signal(false);
|
readonly settingsOpen = signal(false);
|
||||||
readonly localeOptions = SUPPORTED;
|
readonly localeOptions = SUPPORTED;
|
||||||
@@ -526,6 +529,24 @@ export class TabBar {
|
|||||||
if (this.panels.workspaces().length > 1) this.close(event, id);
|
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. */
|
/** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */
|
||||||
editorAppName(): string {
|
editorAppName(): string {
|
||||||
const path = this.settings.editorApp();
|
const path = this.settings.editorApp();
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ export class App {
|
|||||||
this.settings.toggleColumn(action.slice('col:'.length) as ColumnKey);
|
this.settings.toggleColumn(action.slice('col:'.length) as ColumnKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action.startsWith('tab:')) {
|
||||||
|
this.handleTabAction(action.slice('tab:'.length));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (action.startsWith('open-stack:')) {
|
if (action.startsWith('open-stack:')) {
|
||||||
const id = action.slice('open-stack:'.length);
|
const id = action.slice('open-stack:'.length);
|
||||||
let stackConnections = this.connections.connections().filter((c) => c.stackId === id);
|
let stackConnections = this.connections.connections().filter((c) => c.stackId === id);
|
||||||
@@ -197,6 +201,35 @@ export class App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Context-menu actions apply to the row/panel remembered at right-click time. */
|
/** Context-menu actions apply to the row/panel remembered at right-click time. */
|
||||||
|
/**
|
||||||
|
* Workspace-tab menu actions. The target id travels INSIDE the action string
|
||||||
|
* because the right-clicked tab is not necessarily the active one — nothing
|
||||||
|
* here may fall back to activeWorkspaceId.
|
||||||
|
*/
|
||||||
|
private handleTabAction(action: string): void {
|
||||||
|
if (action === 'new') {
|
||||||
|
this.panels.newWorkspace();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = /^([a-z-]+):(\d+)$/.exec(action);
|
||||||
|
if (!parsed) return;
|
||||||
|
const id = Number(parsed[2]);
|
||||||
|
switch (parsed[1]) {
|
||||||
|
case 'close':
|
||||||
|
this.panels.closeWorkspace(id);
|
||||||
|
break;
|
||||||
|
case 'close-others':
|
||||||
|
this.panels.closeOtherWorkspaces(id);
|
||||||
|
break;
|
||||||
|
case 'close-right':
|
||||||
|
this.panels.closeWorkspacesToRight(id);
|
||||||
|
break;
|
||||||
|
case 'disconnect':
|
||||||
|
void this.panels.disconnectWorkspace(id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private handleContextAction(action: string): void {
|
private handleContextAction(action: string): void {
|
||||||
const target = this.actions.menuTarget;
|
const target = this.actions.menuTarget;
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
|
|||||||
@@ -51,10 +51,11 @@ const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
[title]="item.tooltip ?? ''"
|
[title]="item.tooltip ?? ''"
|
||||||
[class.bg-accent]="$index === activeIndex()"
|
[disabled]="!!item.disabled"
|
||||||
|
[class.bg-accent]="$index === activeIndex() && !item.disabled"
|
||||||
(mouseenter)="activeIndex.set($index)"
|
(mouseenter)="activeIndex.set($index)"
|
||||||
(click)="pick(item)"
|
(click)="pick(item)"
|
||||||
class="flex w-full items-center gap-2 px-3 py-1 text-left text-(length:--path-font) hover:bg-accent"
|
class="flex w-full items-center gap-2 px-3 py-1 text-left text-(length:--path-font) hover:bg-accent disabled:pointer-events-none disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<span class="w-3.5 shrink-0">
|
<span class="w-3.5 shrink-0">
|
||||||
@if (item.checked) {
|
@if (item.checked) {
|
||||||
@@ -122,6 +123,8 @@ export class ContextMenu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pick(item: MenuItem): void {
|
pick(item: MenuItem): void {
|
||||||
|
// the keyboard path never consults the disabled attribute — guard here
|
||||||
|
if (item.disabled) return;
|
||||||
if (!item.action) return;
|
if (!item.action) return;
|
||||||
const action = item.action;
|
const action = item.action;
|
||||||
this.close();
|
this.close();
|
||||||
@@ -160,14 +163,15 @@ export class ContextMenu {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Move the highlight by one selectable item, skipping separators, wrapping. */
|
/** Move the highlight by one selectable item, skipping separators and
|
||||||
|
* disabled entries, wrapping. */
|
||||||
private step(items: MenuItem[], delta: number): void {
|
private step(items: MenuItem[], delta: number): void {
|
||||||
const count = items.length;
|
const count = items.length;
|
||||||
if (!count) return;
|
if (!count) return;
|
||||||
let index = this.activeIndex();
|
let index = this.activeIndex();
|
||||||
for (let hops = 0; hops < count; hops++) {
|
for (let hops = 0; hops < count; hops++) {
|
||||||
index = (index + delta + count) % count;
|
index = (index + delta + count) % count;
|
||||||
if (items[index].type !== 'separator') {
|
if (items[index].type !== 'separator' && !items[index].disabled) {
|
||||||
this.activeIndex.set(index);
|
this.activeIndex.set(index);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { NgIcon } from '@ng-icons/core';
|
|||||||
import { BRANDING } from '@shared/branding';
|
import { BRANDING } from '@shared/branding';
|
||||||
import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
|
import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
|
||||||
import type { AccentTone } from '@shared/types';
|
import type { AccentTone } from '@shared/types';
|
||||||
|
import { ContextMenuService } from '@core/context-menu.service';
|
||||||
import { I18nService } from '@core/i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import { PanelsService } from '@core/panels.service';
|
import { PanelsService, type Workspace } from '@core/panels.service';
|
||||||
import { APP_SKIN, IS_MAC_CHROME, MOD_LABEL } from '@core/platform';
|
import { APP_SKIN, 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';
|
||||||
@@ -58,6 +59,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
[class.bg-accent]="ws.id === panels.activeWorkspaceId()"
|
[class.bg-accent]="ws.id === panels.activeWorkspaceId()"
|
||||||
[class.opacity-50]="draggedId() === ws.id"
|
[class.opacity-50]="draggedId() === ws.id"
|
||||||
[style.box-shadow]="dropIndicatorShadow(i)"
|
[style.box-shadow]="dropIndicatorShadow(i)"
|
||||||
|
(contextmenu)="onTabContextMenu($event, ws, i)"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
(dragstart)="onDragStart($event, ws.id)"
|
(dragstart)="onDragStart($event, ws.id)"
|
||||||
(dragover)="onDragOver($event, i)"
|
(dragover)="onDragOver($event, i)"
|
||||||
@@ -473,6 +475,7 @@ export class TabBar {
|
|||||||
readonly settings = inject(SettingsService);
|
readonly settings = inject(SettingsService);
|
||||||
readonly i18n = inject(I18nService);
|
readonly i18n = inject(I18nService);
|
||||||
readonly updater = inject(UpdaterService);
|
readonly updater = inject(UpdaterService);
|
||||||
|
private readonly contextMenu = inject(ContextMenuService);
|
||||||
private readonly rpc = inject(RpcService);
|
private readonly rpc = inject(RpcService);
|
||||||
readonly settingsOpen = signal(false);
|
readonly settingsOpen = signal(false);
|
||||||
readonly localeOptions = SUPPORTED;
|
readonly localeOptions = SUPPORTED;
|
||||||
@@ -519,6 +522,24 @@ export class TabBar {
|
|||||||
this.panels.closeWorkspace(id);
|
this.panels.closeWorkspace(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Right-click a tab. The menu targets THIS tab, not the active one — its
|
||||||
|
* actions all carry the id — so right-clicking a background tab does not
|
||||||
|
* yank focus to it first, the way Explorer and every browser behave.
|
||||||
|
*/
|
||||||
|
onTabContextMenu(event: MouseEvent, workspace: Workspace, index: number): void {
|
||||||
|
event.preventDefault();
|
||||||
|
this.contextMenu.open(
|
||||||
|
event,
|
||||||
|
this.contextMenu.tabItems({
|
||||||
|
id: workspace.id,
|
||||||
|
index,
|
||||||
|
tabCount: this.panels.workspaces().length,
|
||||||
|
isRemote: this.panels.workspaceIsRemote(workspace),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */
|
/** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */
|
||||||
editorAppName(): string {
|
editorAppName(): string {
|
||||||
const path = this.settings.editorApp();
|
const path = this.settings.editorApp();
|
||||||
|
|||||||
Reference in New Issue
Block a user