import { Injectable, computed, inject, signal, type WritableSignal } from '@angular/core'; import type { ConnectionConfig, FileEntry, SavedConnection } from '@shared/types'; import { FocusService } from './focus.service'; 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'); /** Filesystem identity ("local" or resolvedIp:port) — self-copy guards use this. */ readonly fsKey = signal('local'); readonly title = signal('Local'); readonly connecting = signal(false); /** Name shown in the "Connecting to X…" placeholder while connecting. */ readonly connectingTo = signal(null); readonly cwd = signal(''); readonly entries = signal([]); readonly loading = signal(false); /** True once an in-flight load has taken >300ms — drives the dim overlay. */ readonly slowLoad = signal(false); /** Bumping this discards the result of any in-flight connect (cancel/supersede). */ connectToken = 0; /** True once the user navigated or connected — a "pristine" panel is still at local home. */ touched = false; /** Input on this panel is ignored while it's connecting or loading. */ readonly busy = computed(() => this.connecting() || this.loading()); readonly sortKey = signal('name'); readonly sortAsc = signal(true); readonly cursor = signal(0); readonly selected = signal>(new Set()); /** Type-to-filter query; entries not matching are hidden (".." always stays). */ readonly filter = signal(''); /** Directories first, then the active sort column. ".." (parent) is pinned on top. */ readonly sorted = computed(() => { const query = this.filter().toLowerCase(); let list = [...this.entries()]; if (query) { list = list.filter((e) => e.name.toLowerCase().includes(query)); } 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; }); const cwd = this.cwd(); if (cwd && cwd !== '/') { list.unshift({ name: '..', path: `${cwd}/..`, type: 'dir', size: 0, mtime: 0, mode: 0 }); } 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 { if (path !== null) this.touched = true; this.loading.set(true); const slowTimer = setTimeout(() => this.slowLoad.set(true), 300); 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()); this.filter.set(''); } finally { clearTimeout(slowTimer); this.slowLoad.set(false); 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); const slowTimer = setTimeout(() => this.slowLoad.set(true), 300); 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 { clearTimeout(slowTimer); this.slowLoad.set(false); 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()}/..`); } /** Update the filter and park the cursor on the first real match. */ setFilter(query: string): void { this.filter.set(query); const first = this.sorted().findIndex((e) => e.name !== '..'); this.cursor.set(first >= 0 ? first : 0); } 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 { if (name === '..') return; 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) .filter((n) => n !== '..'); this.selected.update((prev) => new Set([...prev, ...names])); } selectAll(): void { this.selected.set(new Set(this.sorted().map((e) => e.name).filter((n) => n !== '..'))); } clearSelection(): void { if (this.selected().size) this.selected.set(new Set()); } } /** One tab: an independent left/right panel pair. */ export interface Workspace { id: number; left: PanelState; right: PanelState; activeSide: WritableSignal; } @Injectable({ providedIn: 'root' }) export class PanelsService { readonly workspaces = signal([]); readonly activeWorkspaceId = signal(0); readonly activeWorkspace = computed(() => { const list = this.workspaces(); return list.find((w) => w.id === this.activeWorkspaceId()) ?? list[0]; }); private readonly focus = inject(FocusService); /** Bumped to tell the active panel to grab keyboard focus again (e.g. after a dialog closes). */ readonly focusTick = this.focus.tick; private nextWorkspaceId = 1; constructor( private rpc: RpcService, private toast: ToastService, ) { this.newWorkspace(); // a session died on its own — reset every panel bound to it, in any tab this.rpc.onSessionClosed((event) => { for (const workspace of this.workspaces()) { for (const panel of [workspace.left, workspace.right]) { if (panel.sessionId() === event.sessionId) { this.toast.error(`${panel.title()}: disconnected (${event.reason})`); this.resetToLocal(panel); } } } }); // an editor window saved a file — refresh panels showing that directory this.rpc.onPathRefresh(({ sessionId, dir }) => { for (const panel of this.allPanels()) { if (panel.sessionId() === sessionId && panel.cwd() === dir) void panel.refresh(); } }); } // ---- panels of the active workspace ---- get(side: Side): PanelState { const workspace = this.activeWorkspace(); return side === 'left' ? workspace.left : workspace.right; } activeSide(): Side { return this.activeWorkspace().activeSide(); } setActiveSide(side: Side): void { this.activeWorkspace().activeSide.set(side); } 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)); } /** Every panel across every workspace (for session-wide updates). */ allPanels(): PanelState[] { return this.workspaces().flatMap((w) => [w.left, w.right]); } refocus(): void { this.focus.bump(); } // ---- workspace (tab) management ---- newWorkspace(): void { const deps = { rpc: this.rpc, toast: this.toast }; const workspace: Workspace = { id: this.nextWorkspaceId++, left: new PanelState(deps), right: new PanelState(deps), activeSide: signal('left'), }; this.workspaces.update((list) => [...list, workspace]); this.activeWorkspaceId.set(workspace.id); if (this.rpc.available) { // may be called during template rendering, where signal writes inside // load() are forbidden — defer past the render pass queueMicrotask(() => { void workspace.left.load(null); void workspace.right.load(null); }); } this.refocus(); } /** Close a tab; its remote sessions are disconnected. The last tab stays. */ closeWorkspace(id: number): void { const list = this.workspaces(); if (list.length <= 1) return; const index = list.findIndex((w) => w.id === id); if (index < 0) return; const workspace = list[index]; for (const panel of [workspace.left, workspace.right]) { const sessionId = panel.sessionId(); if (sessionId !== 'local') { void this.rpc.request.disconnect({ sessionId }); } } this.workspaces.update((l) => l.filter((w) => w.id !== id)); if (this.activeWorkspaceId() === id) { const neighbor = list[index + 1] ?? list[index - 1]; this.activeWorkspaceId.set(neighbor.id); } this.refocus(); } setActiveWorkspace(id: number): void { if (this.activeWorkspaceId() !== id) { this.activeWorkspaceId.set(id); this.refocus(); } } /** * Open a stack: one tab per connection, left panel connecting (right stays * local, jumping to the connection's localDir when set). The first * connection reuses the current tab if it's pristine (local/local, never * navigated) instead of leaving a useless Local tab behind. */ openStack(connections: SavedConnection[]): void { for (const [index, connection] of connections.entries()) { if (index > 0 || !this.workspaceIsPristine(this.activeWorkspace())) { this.newWorkspace(); } // connectPanel resolves the panel synchronously (before awaiting), so // firing these back-to-back binds each connection to its own tab. // Remote goes right, local stays left — the commander convention. void this.connectPanel('right', connection, connection.name, connection.localDir); } } private workspaceIsPristine(workspace: Workspace): boolean { return [workspace.left, workspace.right].every( (p) => p.sessionId() === 'local' && !p.touched && !p.connecting(), ); } /** Reorder a tab (drag & drop). Order is purely visual; ids stay stable. */ moveWorkspace(id: number, toIndex: number): void { this.workspaces.update((list) => { const fromIndex = list.findIndex((w) => w.id === id); if (fromIndex < 0) return list; const next = [...list]; const [moved] = next.splice(fromIndex, 1); next.splice(Math.max(0, Math.min(next.length, toIndex)), 0, moved); return next; }); } cycleWorkspace(delta: 1 | -1): void { const list = this.workspaces(); if (list.length < 2) return; const index = list.findIndex((w) => w.id === this.activeWorkspaceId()); const next = list[(index + delta + list.length) % list.length]; this.setActiveWorkspace(next.id); } /** Jump to a tab by visual position; negative counts from the end (⌘9 = last). */ goToWorkspace(index: number): void { const target = this.workspaces().at(index); if (target) this.setActiveWorkspace(target.id); } /** Tab label: "Local" for a local/local pair, else the remote titles. */ workspaceName(workspace: Workspace): string { const remoteTitles = [workspace.left, workspace.right] .filter((p) => p.sessionId() !== 'local') .map((p) => p.title()); return remoteTitles.length ? remoteTitles.join(' ⇄ ') : 'Local'; } workspaceIsRemote(workspace: Workspace): boolean { return workspace.left.sessionId() !== 'local' || workspace.right.sessionId() !== 'local'; } // ---- session binding ---- /** * Connect a panel to a server; replaces (and disconnects) its previous remote * session. When the connection has a localDir and the workspace's other panel * is local and untouched, that panel jumps to the localDir. */ async connectPanel( side: Side, config: ConnectionConfig, title: string, localDir?: string | null, ): Promise { const workspace = this.activeWorkspace(); const panel = workspace[side]; const token = ++panel.connectToken; panel.connecting.set(true); panel.connectingTo.set(title); try { const res = await this.rpc.request.connect({ config }); if (panel.connectToken !== token) { // cancelled or superseded while waiting — clean up the orphan session if (res.ok) void this.rpc.request.disconnect({ sessionId: res.sessionId }); return false; } if (!res.ok) { this.toast.error(`${title}: ${res.error}`); return false; } const previous = panel.sessionId(); if (previous !== 'local') { void this.rpc.request.disconnect({ sessionId: previous }); } panel.sessionId.set(res.sessionId); panel.fsKey.set(res.fsKey); panel.title.set(title); panel.touched = true; const other = workspace[side === 'left' ? 'right' : 'left']; if (localDir && other.sessionId() === 'local' && !other.touched) { void other.load(localDir); } await panel.load(config.initialPath ?? res.homeDir); return true; } finally { if (panel.connectToken === token) { panel.connecting.set(false); panel.connectingTo.set(null); } } } /** Abort waiting on an in-flight connect; the panel keeps its previous state. */ cancelConnect(side: Side): void { const panel = this.get(side); if (!panel.connecting()) return; panel.connectToken++; panel.connecting.set(false); panel.connectingTo.set(null); } async disconnectPanel(side: Side): Promise { const panel = this.get(side); const sessionId = panel.sessionId(); if (sessionId === 'local') return; await this.rpc.request.disconnect({ sessionId }); this.resetToLocal(panel); } private resetToLocal(panel: PanelState): void { panel.sessionId.set('local'); panel.fsKey.set('local'); panel.title.set('Local'); void panel.load(null); } }