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>
306 lines
11 KiB
TypeScript
306 lines
11 KiB
TypeScript
import { Component, inject, signal } from '@angular/core';
|
|
import { NgIcon } from '@ng-icons/core';
|
|
import { I18nService } from '@core/i18n.service';
|
|
import { BRANDING } from '@shared/branding';
|
|
import type { ColumnKey } from '@shared/types';
|
|
import { ConnectionsService } from '@core/connections.service';
|
|
import { ContextMenu } from './context-menu';
|
|
import { ChmodDialog } from './dialogs/chmod-dialog';
|
|
import { ConfirmDialog } from './dialogs/confirm-dialog';
|
|
import { OverwriteDialog } from './dialogs/overwrite-dialog';
|
|
import { PromptDialog } from './dialogs/prompt-dialog';
|
|
import { DialogsService } from '@core/dialogs.service';
|
|
import { FileActionsService } from '@core/file-actions.service';
|
|
import { FilePanel } from './file-panel/file-panel';
|
|
import { PanelsService } from '@core/panels.service';
|
|
import { blockNativeContextMenu } from '@core/platform';
|
|
import { RpcService } from '@core/rpc.service';
|
|
import { SettingsService } from '@core/settings.service';
|
|
import { ShortcutsService } from '@core/shortcuts.service';
|
|
import { StatusBar } from './status-bar/status-bar';
|
|
import { TabBar } from './tab-bar/tab-bar';
|
|
import { TitleBar } from './title-bar/title-bar';
|
|
import { ToastService } from '@core/toast.service';
|
|
import { Toasts } from './toasts/toasts';
|
|
import { TransferBar } from './transfer-bar/transfer-bar';
|
|
import { TransfersService } from '@core/transfers.service';
|
|
import { UpdaterService } from '@core/updater.service';
|
|
|
|
@Component({
|
|
selector: 'app-root',
|
|
imports: [
|
|
NgIcon,
|
|
FilePanel,
|
|
TabBar,
|
|
StatusBar,
|
|
PromptDialog,
|
|
ConfirmDialog,
|
|
ChmodDialog,
|
|
OverwriteDialog,
|
|
Toasts,
|
|
TransferBar,
|
|
TitleBar,
|
|
ContextMenu,
|
|
],
|
|
templateUrl: './app.html',
|
|
host: {
|
|
'(window:keydown)': 'onWindowKeydown($event)',
|
|
'(window:contextmenu)': 'blockContextMenu($event)',
|
|
// Aero greys an inactive window's title bar and controls. The webview gets
|
|
// DOM focus/blur when the native window's focus changes, so this needs no
|
|
// RPC — see the `active` binding on .window in app.html.
|
|
'(window:focus)': 'windowFocused.set(true)',
|
|
'(window:blur)': 'windowFocused.set(false)',
|
|
},
|
|
})
|
|
export class App {
|
|
readonly i18n = inject(I18nService);
|
|
readonly rpc = inject(RpcService);
|
|
readonly toast = inject(ToastService);
|
|
readonly dialogs = inject(DialogsService);
|
|
readonly panels = inject(PanelsService);
|
|
readonly actions = inject(FileActionsService);
|
|
readonly connections = inject(ConnectionsService);
|
|
private readonly transfers = inject(TransfersService);
|
|
private readonly settings = inject(SettingsService);
|
|
private readonly shortcuts = inject(ShortcutsService);
|
|
private readonly updater = inject(UpdaterService);
|
|
|
|
/** The connection-manager window is open — this window is modal-blocked. */
|
|
readonly managerOpen = signal(false);
|
|
|
|
/** Drives `.window.active`: Aero desaturates an unfocused window's chrome. */
|
|
readonly windowFocused = signal(document.hasFocus());
|
|
|
|
/** Shown in the win7 title bar (no native titlebar to carry it). */
|
|
readonly appName = BRANDING.appName;
|
|
|
|
/**
|
|
* win7 title bar's X. Closing the main window quits, and the app-level
|
|
* before-quit guard (dirty editors + running transfers) still runs — it is
|
|
* the one close path electrobun lets us cancel.
|
|
*/
|
|
closeWindow(): void {
|
|
void this.rpc.request.windowClose({});
|
|
}
|
|
|
|
constructor() {
|
|
document.title = BRANDING.appName;
|
|
if (this.rpc.available) {
|
|
this.rpc.onMenuAction((action) => void this.handleMenuAction(action));
|
|
this.rpc.onManagerState((open) => this.managerOpen.set(open));
|
|
void this.promptForEditLeftovers();
|
|
}
|
|
}
|
|
|
|
focusManager(): void {
|
|
void this.rpc.request.openConnectionManager({});
|
|
}
|
|
|
|
blockContextMenu(event: Event): void {
|
|
blockNativeContextMenu(event);
|
|
}
|
|
|
|
/** Temp files from external edits in a previous run (crash / quit mid-edit). */
|
|
private async promptForEditLeftovers(): Promise<void> {
|
|
const res = await this.rpc.request.getEditLeftovers({});
|
|
if (!res.ok || !res.names.length) return;
|
|
const shown = res.names.slice(0, 5).join(', ');
|
|
const more =
|
|
res.names.length > 5 ? this.i18n.t('toasts.leftoverMore', { count: res.names.length - 5 }) : '';
|
|
this.dialogs.confirm.set({
|
|
title: this.i18n.t('toasts.leftoverTitle'),
|
|
body: this.i18n.t('toasts.leftoverBody', { count: res.names.length, names: shown + more }),
|
|
confirmLabel: this.i18n.t('toasts.leftoverClear'),
|
|
destructive: true,
|
|
onConfirm: () => void this.rpc.request.clearEditLeftovers({}),
|
|
});
|
|
}
|
|
|
|
/** A connection by id; reloads once if unseen (a manager-window save can
|
|
* race the connectionsChanged push). */
|
|
private async findConnection(id: string) {
|
|
const lookup = () => this.connections.connections().find((c) => c.id === id);
|
|
if (!lookup()) await this.connections.reload();
|
|
return lookup();
|
|
}
|
|
|
|
/** Native application-menu / manager-window actions forwarded from bun. */
|
|
private async handleMenuAction(action: string): Promise<void> {
|
|
if (action.startsWith('ctx-')) {
|
|
this.handleContextAction(action);
|
|
return;
|
|
}
|
|
if (action.startsWith('connect:')) {
|
|
const connection = await this.findConnection(action.slice('connect:'.length));
|
|
if (connection) {
|
|
// remote goes right, local stays left — the commander convention
|
|
void this.panels.connectPanel('right', connection, connection.name, connection.localDir);
|
|
}
|
|
return;
|
|
}
|
|
if (action.startsWith('col:')) {
|
|
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);
|
|
if (!stackConnections.length) {
|
|
await this.connections.reload();
|
|
stackConnections = this.connections.connections().filter((c) => c.stackId === id);
|
|
}
|
|
if (stackConnections.length) this.panels.openStack(stackConnections);
|
|
return;
|
|
}
|
|
switch (action) {
|
|
case 'new-tab':
|
|
this.panels.newWorkspace();
|
|
break;
|
|
case 'close-tab':
|
|
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
|
|
break;
|
|
case 'new-folder':
|
|
this.actions.mkdir(this.panels.active());
|
|
break;
|
|
case 'new-file':
|
|
this.actions.createFile(this.panels.active());
|
|
break;
|
|
case 'edit-file':
|
|
void this.actions.edit(this.panels.active());
|
|
break;
|
|
case 'refresh-active':
|
|
void this.panels.active().refresh();
|
|
break;
|
|
case 'check-for-updates':
|
|
void this.updater.check();
|
|
break;
|
|
}
|
|
}
|
|
|
|
/** Window shortcuts — matching lives in ShortcutsService (one keymap for the
|
|
* app); this switch only maps ids onto actions. Panel-level keys stay on
|
|
* the panels themselves. */
|
|
onWindowKeydown(event: KeyboardEvent): void {
|
|
if (this.managerOpen()) return; // modal-blocked behind the manager
|
|
const match = this.shortcuts.resolve(event, 'main');
|
|
if (!match) return;
|
|
// scale keys are hostile mid-typing ('-' in a rename prompt must type '-');
|
|
// the tab shortcuts keep firing there, as they always did
|
|
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
|
|
event.preventDefault();
|
|
switch (match.id) {
|
|
case 'new-tab':
|
|
this.panels.newWorkspace();
|
|
break;
|
|
case 'close-tab':
|
|
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
|
|
break;
|
|
case 'cycle-tab':
|
|
this.panels.cycleWorkspace(1);
|
|
break;
|
|
case 'cycle-tab-back':
|
|
this.panels.cycleWorkspace(-1);
|
|
break;
|
|
case 'go-to-tab':
|
|
// ⌘1–⌘8 jump to that tab, ⌘9 to the last, like Firefox
|
|
this.panels.goToWorkspace(match.digit === 9 ? -1 : match.digit! - 1);
|
|
break;
|
|
case 'scale-up':
|
|
this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
|
break;
|
|
case 'scale-down':
|
|
this.settings.setUiScale(this.settings.uiScale() - 0.1);
|
|
break;
|
|
case 'scale-reset':
|
|
this.settings.setUiScale(1);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/** 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;
|
|
const { panel, side, entry } = target;
|
|
// the empty-space menu acts on the directory itself
|
|
const path = entry?.path ?? panel.cwd();
|
|
switch (action) {
|
|
case 'ctx-open':
|
|
if (entry) panel.open(entry);
|
|
break;
|
|
// local shell integration — only bun can reach the OS shell/clipboard
|
|
case 'ctx-reveal':
|
|
void this.rpc.request.shellItemAction({ action: 'reveal', path });
|
|
break;
|
|
case 'ctx-open-default':
|
|
void this.rpc.request.shellItemAction({ action: 'open', path });
|
|
break;
|
|
case 'ctx-copy-path':
|
|
void this.rpc.request.shellItemAction({ action: 'copyPath', path });
|
|
break;
|
|
case 'ctx-edit':
|
|
void this.actions.edit(panel);
|
|
break;
|
|
case 'ctx-copy':
|
|
void this.transfers.copyToOtherPanel(side);
|
|
break;
|
|
case 'ctx-rename':
|
|
this.actions.rename(panel);
|
|
break;
|
|
case 'ctx-chmod':
|
|
this.actions.chmod(panel);
|
|
break;
|
|
case 'ctx-delete':
|
|
this.actions.delete(panel);
|
|
break;
|
|
case 'ctx-trash':
|
|
if (entry) void this.actions.trash(panel, entry);
|
|
break;
|
|
case 'ctx-new-folder':
|
|
this.actions.mkdir(panel);
|
|
break;
|
|
case 'ctx-new-file':
|
|
this.actions.createFile(panel);
|
|
break;
|
|
case 'ctx-refresh':
|
|
void panel.refresh();
|
|
break;
|
|
}
|
|
}
|
|
|
|
}
|