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
+52 -10
View File
@@ -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);
}
+33
View File
@@ -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;
+25 -3
View File
@@ -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;
+22 -1
View File
@@ -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();
+33
View File
@@ -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;
+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"
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;
}
+22 -1
View File
@@ -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();