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>
189 lines
6.4 KiB
TypeScript
189 lines
6.4 KiB
TypeScript
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();
|
|
},
|
|
});
|
|
}
|
|
}
|