Files
miniSCP/ui/src/app/panels.service.ts
T
JokinandClaude Fable 5 a150c5ee2e 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>
2026-07-11 13:35:02 +02:00

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));
}
}