From b907e0a4ce5232ef195a1fae2a1948c4380a884c Mon Sep 17 00:00:00 2001 From: Jokin Date: Sat, 11 Jul 2026 13:38:40 +0200 Subject: [PATCH] P2: file operations with dialogs (mkdir, new file, rename, delete, chmod) - prompt/confirm/chmod dialogs (signals-driven via DialogsService) - FileActionsService: acts on selection or cursor row of the active panel - function bar + F2/F7/F8/Delete keys; delete confirms and recurses - verified provider ops + walkTree + removeRecursive on a scratch tree Co-Authored-By: Claude Fable 5 --- ui/src/app/app.html | 50 +++++++++++ ui/src/app/app.ts | 9 +- ui/src/app/dialogs.service.ts | 40 +++++++++ ui/src/app/dialogs/chmod-dialog.ts | 125 +++++++++++++++++++++++++++ ui/src/app/dialogs/confirm-dialog.ts | 64 ++++++++++++++ ui/src/app/dialogs/prompt-dialog.ts | 84 ++++++++++++++++++ ui/src/app/file-actions.service.ts | 123 ++++++++++++++++++++++++++ ui/src/app/file-panel/file-panel.ts | 15 +++- ui/src/app/format.ts | 5 ++ ui/src/app/panels.service.ts | 6 ++ 10 files changed, 519 insertions(+), 2 deletions(-) create mode 100644 ui/src/app/dialogs.service.ts create mode 100644 ui/src/app/dialogs/chmod-dialog.ts create mode 100644 ui/src/app/dialogs/confirm-dialog.ts create mode 100644 ui/src/app/dialogs/prompt-dialog.ts create mode 100644 ui/src/app/file-actions.service.ts diff --git a/ui/src/app/app.html b/ui/src/app/app.html index 2a79183..e7fb0c7 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -19,6 +19,56 @@ + + +
+ + + + + +
+ } + + + @if (dialogs.prompt(); as req) { + + } + @if (dialogs.confirm(); as req) { + + } + @if (dialogs.chmod(); as req) { + } diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index e95bf7c..7201489 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -1,19 +1,26 @@ import { Component, inject } from '@angular/core'; import { NgIcon } from '@ng-icons/core'; import { BRANDING } from '@shared/branding'; +import { ChmodDialog } from './dialogs/chmod-dialog'; +import { ConfirmDialog } from './dialogs/confirm-dialog'; +import { PromptDialog } from './dialogs/prompt-dialog'; +import { DialogsService } from './dialogs.service'; +import { FileActionsService } from './file-actions.service'; import { FilePanel } from './file-panel/file-panel'; import { RpcService } from './rpc.service'; import { ToastService } from './toast.service'; @Component({ selector: 'app-root', - imports: [NgIcon, FilePanel], + imports: [NgIcon, FilePanel, PromptDialog, ConfirmDialog, ChmodDialog], templateUrl: './app.html', }) export class App { readonly appName = BRANDING.appName; readonly rpc = inject(RpcService); readonly toast = inject(ToastService); + readonly dialogs = inject(DialogsService); + readonly actions = inject(FileActionsService); constructor() { document.title = BRANDING.appName; diff --git a/ui/src/app/dialogs.service.ts b/ui/src/app/dialogs.service.ts new file mode 100644 index 0000000..a76d701 --- /dev/null +++ b/ui/src/app/dialogs.service.ts @@ -0,0 +1,40 @@ +import { Injectable, signal } from '@angular/core'; + +export interface PromptRequest { + title: string; + placeholder?: string; + initial?: string; + confirmLabel: string; + onConfirm: (value: string) => void; +} + +export interface ConfirmRequest { + title: string; + body: string; + confirmLabel: string; + destructive?: boolean; + onConfirm: () => void; +} + +export interface ChmodRequest { + targets: string[]; // display names + initialMode: number; + onConfirm: (mode: number) => void; +} + +@Injectable({ providedIn: 'root' }) +export class DialogsService { + readonly prompt = signal(null); + readonly confirm = signal(null); + readonly chmod = signal(null); + + get anyOpen(): boolean { + return !!(this.prompt() || this.confirm() || this.chmod()); + } + + closeAll(): void { + this.prompt.set(null); + this.confirm.set(null); + this.chmod.set(null); + } +} diff --git a/ui/src/app/dialogs/chmod-dialog.ts b/ui/src/app/dialogs/chmod-dialog.ts new file mode 100644 index 0000000..8e2a4d7 --- /dev/null +++ b/ui/src/app/dialogs/chmod-dialog.ts @@ -0,0 +1,125 @@ +import { Component, computed, inject, input, signal } from '@angular/core'; +import { DialogsService, type ChmodRequest } from '../dialogs.service'; +import { PanelsService } from '../panels.service'; + +const ROLES = ['Owner', 'Group', 'Others'] as const; +const PERMS = ['r', 'w', 'x'] as const; + +@Component({ + selector: 'app-chmod-dialog', + template: ` +
+
+

Permissions

+

{{ targetLabel() }}

+ + + + + + + + + + + + @for (role of roles; track role; let r = $index) { + + + @for (perm of perms; track perm; let p = $index) { + + } + + } + +
ReadWriteExecute
{{ role }} + +
+ +
+ + +
+ +
+ + +
+
+
+ `, +}) +export class ChmodDialog { + readonly request = input.required(); + + readonly roles = ROLES; + readonly perms = PERMS; + + private readonly dialogs = inject(DialogsService); + private readonly panels = inject(PanelsService); + + // mode is initialized lazily from the request on first read + private readonly modeOverride = signal(null); + readonly mode = computed(() => this.modeOverride() ?? this.request().initialMode & 0o777); + readonly octal = computed(() => this.mode().toString(8).padStart(3, '0')); + + targetLabel(): string { + const targets = this.request().targets; + return targets.length === 1 ? targets[0] : `${targets.length} items`; + } + + hasBit(role: number, perm: number): boolean { + return !!(this.mode() & (0o400 >> (role * 3 + perm))); + } + + toggleBit(role: number, perm: number): void { + this.modeOverride.set(this.mode() ^ (0o400 >> (role * 3 + perm))); + } + + setOctal(text: string): void { + if (/^[0-7]{1,4}$/.test(text)) { + this.modeOverride.set(parseInt(text, 8) & 0o777); + } + } + + submit(): void { + const req = this.request(); + const mode = this.mode(); + this.close(); + req.onConfirm(mode); + } + + cancel(): void { + this.close(); + } + + private close(): void { + this.dialogs.chmod.set(null); + this.panels.refocus(); + } +} diff --git a/ui/src/app/dialogs/confirm-dialog.ts b/ui/src/app/dialogs/confirm-dialog.ts new file mode 100644 index 0000000..f95b19d --- /dev/null +++ b/ui/src/app/dialogs/confirm-dialog.ts @@ -0,0 +1,64 @@ +import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core'; +import { DialogsService, type ConfirmRequest } from '../dialogs.service'; +import { PanelsService } from '../panels.service'; + +@Component({ + selector: 'app-confirm-dialog', + template: ` +
+
+

{{ request().title }}

+

{{ request().body }}

+
+ + +
+
+
+ `, +}) +export class ConfirmDialog { + readonly request = input.required(); + + private readonly confirmBtn = viewChild.required>('confirmBtn'); + private readonly dialogs = inject(DialogsService); + private readonly panels = inject(PanelsService); + + constructor() { + afterNextRender(() => this.confirmBtn().nativeElement.focus()); + } + + submit(): void { + const req = this.request(); + this.close(); + req.onConfirm(); + } + + cancel(): void { + this.close(); + } + + private close(): void { + this.dialogs.confirm.set(null); + this.panels.refocus(); + } +} diff --git a/ui/src/app/dialogs/prompt-dialog.ts b/ui/src/app/dialogs/prompt-dialog.ts new file mode 100644 index 0000000..583e64f --- /dev/null +++ b/ui/src/app/dialogs/prompt-dialog.ts @@ -0,0 +1,84 @@ +import { + Component, + ElementRef, + afterNextRender, + inject, + input, + signal, + viewChild, +} from '@angular/core'; +import { DialogsService, type PromptRequest } from '../dialogs.service'; +import { PanelsService } from '../panels.service'; + +@Component({ + selector: 'app-prompt-dialog', + template: ` +
+
+

{{ request().title }}

+ +
+ + +
+
+
+ `, +}) +export class PromptDialog { + readonly request = input.required(); + readonly value = signal(''); + + private readonly box = viewChild.required>('box'); + private readonly dialogs = inject(DialogsService); + private readonly panels = inject(PanelsService); + + constructor() { + afterNextRender(() => { + this.value.set(this.request().initial ?? ''); + const el = this.box().nativeElement; + el.focus(); + el.select(); + }); + } + + submit(): void { + const value = this.value().trim(); + if (!value) return; + const req = this.request(); + this.close(); + req.onConfirm(value); + } + + cancel(): void { + this.close(); + } + + private close(): void { + this.dialogs.prompt.set(null); + this.panels.refocus(); + } +} diff --git a/ui/src/app/file-actions.service.ts b/ui/src/app/file-actions.service.ts new file mode 100644 index 0000000..0bf2a44 --- /dev/null +++ b/ui/src/app/file-actions.service.ts @@ -0,0 +1,123 @@ +import { Injectable, inject } from '@angular/core'; +import type { FileEntry } from '@shared/types'; +import { DialogsService } from './dialogs.service'; +import { joinPath } from './format'; +import { PanelsService, type PanelState } from './panels.service'; +import { RpcService } from './rpc.service'; +import { ToastService } from './toast.service'; + +/** + * File operations invoked from the function bar / keyboard. They always act on + * the ACTIVE panel: on the selection if there is one, else on the cursor row. + */ +@Injectable({ providedIn: 'root' }) +export class FileActionsService { + private readonly panels = inject(PanelsService); + private readonly rpc = inject(RpcService); + private readonly toast = inject(ToastService); + private readonly dialogs = inject(DialogsService); + + /** Selection if non-empty, else the cursor entry. */ + targets(panel: PanelState): FileEntry[] { + const selected = panel.selectedEntries(); + if (selected.length) return selected; + const atCursor = panel.sorted()[panel.cursor()]; + return atCursor ? [atCursor] : []; + } + + mkdir(): void { + const panel = this.panels.active(); + this.dialogs.prompt.set({ + title: 'New folder', + placeholder: 'folder name', + confirmLabel: 'Create', + 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(): void { + const panel = this.panels.active(); + this.dialogs.prompt.set({ + title: 'New file', + placeholder: 'file name', + confirmLabel: 'Create', + 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(); + }, + }); + } + + rename(): void { + const panel = this.panels.active(); + const entry = panel.sorted()[panel.cursor()]; + if (!entry) return; + this.dialogs.prompt.set({ + title: `Rename "${entry.name}"`, + initial: entry.name, + confirmLabel: 'Rename', + 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(): void { + const panel = this.panels.active(); + const targets = this.targets(panel); + if (!targets.length) return; + const what = + targets.length === 1 ? `"${targets[0].name}"` : `${targets.length} items`; + this.dialogs.confirm.set({ + title: 'Delete', + body: `Delete ${what}? Folders are deleted recursively. This cannot be undone.`, + confirmLabel: 'Delete', + 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(): void { + const panel = this.panels.active(); + 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(); + }, + }); + } +} diff --git a/ui/src/app/file-panel/file-panel.ts b/ui/src/app/file-panel/file-panel.ts index 957f99d..8fce7f0 100644 --- a/ui/src/app/file-panel/file-panel.ts +++ b/ui/src/app/file-panel/file-panel.ts @@ -10,6 +10,7 @@ import { } from '@angular/core'; import { NgIcon } from '@ng-icons/core'; import type { FileEntry } from '@shared/types'; +import { FileActionsService } from '../file-actions.service'; import { formatDate, formatSize } from '../format'; import { PanelsService, type Side, type SortKey } from '../panels.service'; @@ -23,6 +24,7 @@ export class FilePanel { readonly side = input.required(); readonly panels = inject(PanelsService); + readonly actions = inject(FileActionsService); readonly panel = computed(() => this.panels.get(this.side())); readonly isActive = computed(() => this.panels.activeSide() === this.side()); @@ -37,8 +39,9 @@ export class FilePanel { constructor() { // keep the editable path bar in sync with the actual cwd effect(() => this.pathDraft.set(this.panel().cwd())); - // focus follows the active side (Tab switches panels) + // focus follows the active side (Tab switches panels, dialogs refocus on close) effect(() => { + this.panels.focusTick(); if (this.isActive()) this.listEl().nativeElement.focus(); }); } @@ -138,6 +141,16 @@ export class FilePanel { case 'Backspace': panel.up(); break; + case 'F2': + this.actions.rename(); + break; + case 'F7': + this.actions.mkdir(); + break; + case 'F8': + case 'Delete': + this.actions.delete(); + break; default: return; // let unhandled keys through } diff --git a/ui/src/app/format.ts b/ui/src/app/format.ts index 0dd200a..2a01a30 100644 --- a/ui/src/app/format.ts +++ b/ui/src/app/format.ts @@ -23,6 +23,11 @@ export function formatDate(epochMs: number): string { return DATE_FORMAT.format(new Date(epochMs)); } +/** POSIX-style join for UI-composed paths (both panels use "/" on macOS + remote). */ +export function joinPath(dir: string, name: string): string { + return dir.endsWith('/') ? dir + name : `${dir}/${name}`; +} + /** 0o755 -> "rwxr-xr-x" */ export function formatMode(mode: number): string { const flags = 'rwxrwxrwx'; diff --git a/ui/src/app/panels.service.ts b/ui/src/app/panels.service.ts index 44a895a..022e69c 100644 --- a/ui/src/app/panels.service.ts +++ b/ui/src/app/panels.service.ts @@ -149,6 +149,8 @@ export class PanelsService { readonly left: PanelState; readonly right: PanelState; readonly activeSide = signal('left'); + /** Bumped to tell the active panel to grab keyboard focus again (e.g. after a dialog closes). */ + readonly focusTick = signal(0); constructor(rpc: RpcService, toast: ToastService) { const deps = { rpc, toast }; @@ -175,4 +177,8 @@ export class PanelsService { other(side: Side): PanelState { return this.get(this.otherSide(side)); } + + refocus(): void { + this.focusTick.update((n) => n + 1); + } }