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