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