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:
2026-08-16 20:50:42 +02:00
co-authored by Claude
parent 89af52e1c0
commit c4364c1051
19 changed files with 524 additions and 28 deletions
+28 -6
View File
@@ -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
View File
@@ -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;
+143
View File
@@ -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");
+5
View File
@@ -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: {
+5
View File
@@ -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: {
+5
View File
@@ -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: {
+5
View File
@@ -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: {
+5
View File
@@ -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: {
+5
View File
@@ -294,6 +294,11 @@ export const ja = {
copyPath: "パスをコピー", copyPath: "パスをコピー",
delete: "削除", delete: "削除",
columns: "列", columns: "列",
tabNew: "新しいタブ",
tabClose: "タブを閉じる",
tabCloseOthers: "他のタブを閉じる",
tabCloseRight: "右側のタブを閉じる",
tabDisconnect: "切断",
trashTooltip: "ここからゴミ箱に入れた項目は Finder の「戻す」を利用できません", trashTooltip: "ここからゴミ箱に入れた項目は Finder の「戻す」を利用できません",
}, },
errors: { errors: {
+5
View File
@@ -294,6 +294,11 @@ export const zh = {
copyPath: "复制路径", copyPath: "复制路径",
delete: "删除", delete: "删除",
columns: "列", columns: "列",
tabNew: "新建标签页",
tabClose: "关闭标签页",
tabCloseOthers: "关闭其他标签页",
tabCloseRight: "关闭右侧标签页",
tabDisconnect: "断开连接",
trashTooltip: "从此处移入废纸篓的项目无法使用访达的放回原处功能", trashTooltip: "从此处移入废纸篓的项目无法使用访达的放回原处功能",
}, },
errors: { errors: {
+54 -2
View File
@@ -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) => ({
+40
View File
@@ -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);
+52 -10
View File
@@ -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,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-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-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-c: var(--w7-el-c);
--w7-mn-bd: var(--w7-el-bd-d); --w7-mn-bd: var(--w7-el-bd-d);
--w7-mn-stripe: inset 1px 0 #00000059, inset -1px 0 #ffffff14; --w7-mn-stripe: inset 1px 0 #00000059, inset -1px 0 #ffffff14;
@@ -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);
} }
+33
View File
@@ -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;
+25 -3
View File
@@ -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;
+22 -1
View File
@@ -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();
+33
View File
@@ -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;
+8 -4
View File
@@ -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;
} }
+22 -1
View File
@@ -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();