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([]); readonly loading = signal(false); readonly sortKey = signal('name'); readonly sortAsc = signal(true); readonly cursor = signal(0); readonly selected = signal>(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 { 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 { 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('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)); } }