Stage 1: extract the skin-agnostic UI layer into app/core/

First step of splitting the UI into two skin trees. All 12 services plus
platform.ts, format.ts, file-icons.ts, icons.ts and app.config.ts move to
ui/src/app/core/ — the layer both skins will share.

This is a pure move: git records all 17 as renames, and no file content
changed beyond import paths. The premise holds because `git diff
main...feat/7css-skin` touches none of these files — the service layer was
already skin-agnostic, so there is nothing to untangle, only to relocate.

Components now reach it through a new `@core/*` tsconfig alias rather than a
relative path. That is deliberate and load-bearing for the next stage: skin
components are about to move under app/skins/<skin>/ at varying depths, and
an alias keeps every one of these 67 imports correct wherever its file ends
up. `@shared/*` (the bun-side src/shared) already set the precedent.

Intra-core imports needed no edits at all — every ./sibling reference among
the moved files points at another moved file.

Verified: tsc clean, `bun run build:ui` clean (AOT resolves every import and
template symbol), and the running window's opaque interior is pixel-identical
to a pre-refactor capture.

Note on that comparison: the Aero frame and title bar do NOT compare equal
between launches, and that is not a regression — .window.glass::before runs
at opacity .6, so the chrome composites over whatever desktop is behind the
window and shifts by a uniform ~8% brightness. The check therefore diffs the
opaque interior (inset 30px sides, 80px top), where anything this refactor
could break would show.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-16 04:49:27 +02:00
co-authored by Claude
parent ab0bb4ef6f
commit 17bb1a14fb
33 changed files with 74 additions and 68 deletions
+188
View File
@@ -0,0 +1,188 @@
import { Injectable, inject } from '@angular/core';
import type { FileEntry } from '@shared/types';
import { ContextMenuService } from './context-menu.service';
import { DialogsService } from './dialogs.service';
import { joinPath } from './format';
import { type PanelState, type Side } from './panels.service';
import { IS_WINDOWS } from './platform';
import { RpcService } from './rpc.service';
import { SettingsService } from './settings.service';
import { I18nService } from './i18n.service';
import { ToastService } from './toast.service';
/** The row (or empty space) the user last right-clicked; ctx-* actions apply here. */
export interface MenuTarget {
side: Side;
panel: PanelState;
entry: FileEntry | null;
}
/**
* File operations invoked from a panel's toolbar / keyboard. They act on the
* GIVEN panel: on the selection if there is one, else on the cursor row.
*/
@Injectable({ providedIn: 'root' })
export class FileActionsService {
private readonly i18n = inject(I18nService);
private readonly rpc = inject(RpcService);
private readonly toast = inject(ToastService);
private readonly dialogs = inject(DialogsService);
private readonly menu = inject(ContextMenuService);
private readonly settings = inject(SettingsService);
menuTarget: MenuTarget | null = null;
/** Right-click entry point: remember the target, then open the menu on it. */
openContextMenu(event: MouseEvent, target: MenuTarget): void {
this.menuTarget = target;
const { panel, entry } = target;
this.menu.open(
event,
this.menu.rowItems({
path: entry?.path ?? panel.cwd(),
name: entry?.name ?? '',
type: entry?.type ?? 'dir',
isLocal: panel.sessionId() === 'local',
hasEntry: !!entry,
}),
);
}
async trash(panel: PanelState, entry: FileEntry): Promise<void> {
const res = await this.rpc.request.moveToTrash({ path: entry.path });
if (!res.ok) this.toast.error(res.error);
else this.toast.info(this.i18n.t('toasts.movedToTrash', { name: entry.name }));
await panel.refresh();
}
/** Selection if non-empty, else the cursor entry. Never the ".." parent row. */
targets(panel: PanelState): FileEntry[] {
const selected = panel.selectedEntries();
if (selected.length) return selected;
const atCursor = panel.sorted()[panel.cursor()];
return atCursor && atCursor.name !== '..' ? [atCursor] : [];
}
mkdir(panel: PanelState): void {
this.dialogs.prompt.set({
title: this.i18n.t('fileActions.newFolderTitle'),
placeholder: this.i18n.t('fileActions.newFolderPlaceholder'),
confirmLabel: this.i18n.t('fileActions.createConfirm'),
onConfirm: async (name) => {
const res = await this.rpc.request.mkdir({
sessionId: panel.sessionId(),
path: joinPath(panel.cwd(), name),
});
if (!res.ok) this.toast.error(res.error);
await panel.refresh();
},
});
}
createFile(panel: PanelState): void {
this.dialogs.prompt.set({
title: this.i18n.t('fileActions.newFileTitle'),
placeholder: this.i18n.t('fileActions.newFilePlaceholder'),
confirmLabel: this.i18n.t('fileActions.createConfirm'),
onConfirm: async (name) => {
const res = await this.rpc.request.createFile({
sessionId: panel.sessionId(),
path: joinPath(panel.cwd(), name),
});
if (!res.ok) this.toast.error(res.error);
await panel.refresh();
},
});
}
/** Open the cursor file in the built-in editor or the configured external app. */
async edit(panel: PanelState): Promise<void> {
const entry = panel.sorted()[panel.cursor()];
if (!entry || entry.type !== 'file' || entry.name === '..') return;
if (this.settings.editor() === 'custom') {
const res = await this.rpc.request.editExternal({
sessionId: panel.sessionId(),
path: entry.path,
});
if (!res.ok) {
this.toast.error(res.error);
} else if (res.watching) {
this.toast.info(this.i18n.t('toasts.editingExternal', { name: entry.name }));
}
return;
}
// built-in editor: its own native window (reads the file itself)
const res = await this.rpc.request.openEditor({
sessionId: panel.sessionId(),
path: entry.path,
name: entry.name,
sessionTitle: panel.title(),
});
if (!res.ok) this.toast.error(res.error);
}
rename(panel: PanelState): void {
const entry = panel.sorted()[panel.cursor()];
if (!entry || entry.name === '..') return;
this.dialogs.prompt.set({
title: this.i18n.t('fileActions.renameTitle', { name: entry.name }),
initial: entry.name,
confirmLabel: this.i18n.t('fileActions.renameConfirm'),
onConfirm: async (name) => {
if (name === entry.name) return;
const res = await this.rpc.request.rename({
sessionId: panel.sessionId(),
oldPath: entry.path,
newPath: joinPath(panel.cwd(), name),
});
if (!res.ok) this.toast.error(res.error);
await panel.refresh();
},
});
}
delete(panel: PanelState): void {
const targets = this.targets(panel);
if (!targets.length) return;
const what =
targets.length === 1
? `"${targets[0].name}"`
: this.i18n.t('fileActions.itemsCount', { count: targets.length });
this.dialogs.confirm.set({
title: this.i18n.t('fileActions.deleteTitle'),
body: this.i18n.t('fileActions.deleteBody', { what }),
confirmLabel: this.i18n.t('fileActions.deleteConfirm'),
destructive: true,
onConfirm: async () => {
const res = await this.rpc.request.deleteEntries({
sessionId: panel.sessionId(),
entries: targets.map(({ path, type }) => ({ path, type })),
});
if (!res.ok) this.toast.error(res.error);
await panel.refresh();
},
});
}
chmod(panel: PanelState): void {
// Windows local chmod only flips the read-only bit — don't pretend
if (IS_WINDOWS && panel.sessionId() === 'local') return;
const targets = this.targets(panel);
if (!targets.length) return;
this.dialogs.chmod.set({
targets: targets.map((t) => t.name),
initialMode: targets[0].mode,
onConfirm: async (mode) => {
const res = await this.rpc.request.chmod({
sessionId: panel.sessionId(),
paths: targets.map((t) => t.path),
mode,
});
if (!res.ok) this.toast.error(res.error);
await panel.refresh();
},
});
}
}