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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user