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).
|
||||
- `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always
|
||||
against an explicit panel; `menuTarget` remembers the right-clicked row.
|
||||
- `context-menu.service.ts` + `context-menu.ts` — the panel right-click menus,
|
||||
in HTML. Electrobun's native `showContextMenu` is a no-op on Linux, so it was
|
||||
- `context-menu.service.ts` + `context-menu.ts` — the panel and tab right-click
|
||||
menus, in HTML. Electrobun's native `showContextMenu` is a no-op on Linux, so it was
|
||||
retired on ALL platforms rather than kept as a second code path (see
|
||||
platform-notes.md). The service builds the item list (row / empty-space menu,
|
||||
and the header column picker) and holds the open request as a signal; the
|
||||
component renders it at the pointer with edge-flip clamping, arrow/Enter/Escape
|
||||
keys, and close-on-blur/resize/scroll. Picked items are emitted verbatim as
|
||||
`ctx-*` / `col:*` strings through `RpcService.emitMenuAction`, so they land in
|
||||
the SAME `App.handleMenuAction` dispatcher the macOS app menubar uses — one
|
||||
handler, two sources. The three shell actions the webview cannot perform
|
||||
`ctx-*` / `col:*` / `tab:*` strings through `RpcService.emitMenuAction`, so they
|
||||
land in the SAME `App.handleMenuAction` dispatcher the macOS app menubar uses —
|
||||
one handler, two sources. The three shell actions the webview cannot perform
|
||||
(reveal, open-with-default, copy path) go back to bun via the `shellItemAction`
|
||||
RPC, which is all that remains of the old native menu.
|
||||
`tabItems()` builds the workspace-tab menu (`tab:new`, and
|
||||
`tab:close|close-others|close-right|disconnect:<id>`), served by
|
||||
`PanelsService.closeOtherWorkspaces` / `closeWorkspacesToRight` /
|
||||
`disconnectWorkspace`. **The target id travels inside the action string**: the
|
||||
right-clicked tab is not necessarily the active one, so nothing on that path
|
||||
may fall back to `activeWorkspaceId`. Illegal entries are `disabled`, not
|
||||
omitted — Win7 greys out, and a menu whose items move between right-clicks is
|
||||
worse than one with a dead row. 7.css hangs that state on
|
||||
`li[role=menuitem][aria-disabled]` (opacity + `pointer-events: none`), which
|
||||
the keyboard path never consults — so `pick()` and the arrow-key walker check
|
||||
the flag themselves, in both skins.
|
||||
- `file-icons.ts` — extension → category map driving glyphs, colors
|
||||
(fixed `--icon-*` tokens, never accent-tinted) and the Kind column.
|
||||
- `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`,
|
||||
@@ -417,6 +428,14 @@ zeroed); the frame reads the state off its own child with
|
||||
`:has(> .title-bar.is-maximized)`, which `title-bar.ts` publishes from its
|
||||
existing `maximized` signal rather than subscribing to `windowState` twice.
|
||||
|
||||
**Selection band**: the file list's selected row is a FLAT Win7-Explorer blue
|
||||
(`--w7-sel-bg/-bd` = `#cbe8f6`/`#26a0da`), not 7.css's `--w7-el-grad-h` — that
|
||||
var is its Aero element-hover ramp and stays reserved for buttons and tabs,
|
||||
which still want the gloss. The unfocused pane gets `--w7-sel-bg-idle/-bd-idle`,
|
||||
Explorer's grey inactive selection; the band never disappears with focus, only
|
||||
weakens (the commander convention above). All four are dark-overridden in BOTH
|
||||
dark blocks.
|
||||
|
||||
**Context menu**: pure 7.css via its real menu contract —
|
||||
`ul[role=menu] > li[role=menuitem] > button` for actions,
|
||||
`input[type=checkbox] + label` for the checkable column toggles, dividers as
|
||||
@@ -426,7 +445,10 @@ nothing, and wrongly concluded "7.css has no menu component" — it has a full
|
||||
one (`dist/gui/menu.css`), including the gutter stripe, disabled state and
|
||||
submenu arrows. Keyboard moves REAL focus between items (7.css styles
|
||||
`:focus-visible` like `:hover`); Enter/Space activate buttons natively. Only
|
||||
menu.css's hardcoded light literals are restated for dark, in `skinfix`.
|
||||
menu.css's hardcoded light literals are restated for dark, in `skinfix` — plus
|
||||
one structural override: `.w7-ctx` restores `position: fixed`, which 7.css's own
|
||||
`position: relative` on `ul[role=menu]` had been silently winning (see
|
||||
gotchas.md; `scripts/menu-probe.ts` is the regression test).
|
||||
|
||||
**Font**: Segoe UI is absent off Windows and not redistributable. Noto Sans —
|
||||
the substitute 7.css itself names — is vendored (OFL-1.1, from
|
||||
|
||||
+29
-1
@@ -160,6 +160,22 @@ touching the adjacent code.
|
||||
`:root.win7` and overridden in **both** dark blocks (the `[data-theme]`
|
||||
attribute one *and* the `prefers-color-scheme` one; system dark sets no
|
||||
attribute).
|
||||
- **The skin layer can also override `position`, and that breaks LAYOUT, not
|
||||
just paint.** `ul[role=menu]` carries `position: relative` in 7.css (for the
|
||||
gutter stripe's `::before` and submenu anchoring), which outranked Tailwind's
|
||||
`.fixed` on the app's own context menu. `[style.left/top.px]` then became
|
||||
*flow* offsets from the menu's static position — the last child of the
|
||||
`h-screen` frame — so every right-click menu rendered below the viewport,
|
||||
unreachable behind `overflow: hidden`. The backdrop is a plain `<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
|
||||
support this".** `menu[role=tablist] button` is a DESCENDANT selector, so
|
||||
every button inside the strip becomes a tab — the `+` and the right-hand
|
||||
@@ -231,7 +247,19 @@ touching the adjacent code.
|
||||
Kill first (`pkill -f dev-linux-x64`), confirm with
|
||||
`pgrep -cf 'dev-linux-x64/miniSCP-dev/bin/launcher'`, then launch and capture.
|
||||
Note `pkill -f 'electrobun dev'` also matches the shell running it and kills
|
||||
your own command (exit 144) — match on the build path instead.
|
||||
your own command (exit 144) — match on the build path instead. That is only
|
||||
half the trap: `pkill -f dev-linux-x64` does the same thing, because the
|
||||
pattern appears in the killing command's OWN argv. Break the literal with a
|
||||
character class — `pkill -f 'dev-linux-x[6]4'` — the way `ps | grep [x]yz`
|
||||
has always worked.
|
||||
- **Prefer the headless probes over launching the app.** `bun run start` opens a
|
||||
real window that takes focus, which interrupts whatever the user is doing;
|
||||
there is no Xvfb/xvfb-run on this machine and Wayland offers no
|
||||
launch-without-focus. `scripts/shot.ts` (+ `--dark`), `hover-probe.ts` and
|
||||
`menu-probe.ts` all render through headless Firefox and open nothing. Launch
|
||||
the real app only for what Gecko genuinely cannot answer — WebKitGTK
|
||||
behaviour, the native frame, transparency, live RPC — ask first, and batch
|
||||
every live check into a single launch.
|
||||
- **X11 capture cannot answer "is this pixel transparent?"** for the frameless
|
||||
window. `import -window` grabs the window's own pixels with no compositing,
|
||||
so a transparent corner and an opaque black one both come out black;
|
||||
|
||||
@@ -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",
|
||||
delete: "Löschen",
|
||||
columns: "Spalten",
|
||||
tabNew: "Neuer Tab",
|
||||
tabClose: "Tab schließen",
|
||||
tabCloseOthers: "Andere Tabs schließen",
|
||||
tabCloseRight: "Tabs rechts schließen",
|
||||
tabDisconnect: "Trennen",
|
||||
trashTooltip: "Finders Wiederherstellen ist für von hier gelöschte Elemente nicht verfügbar",
|
||||
},
|
||||
errors: {
|
||||
|
||||
@@ -298,6 +298,11 @@ export const en = {
|
||||
copyPath: "Copy Path",
|
||||
delete: "Delete",
|
||||
columns: "Columns",
|
||||
tabNew: "New Tab",
|
||||
tabClose: "Close Tab",
|
||||
tabCloseOthers: "Close Other Tabs",
|
||||
tabCloseRight: "Close Tabs to the Right",
|
||||
tabDisconnect: "Disconnect",
|
||||
trashTooltip: "Finder's Restore is not available for items trashed from here",
|
||||
},
|
||||
errors: {
|
||||
|
||||
@@ -294,6 +294,11 @@ export const es = {
|
||||
copyPath: "Copiar ruta",
|
||||
delete: "Eliminar",
|
||||
columns: "Columnas",
|
||||
tabNew: "Nueva pestaña",
|
||||
tabClose: "Cerrar pestaña",
|
||||
tabCloseOthers: "Cerrar las demás pestañas",
|
||||
tabCloseRight: "Cerrar pestañas a la derecha",
|
||||
tabDisconnect: "Desconectar",
|
||||
trashTooltip: "La opción Restaurar de Finder no está disponible para elementos enviados a la papelera desde aquí",
|
||||
},
|
||||
errors: {
|
||||
|
||||
@@ -296,6 +296,11 @@ export const eu = {
|
||||
copyPath: "Kopiatu bidea",
|
||||
delete: "Ezabatu",
|
||||
columns: "Zutabeak",
|
||||
tabNew: "Fitxa berria",
|
||||
tabClose: "Itxi fitxa",
|
||||
tabCloseOthers: "Itxi beste fitxak",
|
||||
tabCloseRight: "Itxi eskuineko fitxak",
|
||||
tabDisconnect: "Deskonektatu",
|
||||
trashTooltip: "Finder-en «Jarri berriro» ez dago erabilgarri hemendik zakarrontzira botatako elementuentzat",
|
||||
},
|
||||
errors: {
|
||||
|
||||
@@ -294,6 +294,11 @@ export const fr = {
|
||||
copyPath: "Copier le chemin",
|
||||
delete: "Supprimer",
|
||||
columns: "Colonnes",
|
||||
tabNew: "Nouvel onglet",
|
||||
tabClose: "Fermer l'onglet",
|
||||
tabCloseOthers: "Fermer les autres onglets",
|
||||
tabCloseRight: "Fermer les onglets à droite",
|
||||
tabDisconnect: "Déconnecter",
|
||||
trashTooltip: "La restauration du Finder n'est pas disponible pour les éléments mis à la corbeille d'ici",
|
||||
},
|
||||
errors: {
|
||||
|
||||
@@ -294,6 +294,11 @@ export const ja = {
|
||||
copyPath: "パスをコピー",
|
||||
delete: "削除",
|
||||
columns: "列",
|
||||
tabNew: "新しいタブ",
|
||||
tabClose: "タブを閉じる",
|
||||
tabCloseOthers: "他のタブを閉じる",
|
||||
tabCloseRight: "右側のタブを閉じる",
|
||||
tabDisconnect: "切断",
|
||||
trashTooltip: "ここからゴミ箱に入れた項目は Finder の「戻す」を利用できません",
|
||||
},
|
||||
errors: {
|
||||
|
||||
@@ -294,6 +294,11 @@ export const zh = {
|
||||
copyPath: "复制路径",
|
||||
delete: "删除",
|
||||
columns: "列",
|
||||
tabNew: "新建标签页",
|
||||
tabClose: "关闭标签页",
|
||||
tabCloseOthers: "关闭其他标签页",
|
||||
tabCloseRight: "关闭右侧标签页",
|
||||
tabDisconnect: "断开连接",
|
||||
trashTooltip: "从此处移入废纸篓的项目无法使用访达的放回原处功能",
|
||||
},
|
||||
errors: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import type { ColumnKey, EntryType } from '@shared/types';
|
||||
import { I18nService } from './i18n.service';
|
||||
import { IS_MAC, IS_WINDOWS } from './platform';
|
||||
import { IS_MAC, IS_WINDOWS, MOD_LABEL } from './platform';
|
||||
import { RpcService } from './rpc.service';
|
||||
|
||||
/** Optional list columns, in display order (Name is fixed). */
|
||||
@@ -10,12 +10,15 @@ const COLUMN_KEYS: ColumnKey[] = ['size', 'mtime', 'kind', 'rights', 'owner', 'g
|
||||
export interface MenuItem {
|
||||
type?: 'separator';
|
||||
label?: string;
|
||||
/** Emitted verbatim to the action dispatcher (`ctx-*` / `col:*`). */
|
||||
/** Emitted verbatim to the action dispatcher (`ctx-*` / `col:*` / `tab:*`). */
|
||||
action?: string;
|
||||
checked?: boolean;
|
||||
tooltip?: string;
|
||||
/** Shortcut hint, rendered right-aligned. */
|
||||
accelerator?: string;
|
||||
/** Greyed and unclickable. Win7 menus disable rather than hide, so the set
|
||||
* of commands stays in the same place from one right-click to the next. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface MenuRequest {
|
||||
@@ -24,6 +27,16 @@ export interface MenuRequest {
|
||||
items: MenuItem[];
|
||||
}
|
||||
|
||||
export interface TabMenuContext {
|
||||
/** The right-clicked tab (NOT necessarily the active one). */
|
||||
id: number;
|
||||
/** Position in the strip, for "close to the right". */
|
||||
index: number;
|
||||
tabCount: number;
|
||||
/** Any pane in this tab holds a live remote session. */
|
||||
isRemote: boolean;
|
||||
}
|
||||
|
||||
export interface RowMenuContext {
|
||||
path: string;
|
||||
name: string;
|
||||
@@ -114,6 +127,45 @@ export class ContextMenuService {
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace-tab menu. Actions carry the target tab's id, because the
|
||||
* right-clicked tab is not necessarily the active one — nothing here may
|
||||
* assume `activeWorkspace`.
|
||||
*
|
||||
* Everything illegal is DISABLED, not omitted: a menu whose items move
|
||||
* between right-clicks is a Win7 sin, and "Close Tab" greyed out explains
|
||||
* why the last tab cannot be closed better than its absence does.
|
||||
*
|
||||
* Duplicate/Rename/Save-session are deliberately absent until tabs have an
|
||||
* identity to duplicate (a stored connection id) — the sessions round.
|
||||
*/
|
||||
tabItems(ctx: TabMenuContext): MenuItem[] {
|
||||
const t = (key: string) => this.i18n.t(key);
|
||||
const last = ctx.tabCount <= 1;
|
||||
return [
|
||||
{ label: t('ctx.tabNew'), action: 'tab:new', accelerator: `${MOD_LABEL}T` },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: t('ctx.tabClose'),
|
||||
action: `tab:close:${ctx.id}`,
|
||||
accelerator: `${MOD_LABEL}W`,
|
||||
disabled: last,
|
||||
},
|
||||
{ label: t('ctx.tabCloseOthers'), action: `tab:close-others:${ctx.id}`, disabled: last },
|
||||
{
|
||||
label: t('ctx.tabCloseRight'),
|
||||
action: `tab:close-right:${ctx.id}`,
|
||||
disabled: ctx.index >= ctx.tabCount - 1,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: t('ctx.tabDisconnect'),
|
||||
action: `tab:disconnect:${ctx.id}`,
|
||||
disabled: !ctx.isRemote,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Column picker for the list headers: checkable toggles per column. */
|
||||
columnItems(visible: ColumnKey[]): MenuItem[] {
|
||||
return COLUMN_KEYS.map((key) => ({
|
||||
|
||||
@@ -398,6 +398,46 @@ export class PanelsService {
|
||||
this.refocus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close every tab except one — the tab menu's "Close Other Tabs".
|
||||
*
|
||||
* Snapshot the ids FIRST: closeWorkspace mutates the list on every call, so
|
||||
* iterating the live signal would skip half of them. It also refuses the
|
||||
* last tab, which is exactly the wanted floor here.
|
||||
*/
|
||||
closeOtherWorkspaces(id: number): void {
|
||||
for (const other of this.workspaces().map((w) => w.id)) {
|
||||
if (other !== id) this.closeWorkspace(other);
|
||||
}
|
||||
this.setActiveWorkspace(id);
|
||||
}
|
||||
|
||||
/** Close every tab to the right of one, in visual order. */
|
||||
closeWorkspacesToRight(id: number): void {
|
||||
const list = this.workspaces();
|
||||
const index = list.findIndex((w) => w.id === id);
|
||||
if (index < 0) return;
|
||||
for (const target of list.slice(index + 1).map((w) => w.id)) {
|
||||
this.closeWorkspace(target);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop both of a tab's remote sessions, keeping the tab itself. Unlike
|
||||
* `disconnectPanel` this targets a tab by id, because the right-clicked tab
|
||||
* is not necessarily the active one.
|
||||
*/
|
||||
async disconnectWorkspace(id: number): Promise<void> {
|
||||
const workspace = this.workspaces().find((w) => w.id === id);
|
||||
if (!workspace) return;
|
||||
for (const panel of [workspace.left, workspace.right]) {
|
||||
const sessionId = panel.sessionId();
|
||||
if (sessionId === 'local') continue;
|
||||
await this.rpc.request.disconnect({ sessionId });
|
||||
this.resetToLocal(panel);
|
||||
}
|
||||
}
|
||||
|
||||
setActiveWorkspace(id: number): void {
|
||||
if (this.activeWorkspaceId() !== id) {
|
||||
this.activeWorkspaceId.set(id);
|
||||
|
||||
@@ -128,6 +128,18 @@
|
||||
--w7-lv-head-grad: linear-gradient(180deg, #fff 45%, #fafafa 0, #f0f0f0);
|
||||
--w7-lv-head-bd: #d7d7d7;
|
||||
--w7-lv-cell-bd: #eee;
|
||||
/* Selected row. Explorer painted a FLAT blue, not a gradient — 7.css reuses
|
||||
its Aero element-hover gradient for the band, which is what made the list
|
||||
look glossy. These are dedicated tokens on purpose: --w7-el-grad-h is
|
||||
consumed by buttons and tabs and must keep its gradient.
|
||||
`-idle` is the unfocused pane's band. Explorer greyed the selection out
|
||||
when the list lost focus rather than dropping it, which is exactly the
|
||||
commander convention this app already follows (see the pane-focus model
|
||||
below): the band never disappears, only its strength changes. */
|
||||
--w7-sel-bg: #cbe8f6;
|
||||
--w7-sel-bd: #26a0da;
|
||||
--w7-sel-bg-idle: #dedede;
|
||||
--w7-sel-bd-idle: #c9c9c9;
|
||||
/* Tabs (tabs.css). 7.css hardcodes `color: #222` on every tablist button,
|
||||
outside its own var system, which is unreadable on the dark surface. */
|
||||
--w7-tab-c: #222;
|
||||
@@ -188,6 +200,10 @@
|
||||
--w7-lv-head-grad: linear-gradient(180deg, var(--w7-el-bg) 45%, var(--w7-el-bg-d) 0, var(--w7-el-bg-s-1));
|
||||
--w7-lv-head-bd: var(--w7-el-bd-d);
|
||||
--w7-lv-cell-bd: var(--w7-el-bd-d);
|
||||
--w7-sel-bg: #2a4a63;
|
||||
--w7-sel-bd: #4a8cbe;
|
||||
--w7-sel-bg-idle: #3a3a3a;
|
||||
--w7-sel-bd-idle: #4c4c4c;
|
||||
--w7-tab-c: var(--w7-el-c);
|
||||
--w7-mn-c: var(--w7-el-c);
|
||||
--w7-mn-bd: var(--w7-el-bd-d);
|
||||
@@ -245,7 +261,11 @@
|
||||
--w7-lv-head-grad: linear-gradient(180deg, var(--w7-el-bg) 45%, var(--w7-el-bg-d) 0, var(--w7-el-bg-s-1));
|
||||
--w7-lv-head-bd: var(--w7-el-bd-d);
|
||||
--w7-lv-cell-bd: var(--w7-el-bd-d);
|
||||
--w7-tab-c: var(--w7-el-c);
|
||||
--w7-sel-bg: #2a4a63;
|
||||
--w7-sel-bd: #4a8cbe;
|
||||
--w7-sel-bg-idle: #3a3a3a;
|
||||
--w7-sel-bd-idle: #4c4c4c;
|
||||
--w7-tab-c: var(--w7-el-c);
|
||||
--w7-mn-c: var(--w7-el-c);
|
||||
--w7-mn-bd: var(--w7-el-bd-d);
|
||||
--w7-mn-stripe: inset 1px 0 #00000059, inset -1px 0 #ffffff14;
|
||||
@@ -561,8 +581,8 @@ html.win7 body {
|
||||
border-right: none;
|
||||
}
|
||||
.win7 table > tbody > tr.highlighted {
|
||||
background: var(--w7-li-bg-hl);
|
||||
border-color: var(--w7-li-bd-hl);
|
||||
background: var(--w7-sel-bg-idle);
|
||||
border-color: var(--w7-sel-bd-idle);
|
||||
}
|
||||
|
||||
/* ---- Window frame ----
|
||||
@@ -645,7 +665,7 @@ html.win7 body {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.win7 table > tbody > tr.highlighted {
|
||||
border-color: var(--w7-li-bd-hl);
|
||||
border-color: var(--w7-sel-bd-idle);
|
||||
}
|
||||
|
||||
/* Pane-focus model (commander convention, matching Win7 Explorer):
|
||||
@@ -653,15 +673,19 @@ html.win7 body {
|
||||
tr.highlighted covers "selected via Space" AND "cursor row" (the template
|
||||
ORs them). What changes with pane focus is the band's strength, never
|
||||
whether it exists:
|
||||
- focused pane -> strong Aero band (below)
|
||||
- unfocused pane -> 7.css's own tr.highlighted, which IS Win7's
|
||||
inactive-selection wash — faint by design, not a bug.
|
||||
- focused pane -> the flat blue selection (below)
|
||||
- unfocused pane -> the grey idle band, which is what Explorer drew
|
||||
when the list lost focus — faint by design, not a bug.
|
||||
The dotted focus rectangle is NOT the focus signal; it is layered on top
|
||||
of the band in the focused pane only, the way Win7 drew the keyboard
|
||||
focus rect over the selected item. */
|
||||
focus rect over the selected item.
|
||||
FLAT, not a gradient: 7.css bands the row with --w7-el-grad-h, its Aero
|
||||
element-hover ramp, which reads as a glossy button rather than a file
|
||||
selection. Explorer used one solid fill and a solid border. Keep the
|
||||
1px border and 7.css's 3px radius — those ARE Explorer's selection box. */
|
||||
.win7 table.is-active > tbody > tr.highlighted {
|
||||
background: var(--w7-el-grad-h);
|
||||
border-color: var(--w7-el-bd-h);
|
||||
background: var(--w7-sel-bg);
|
||||
border-color: var(--w7-sel-bd);
|
||||
}
|
||||
|
||||
/* One dotted rectangle around the ROW; per cell (tr > *) draws a dotted box
|
||||
@@ -684,6 +708,24 @@ html.win7 body {
|
||||
color: var(--w7-mn-c);
|
||||
border-color: var(--w7-mn-bd);
|
||||
}
|
||||
|
||||
/* THE POSITIONING ONE — this is why right-click looked dead under the skin.
|
||||
menu.css declares `position: relative` on ul[role=menu] (it needs it for
|
||||
the gutter stripe's ::before and for submenu anchoring), and `skin`
|
||||
outranks `utilities`, so Tailwind's `.fixed` on the app's menu lost. Its
|
||||
[style.left/top.px] then became FLOW offsets from the menu's static
|
||||
position — the last child of an h-screen flex column — and every menu
|
||||
rendered below the viewport, unreachable behind `overflow: hidden`. The
|
||||
backdrop is a plain <div>, which 7.css does not match, so it stayed truly
|
||||
fixed and swallowed the next click: "right click does nothing, then the
|
||||
app ignores me once".
|
||||
Scoped to the app menu's own marker class, never blanket: 7.css's own
|
||||
relative positioning must survive for any other ul[role=menu].
|
||||
Regression test: scripts/menu-probe.ts (a resting pixel diff cannot see a
|
||||
positioning bug — nothing about the idle window changes). */
|
||||
.win7 ul[role='menu'].w7-ctx {
|
||||
position: fixed;
|
||||
}
|
||||
.win7 ul[role='menu']:before {
|
||||
box-shadow: var(--w7-mn-stripe);
|
||||
}
|
||||
|
||||
@@ -143,6 +143,10 @@ export class App {
|
||||
this.settings.toggleColumn(action.slice('col:'.length) as ColumnKey);
|
||||
return;
|
||||
}
|
||||
if (action.startsWith('tab:')) {
|
||||
this.handleTabAction(action.slice('tab:'.length));
|
||||
return;
|
||||
}
|
||||
if (action.startsWith('open-stack:')) {
|
||||
const id = action.slice('open-stack:'.length);
|
||||
let stackConnections = this.connections.connections().filter((c) => c.stackId === id);
|
||||
@@ -219,6 +223,35 @@ export class App {
|
||||
}
|
||||
|
||||
/** Context-menu actions apply to the row/panel remembered at right-click time. */
|
||||
/**
|
||||
* Workspace-tab menu actions. The target id travels INSIDE the action string
|
||||
* because the right-clicked tab is not necessarily the active one — nothing
|
||||
* here may fall back to activeWorkspaceId.
|
||||
*/
|
||||
private handleTabAction(action: string): void {
|
||||
if (action === 'new') {
|
||||
this.panels.newWorkspace();
|
||||
return;
|
||||
}
|
||||
const parsed = /^([a-z-]+):(\d+)$/.exec(action);
|
||||
if (!parsed) return;
|
||||
const id = Number(parsed[2]);
|
||||
switch (parsed[1]) {
|
||||
case 'close':
|
||||
this.panels.closeWorkspace(id);
|
||||
break;
|
||||
case 'close-others':
|
||||
this.panels.closeOtherWorkspaces(id);
|
||||
break;
|
||||
case 'close-right':
|
||||
this.panels.closeWorkspacesToRight(id);
|
||||
break;
|
||||
case 'disconnect':
|
||||
void this.panels.disconnectWorkspace(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleContextAction(action: string): void {
|
||||
const target = this.actions.menuTarget;
|
||||
if (!target) return;
|
||||
|
||||
@@ -33,6 +33,12 @@ interface MenuRow {
|
||||
* rendered div > button, concluded "7.css has no menu component", and
|
||||
* hand-built a lookalike. It does have one; the selectors just never matched.
|
||||
*
|
||||
* `w7-ctx` is OURS, not 7.css's: `ul[role=menu]` carries `position: relative`
|
||||
* in 7.css (for its gutter stripe and submenus), and the skin layer outranks
|
||||
* Tailwind's `.fixed` — so the menu laid out in flow, at the bottom of the
|
||||
* h-screen frame, off the viewport. The class scopes a `position: fixed`
|
||||
* restatement in 7like.css to THIS menu; see scripts/menu-probe.ts.
|
||||
*
|
||||
* Keyboard follows the platform: arrows move REAL focus between items (7.css
|
||||
* styles :focus-visible identically to :hover), Enter/Space on a button
|
||||
* activate it natively; only labels need a synthetic click.
|
||||
@@ -51,10 +57,17 @@ interface MenuRow {
|
||||
[style.left.px]="position().x"
|
||||
[style.top.px]="position().y"
|
||||
[style.width.rem]="menuWidthRem"
|
||||
class="fixed z-50 outline-none"
|
||||
class="w7-ctx fixed z-50 outline-none"
|
||||
>
|
||||
@for (row of rows(); track $index) {
|
||||
<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) {
|
||||
<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)">
|
||||
@@ -140,6 +153,9 @@ export class ContextMenu {
|
||||
// labels: don't let the click also toggle the real checkbox — the fresh
|
||||
// `checked` state comes back through settings when the menu reopens
|
||||
event?.preventDefault();
|
||||
// 7.css already blocks the pointer on [aria-disabled]; this covers the
|
||||
// keyboard path, which routes through focus and never consults CSS
|
||||
if (item.disabled) return;
|
||||
if (!item.action) return;
|
||||
const action = item.action;
|
||||
this.close();
|
||||
@@ -189,7 +205,13 @@ export class ContextMenu {
|
||||
private step(delta: number): void {
|
||||
const root = this.menuEl()?.nativeElement;
|
||||
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;
|
||||
const current = targets.indexOf(document.activeElement as HTMLElement);
|
||||
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 { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
|
||||
import type { AccentTone } from '@shared/types';
|
||||
import { ContextMenuService } from '@core/context-menu.service';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { PanelsService } from '@core/panels.service';
|
||||
import { PanelsService, type Workspace } from '@core/panels.service';
|
||||
import { APP_SKIN, MOD_LABEL } from '@core/platform';
|
||||
import { RpcService } from '@core/rpc.service';
|
||||
import { SettingsService } from '@core/settings.service';
|
||||
@@ -71,6 +72,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
[attr.data-tab-id]="ws.id"
|
||||
(click)="panels.setActiveWorkspace(ws.id)"
|
||||
(auxclick)="onAuxClick($event, ws.id)"
|
||||
(contextmenu)="onTabContextMenu($event, ws, i)"
|
||||
class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5 electrobun-webkit-app-region-no-drag"
|
||||
[class.opacity-50]="draggedId() === ws.id"
|
||||
[style.box-shadow]="dropIndicatorShadow(i)"
|
||||
@@ -467,6 +469,7 @@ export class TabBar {
|
||||
readonly settings = inject(SettingsService);
|
||||
readonly i18n = inject(I18nService);
|
||||
readonly updater = inject(UpdaterService);
|
||||
private readonly contextMenu = inject(ContextMenuService);
|
||||
private readonly rpc = inject(RpcService);
|
||||
readonly settingsOpen = signal(false);
|
||||
readonly localeOptions = SUPPORTED;
|
||||
@@ -526,6 +529,24 @@ export class TabBar {
|
||||
if (this.panels.workspaces().length > 1) this.close(event, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-click a tab. The menu targets THIS tab, not the active one — its
|
||||
* actions all carry the id — so right-clicking a background tab does not
|
||||
* yank focus to it first, the way Explorer and every browser behave.
|
||||
*/
|
||||
onTabContextMenu(event: MouseEvent, workspace: Workspace, index: number): void {
|
||||
event.preventDefault();
|
||||
this.contextMenu.open(
|
||||
event,
|
||||
this.contextMenu.tabItems({
|
||||
id: workspace.id,
|
||||
index,
|
||||
tabCount: this.panels.workspaces().length,
|
||||
isRemote: this.panels.workspaceIsRemote(workspace),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */
|
||||
editorAppName(): string {
|
||||
const path = this.settings.editorApp();
|
||||
|
||||
@@ -121,6 +121,10 @@ export class App {
|
||||
this.settings.toggleColumn(action.slice('col:'.length) as ColumnKey);
|
||||
return;
|
||||
}
|
||||
if (action.startsWith('tab:')) {
|
||||
this.handleTabAction(action.slice('tab:'.length));
|
||||
return;
|
||||
}
|
||||
if (action.startsWith('open-stack:')) {
|
||||
const id = action.slice('open-stack:'.length);
|
||||
let stackConnections = this.connections.connections().filter((c) => c.stackId === id);
|
||||
@@ -197,6 +201,35 @@ export class App {
|
||||
}
|
||||
|
||||
/** Context-menu actions apply to the row/panel remembered at right-click time. */
|
||||
/**
|
||||
* Workspace-tab menu actions. The target id travels INSIDE the action string
|
||||
* because the right-clicked tab is not necessarily the active one — nothing
|
||||
* here may fall back to activeWorkspaceId.
|
||||
*/
|
||||
private handleTabAction(action: string): void {
|
||||
if (action === 'new') {
|
||||
this.panels.newWorkspace();
|
||||
return;
|
||||
}
|
||||
const parsed = /^([a-z-]+):(\d+)$/.exec(action);
|
||||
if (!parsed) return;
|
||||
const id = Number(parsed[2]);
|
||||
switch (parsed[1]) {
|
||||
case 'close':
|
||||
this.panels.closeWorkspace(id);
|
||||
break;
|
||||
case 'close-others':
|
||||
this.panels.closeOtherWorkspaces(id);
|
||||
break;
|
||||
case 'close-right':
|
||||
this.panels.closeWorkspacesToRight(id);
|
||||
break;
|
||||
case 'disconnect':
|
||||
void this.panels.disconnectWorkspace(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleContextAction(action: string): void {
|
||||
const target = this.actions.menuTarget;
|
||||
if (!target) return;
|
||||
|
||||
@@ -51,10 +51,11 @@ const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
|
||||
type="button"
|
||||
role="menuitem"
|
||||
[title]="item.tooltip ?? ''"
|
||||
[class.bg-accent]="$index === activeIndex()"
|
||||
[disabled]="!!item.disabled"
|
||||
[class.bg-accent]="$index === activeIndex() && !item.disabled"
|
||||
(mouseenter)="activeIndex.set($index)"
|
||||
(click)="pick(item)"
|
||||
class="flex w-full items-center gap-2 px-3 py-1 text-left text-(length:--path-font) hover:bg-accent"
|
||||
class="flex w-full items-center gap-2 px-3 py-1 text-left text-(length:--path-font) hover:bg-accent disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<span class="w-3.5 shrink-0">
|
||||
@if (item.checked) {
|
||||
@@ -122,6 +123,8 @@ export class ContextMenu {
|
||||
}
|
||||
|
||||
pick(item: MenuItem): void {
|
||||
// the keyboard path never consults the disabled attribute — guard here
|
||||
if (item.disabled) return;
|
||||
if (!item.action) return;
|
||||
const action = item.action;
|
||||
this.close();
|
||||
@@ -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 {
|
||||
const count = items.length;
|
||||
if (!count) return;
|
||||
let index = this.activeIndex();
|
||||
for (let hops = 0; hops < count; hops++) {
|
||||
index = (index + delta + count) % count;
|
||||
if (items[index].type !== 'separator') {
|
||||
if (items[index].type !== 'separator' && !items[index].disabled) {
|
||||
this.activeIndex.set(index);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ import { NgIcon } from '@ng-icons/core';
|
||||
import { BRANDING } from '@shared/branding';
|
||||
import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
|
||||
import type { AccentTone } from '@shared/types';
|
||||
import { ContextMenuService } from '@core/context-menu.service';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { PanelsService } from '@core/panels.service';
|
||||
import { PanelsService, type Workspace } from '@core/panels.service';
|
||||
import { APP_SKIN, IS_MAC_CHROME, MOD_LABEL } from '@core/platform';
|
||||
import { RpcService } from '@core/rpc.service';
|
||||
import { SettingsService } from '@core/settings.service';
|
||||
@@ -58,6 +59,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
[class.bg-accent]="ws.id === panels.activeWorkspaceId()"
|
||||
[class.opacity-50]="draggedId() === ws.id"
|
||||
[style.box-shadow]="dropIndicatorShadow(i)"
|
||||
(contextmenu)="onTabContextMenu($event, ws, i)"
|
||||
draggable="true"
|
||||
(dragstart)="onDragStart($event, ws.id)"
|
||||
(dragover)="onDragOver($event, i)"
|
||||
@@ -473,6 +475,7 @@ export class TabBar {
|
||||
readonly settings = inject(SettingsService);
|
||||
readonly i18n = inject(I18nService);
|
||||
readonly updater = inject(UpdaterService);
|
||||
private readonly contextMenu = inject(ContextMenuService);
|
||||
private readonly rpc = inject(RpcService);
|
||||
readonly settingsOpen = signal(false);
|
||||
readonly localeOptions = SUPPORTED;
|
||||
@@ -519,6 +522,24 @@ export class TabBar {
|
||||
this.panels.closeWorkspace(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-click a tab. The menu targets THIS tab, not the active one — its
|
||||
* actions all carry the id — so right-clicking a background tab does not
|
||||
* yank focus to it first, the way Explorer and every browser behave.
|
||||
*/
|
||||
onTabContextMenu(event: MouseEvent, workspace: Workspace, index: number): void {
|
||||
event.preventDefault();
|
||||
this.contextMenu.open(
|
||||
event,
|
||||
this.contextMenu.tabItems({
|
||||
id: workspace.id,
|
||||
index,
|
||||
tabCount: this.panels.workspaces().length,
|
||||
isRemote: this.panels.workspaceIsRemote(workspace),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */
|
||||
editorAppName(): string {
|
||||
const path = this.settings.editorApp();
|
||||
|
||||
Reference in New Issue
Block a user