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 <noreply@anthropic.com>
This commit is contained in:
Jokin
2026-07-11 13:38:40 +02:00
co-authored by Claude Fable 5
parent a150c5ee2e
commit b907e0a4ce
10 changed files with 519 additions and 2 deletions
+50
View File
@@ -19,6 +19,56 @@
<app-file-panel side="left" /> <app-file-panel side="left" />
<app-file-panel side="right" /> <app-file-panel side="right" />
</main> </main>
<!-- function bar: acts on the active panel -->
<footer class="flex items-center gap-1 border-t border-border px-2 py-1">
<button
(click)="actions.rename()"
title="Rename (F2)"
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaEdit" size="0.9rem" /> Rename
</button>
<button
(click)="actions.mkdir()"
title="New folder (F7)"
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaFolderPlus" size="0.9rem" /> Folder
</button>
<button
(click)="actions.createFile()"
title="New file"
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaFilePlus" size="0.9rem" /> File
</button>
<button
(click)="actions.chmod()"
title="Permissions"
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaLock" size="0.9rem" /> Permissions
</button>
<button
(click)="actions.delete()"
title="Delete (F8 / Del)"
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-destructive"
>
<ng-icon name="mynaTrash" size="0.9rem" /> Delete
</button>
</footer>
}
<!-- dialogs -->
@if (dialogs.prompt(); as req) {
<app-prompt-dialog [request]="req" />
}
@if (dialogs.confirm(); as req) {
<app-confirm-dialog [request]="req" />
}
@if (dialogs.chmod(); as req) {
<app-chmod-dialog [request]="req" />
} }
<!-- toasts --> <!-- toasts -->
+8 -1
View File
@@ -1,19 +1,26 @@
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { BRANDING } from '@shared/branding'; 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 { FilePanel } from './file-panel/file-panel';
import { RpcService } from './rpc.service'; import { RpcService } from './rpc.service';
import { ToastService } from './toast.service'; import { ToastService } from './toast.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [NgIcon, FilePanel], imports: [NgIcon, FilePanel, PromptDialog, ConfirmDialog, ChmodDialog],
templateUrl: './app.html', templateUrl: './app.html',
}) })
export class App { export class App {
readonly appName = BRANDING.appName; readonly appName = BRANDING.appName;
readonly rpc = inject(RpcService); readonly rpc = inject(RpcService);
readonly toast = inject(ToastService); readonly toast = inject(ToastService);
readonly dialogs = inject(DialogsService);
readonly actions = inject(FileActionsService);
constructor() { constructor() {
document.title = BRANDING.appName; document.title = BRANDING.appName;
+40
View File
@@ -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<PromptRequest | null>(null);
readonly confirm = signal<ConfirmRequest | null>(null);
readonly chmod = signal<ChmodRequest | null>(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);
}
}
+125
View File
@@ -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: `
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
<div
class="w-80 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()"
(keydown.escape)="cancel()"
>
<h2 class="text-sm font-semibold">Permissions</h2>
<p class="mt-1 truncate text-xs text-muted-foreground">{{ targetLabel() }}</p>
<table class="mt-3 w-full text-xs">
<thead>
<tr class="text-muted-foreground">
<th class="pb-1 text-left font-normal"></th>
<th class="pb-1 font-normal">Read</th>
<th class="pb-1 font-normal">Write</th>
<th class="pb-1 font-normal">Execute</th>
</tr>
</thead>
<tbody>
@for (role of roles; track role; let r = $index) {
<tr>
<td class="py-1 pr-2 text-muted-foreground">{{ role }}</td>
@for (perm of perms; track perm; let p = $index) {
<td class="py-1 text-center">
<input
type="checkbox"
[checked]="hasBit(r, p)"
(change)="toggleBit(r, p)"
class="size-3.5 accent-[var(--primary)]"
/>
</td>
}
</tr>
}
</tbody>
</table>
<div class="mt-3 flex items-center gap-2">
<label class="text-xs text-muted-foreground">Octal</label>
<input
[value]="octal()"
(input)="setOctal($any($event.target).value)"
maxlength="4"
spellcheck="false"
class="h-7 w-20 rounded-md border border-input bg-transparent px-2 font-mono text-xs outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div class="mt-4 flex justify-end gap-2">
<button
(click)="cancel()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
Cancel
</button>
<button
(click)="submit()"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
Apply
</button>
</div>
</div>
</div>
`,
})
export class ChmodDialog {
readonly request = input.required<ChmodRequest>();
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<number | null>(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();
}
}
+64
View File
@@ -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: `
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
<div
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()"
(keydown.escape)="cancel()"
>
<h2 class="text-sm font-semibold">{{ request().title }}</h2>
<p class="mt-2 text-xs text-muted-foreground">{{ request().body }}</p>
<div class="mt-4 flex justify-end gap-2">
<button
(click)="cancel()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
Cancel
</button>
<button
#confirmBtn
(click)="submit()"
[class]="
request().destructive
? 'rounded-md bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground hover:bg-destructive/90'
: 'rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90'
"
>
{{ request().confirmLabel }}
</button>
</div>
</div>
</div>
`,
})
export class ConfirmDialog {
readonly request = input.required<ConfirmRequest>();
private readonly confirmBtn = viewChild.required<ElementRef<HTMLButtonElement>>('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();
}
}
+84
View File
@@ -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: `
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
<div
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()"
>
<h2 class="text-sm font-semibold">{{ request().title }}</h2>
<input
#box
[value]="value()"
(input)="value.set($any($event.target).value)"
(keydown.enter)="submit()"
(keydown.escape)="cancel()"
[placeholder]="request().placeholder ?? ''"
spellcheck="false"
class="mt-3 h-8 w-full rounded-md border border-input bg-transparent px-2 text-sm outline-none focus:ring-2 focus:ring-ring"
/>
<div class="mt-4 flex justify-end gap-2">
<button
(click)="cancel()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
Cancel
</button>
<button
(click)="submit()"
[disabled]="!value().trim()"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{{ request().confirmLabel }}
</button>
</div>
</div>
</div>
`,
})
export class PromptDialog {
readonly request = input.required<PromptRequest>();
readonly value = signal('');
private readonly box = viewChild.required<ElementRef<HTMLInputElement>>('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();
}
}
+123
View File
@@ -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();
},
});
}
}
+14 -1
View File
@@ -10,6 +10,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import type { FileEntry } from '@shared/types'; import type { FileEntry } from '@shared/types';
import { FileActionsService } from '../file-actions.service';
import { formatDate, formatSize } from '../format'; import { formatDate, formatSize } from '../format';
import { PanelsService, type Side, type SortKey } from '../panels.service'; import { PanelsService, type Side, type SortKey } from '../panels.service';
@@ -23,6 +24,7 @@ export class FilePanel {
readonly side = input.required<Side>(); readonly side = input.required<Side>();
readonly panels = inject(PanelsService); readonly panels = inject(PanelsService);
readonly actions = inject(FileActionsService);
readonly panel = computed(() => this.panels.get(this.side())); readonly panel = computed(() => this.panels.get(this.side()));
readonly isActive = computed(() => this.panels.activeSide() === this.side()); readonly isActive = computed(() => this.panels.activeSide() === this.side());
@@ -37,8 +39,9 @@ export class FilePanel {
constructor() { constructor() {
// keep the editable path bar in sync with the actual cwd // keep the editable path bar in sync with the actual cwd
effect(() => this.pathDraft.set(this.panel().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(() => { effect(() => {
this.panels.focusTick();
if (this.isActive()) this.listEl().nativeElement.focus(); if (this.isActive()) this.listEl().nativeElement.focus();
}); });
} }
@@ -138,6 +141,16 @@ export class FilePanel {
case 'Backspace': case 'Backspace':
panel.up(); panel.up();
break; break;
case 'F2':
this.actions.rename();
break;
case 'F7':
this.actions.mkdir();
break;
case 'F8':
case 'Delete':
this.actions.delete();
break;
default: default:
return; // let unhandled keys through return; // let unhandled keys through
} }
+5
View File
@@ -23,6 +23,11 @@ export function formatDate(epochMs: number): string {
return DATE_FORMAT.format(new Date(epochMs)); 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" */ /** 0o755 -> "rwxr-xr-x" */
export function formatMode(mode: number): string { export function formatMode(mode: number): string {
const flags = 'rwxrwxrwx'; const flags = 'rwxrwxrwx';
+6
View File
@@ -149,6 +149,8 @@ export class PanelsService {
readonly left: PanelState; readonly left: PanelState;
readonly right: PanelState; readonly right: PanelState;
readonly activeSide = signal<Side>('left'); readonly activeSide = signal<Side>('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) { constructor(rpc: RpcService, toast: ToastService) {
const deps = { rpc, toast }; const deps = { rpc, toast };
@@ -175,4 +177,8 @@ export class PanelsService {
other(side: Side): PanelState { other(side: Side): PanelState {
return this.get(this.otherSide(side)); return this.get(this.otherSide(side));
} }
refocus(): void {
this.focusTick.update((n) => n + 1);
}
} }