- shared/types + shared/rpc: full wire contract (Result envelope, no exceptions cross the bridge) - FileSystemProvider interface, LocalProvider, SessionRegistry with built-in 'local' - walk.ts: provider-generic recursive walk + removeRecursive - rpc-handlers wired into BrowserWindow; 10min request timeout for slow SFTP ops - Angular: RpcService (Electroview bridge + plain-browser guard), PanelsService with signals-based PanelState (sort/cursor/selection), FilePanel component with keyboard nav (arrows/Space/Enter/Backspace/Tab), path bar, toasts - verified: both panels listDir home over the bridge in the running app Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
179 lines
5.0 KiB
TypeScript
179 lines
5.0 KiB
TypeScript
import { Injectable, computed, signal } from '@angular/core';
|
|
import type { FileEntry } from '@shared/types';
|
|
import { RpcService } from './rpc.service';
|
|
import { ToastService } from './toast.service';
|
|
|
|
export type Side = 'left' | 'right';
|
|
export type SortKey = 'name' | 'size' | 'mtime';
|
|
|
|
interface PanelDeps {
|
|
rpc: RpcService;
|
|
toast: ToastService;
|
|
}
|
|
|
|
export class PanelState {
|
|
readonly sessionId = signal('local');
|
|
readonly title = signal('Local');
|
|
readonly cwd = signal('');
|
|
readonly entries = signal<FileEntry[]>([]);
|
|
readonly loading = signal(false);
|
|
readonly sortKey = signal<SortKey>('name');
|
|
readonly sortAsc = signal(true);
|
|
readonly cursor = signal(0);
|
|
readonly selected = signal<ReadonlySet<string>>(new Set());
|
|
|
|
/** Directories first, then the active sort column. */
|
|
readonly sorted = computed(() => {
|
|
const list = [...this.entries()];
|
|
const key = this.sortKey();
|
|
const dir = this.sortAsc() ? 1 : -1;
|
|
list.sort((a, b) => {
|
|
const aDir = a.type === 'dir';
|
|
const bDir = b.type === 'dir';
|
|
if (aDir !== bDir) return aDir ? -1 : 1;
|
|
let cmp = 0;
|
|
if (key === 'size') cmp = a.size - b.size;
|
|
else if (key === 'mtime') cmp = a.mtime - b.mtime;
|
|
if (cmp === 0) {
|
|
cmp = a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
|
|
if (key === 'name') return cmp * dir;
|
|
}
|
|
return cmp * dir;
|
|
});
|
|
return list;
|
|
});
|
|
|
|
readonly selectedEntries = computed(() => {
|
|
const names = this.selected();
|
|
return this.sorted().filter((e) => names.has(e.name));
|
|
});
|
|
|
|
constructor(private deps: PanelDeps) {}
|
|
|
|
async load(path: string | null): Promise<void> {
|
|
this.loading.set(true);
|
|
try {
|
|
const res = await this.deps.rpc.request.listDir({ sessionId: this.sessionId(), path });
|
|
if (!res.ok) {
|
|
this.deps.toast.error(res.error);
|
|
return;
|
|
}
|
|
this.cwd.set(res.path);
|
|
this.entries.set(res.entries);
|
|
this.cursor.set(0);
|
|
this.selected.set(new Set());
|
|
} finally {
|
|
this.loading.set(false);
|
|
}
|
|
}
|
|
|
|
/** Reload cwd, keeping cursor position and selection by name. */
|
|
async refresh(): Promise<void> {
|
|
const keepCursorName = this.sorted()[this.cursor()]?.name;
|
|
const keepSelected = this.selected();
|
|
this.loading.set(true);
|
|
try {
|
|
const res = await this.deps.rpc.request.listDir({
|
|
sessionId: this.sessionId(),
|
|
path: this.cwd(),
|
|
});
|
|
if (!res.ok) {
|
|
this.deps.toast.error(res.error);
|
|
return;
|
|
}
|
|
this.cwd.set(res.path);
|
|
this.entries.set(res.entries);
|
|
const names = new Set(res.entries.map((e) => e.name));
|
|
this.selected.set(new Set([...keepSelected].filter((n) => names.has(n))));
|
|
const idx = this.sorted().findIndex((e) => e.name === keepCursorName);
|
|
this.cursor.set(idx >= 0 ? idx : Math.min(this.cursor(), Math.max(0, this.sorted().length - 1)));
|
|
} finally {
|
|
this.loading.set(false);
|
|
}
|
|
}
|
|
|
|
open(entry: FileEntry): void {
|
|
if (entry.type === 'file') return;
|
|
// symlinks: try to open as a dir; the error toast covers file links
|
|
void this.load(entry.path);
|
|
}
|
|
|
|
up(): void {
|
|
if (!this.cwd()) return;
|
|
void this.load(`${this.cwd()}/..`);
|
|
}
|
|
|
|
toggleSort(key: SortKey): void {
|
|
if (this.sortKey() === key) {
|
|
this.sortAsc.update((v) => !v);
|
|
} else {
|
|
this.sortKey.set(key);
|
|
this.sortAsc.set(true);
|
|
}
|
|
}
|
|
|
|
moveCursor(delta: number): void {
|
|
const max = this.sorted().length - 1;
|
|
if (max < 0) return;
|
|
this.cursor.set(Math.max(0, Math.min(max, this.cursor() + delta)));
|
|
}
|
|
|
|
toggleSelect(name: string): void {
|
|
this.selected.update((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(name)) next.delete(name);
|
|
else next.add(name);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
selectRange(fromIndex: number, toIndex: number): void {
|
|
const [lo, hi] = fromIndex <= toIndex ? [fromIndex, toIndex] : [toIndex, fromIndex];
|
|
const names = this.sorted()
|
|
.slice(lo, hi + 1)
|
|
.map((e) => e.name);
|
|
this.selected.update((prev) => new Set([...prev, ...names]));
|
|
}
|
|
|
|
selectAll(): void {
|
|
this.selected.set(new Set(this.sorted().map((e) => e.name)));
|
|
}
|
|
|
|
clearSelection(): void {
|
|
if (this.selected().size) this.selected.set(new Set());
|
|
}
|
|
}
|
|
|
|
@Injectable({ providedIn: 'root' })
|
|
export class PanelsService {
|
|
readonly left: PanelState;
|
|
readonly right: PanelState;
|
|
readonly activeSide = signal<Side>('left');
|
|
|
|
constructor(rpc: RpcService, toast: ToastService) {
|
|
const deps = { rpc, toast };
|
|
this.left = new PanelState(deps);
|
|
this.right = new PanelState(deps);
|
|
if (rpc.available) {
|
|
void this.left.load(null);
|
|
void this.right.load(null);
|
|
}
|
|
}
|
|
|
|
get(side: Side): PanelState {
|
|
return side === 'left' ? this.left : this.right;
|
|
}
|
|
|
|
active(): PanelState {
|
|
return this.get(this.activeSide());
|
|
}
|
|
|
|
otherSide(side: Side): Side {
|
|
return side === 'left' ? 'right' : 'left';
|
|
}
|
|
|
|
other(side: Side): PanelState {
|
|
return this.get(this.otherSide(side));
|
|
}
|
|
}
|