Stage 2: move the win7 component tree into app/skins/7like/

All 14 components (including title-bar/, which has no modern counterpart)
move under app/skins/7like/. git records every one as a rename — no content
changed except main.ts.

The @core alias from stage 1 paid off exactly as intended: not one of the 67
service imports needed touching despite every file changing depth. The only
relative imports left inside the tree are skin-internal (app -> its dialogs,
connection-manager -> title-bar), and those survived because the move
preserved their relative structure.

Added skins/7like/index.ts exporting SEVEN_ROOTS { main, manager, editor }.
Every skin will export that same shape, so main.ts can pick a tree by skin
and then a root by APP_MODE without knowing either skin's internals. main.ts
selects from that barrel now, with the second axis still hardcoded until the
shadlike tree lands.

Verified: tsc clean, build:ui clean, opaque interior pixel-identical to the
pre-refactor baseline. That last check also proves Tailwind still scans the
templates from their new skins/** paths — a silent utility purge would have
collapsed the layout rather than reproducing it exactly.

The three structural 7.css contracts that a careless move breaks silently are
intact by construction, since no markup changed: app-title-bar is still a
direct child of .window.w7-frame, nothing new nests inside menu[role=tablist],
and the context menu still emits ul[role=menu] > li[role=menuitem] > button.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-16 04:50:53 +02:00
co-authored by Claude
parent 17bb1a14fb
commit 39e019af9f
21 changed files with 21 additions and 4 deletions
+253
View File
@@ -0,0 +1,253 @@
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 { 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 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('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;
}
}
/** Tab (workspace) shortcuts — panel-level keys live on the panels themselves. */
onWindowKeydown(event: KeyboardEvent): void {
if (this.managerOpen()) return; // modal-blocked behind the manager
const mod = event.metaKey || event.ctrlKey;
if (mod && event.key.toLowerCase() === 't') {
event.preventDefault();
this.panels.newWorkspace();
} else if (mod && event.key.toLowerCase() === 'w') {
event.preventDefault();
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
} else if (event.ctrlKey && event.key === 'Tab') {
event.preventDefault();
this.panels.cycleWorkspace(event.shiftKey ? -1 : 1);
} else if (mod && !event.shiftKey && !event.altKey) {
// ⌘1–⌘8 jump to that tab, ⌘9 to the last (Ctrl+digit elsewhere), like
// Firefox. Matched on event.code so the digit row works on any layout.
const digit = /^(?:Digit|Numpad)([1-9])$/.exec(event.code)?.[1];
if (!digit) return;
event.preventDefault();
this.panels.goToWorkspace(digit === '9' ? -1 : Number(digit) - 1);
}
}
/** Context-menu actions apply to the row/panel remembered at right-click time. */
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;
}
}
}