P1: typed RPC bridge + FileSystemProvider + dual-panel local browsing
- 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>
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user