import { Injectable, computed, effect, inject, signal, type WritableSignal } from '@angular/core'; import type { ColumnKey, ConnectionConfig, FileEntry, SavedConnection } from '@shared/types'; import { fileKindKey } from './file-icons'; import { FocusService } from './focus.service'; import { formatMode, isRootPath, joinPath } from './format'; import { RpcService } from './rpc.service'; import { I18nService } from './i18n.service'; import { ToastService } from './toast.service'; export type Side = 'left' | 'right'; /** Any visible column sorts; Name is always available. */ export type SortKey = 'name' | ColumnKey; /** Display string the text-based columns sort by. */ function sortText(entry: FileEntry, key: 'kind' | 'rights' | 'owner' | 'group'): string { if (key === 'kind') return fileKindKey(entry); if (key === 'rights') return formatMode(entry.mode); return (key === 'owner' ? entry.owner : entry.group) ?? ''; } 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; else if (key !== 'name') cmp = sortText(a, key).localeCompare(sortText(b, key)); 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 && !isRootPath(cwd)) { list.unshift({ name: '..', path: joinPath(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); } } private refreshing = false; /** * Reload cwd, keeping cursor position and selection by name. `silent` (used by * auto-refresh) skips the loading/slowLoad flags — so no spinner, no dim * overlay, input never blocked — and touches no DOM, so scroll stays put. */ async refresh(opts?: { silent?: boolean }): Promise { const silent = opts?.silent ?? false; // a silent refresh must never fight the user or overlap itself if (silent && (this.refreshing || this.busy())) return; this.refreshing = true; // snapshot what we're listing; a silent refresh has no busy-block, so the // user can navigate mid-flight — discard the result if they did const forSession = this.sessionId(); const forDir = this.cwd(); const keepCursorName = this.sorted()[this.cursor()]?.name; const keepSelected = this.selected(); if (!silent) this.loading.set(true); const slowTimer = silent ? null : setTimeout(() => this.slowLoad.set(true), 300); try { const res = await this.deps.rpc.request.listDir({ sessionId: forSession, path: forDir, }); if (this.sessionId() !== forSession || this.cwd() !== forDir) return; if (!res.ok) { if (!silent) 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 { if (slowTimer) clearTimeout(slowTimer); if (!silent) { this.slowLoad.set(false); this.loading.set(false); } this.refreshing = 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(joinPath(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 { private readonly i18n = inject(I18nService); 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( this.i18n.t('toasts.disconnectedReason', { title: panel.title(), reason: event.reason, }), ); this.resetToLocal(panel); } } } }); // a watched local dir changed, or an editor saved a file — silently // refresh every panel showing that directory (no spinner, view preserved) this.rpc.onPathRefresh(({ sessionId, dir }) => { for (const panel of this.allPanels()) { if (panel.sessionId() === sessionId && panel.cwd() === dir) { void panel.refresh({ silent: true }); } } }); this.setupAutoRefresh(); } /** * Auto-refresh the active tab's panels without disturbing the view. Local * panels are watched instantly via fs.watch (backend pushes pathRefresh); * remote panels have no change notification, so they're polled. Only the * active tab is watched/polled; switching tabs refreshes the new one at once. */ private setupAutoRefresh(): void { // REMOTE_POLL_MS: also the cadence a future "auto-refresh interval" setting // would drive (see the commented breadcrumbs in settings). const REMOTE_POLL_MS = 3000; setInterval(() => { const ws = this.activeWorkspace(); if (!ws) return; for (const panel of [ws.left, ws.right]) { if (panel.sessionId() !== 'local') void panel.refresh({ silent: true }); } }, REMOTE_POLL_MS); // register the active tab's LOCAL dirs for fs.watch; re-runs when the active // tab changes or either panel navigates/(dis)connects effect(() => { const ws = this.activeWorkspace(); const dirs = ws ? [ws.left, ws.right] .filter((p) => p.sessionId() === 'local' && p.cwd()) .map((p) => p.cwd()) : []; void this.rpc.request.setWatchedDirs({ dirs: [...new Set(dirs)] }); }); // switching tabs shows fresh data immediately instead of waiting a poll tick let lastActive = this.activeWorkspaceId(); effect(() => { const id = this.activeWorkspaceId(); if (id === lastActive) return; lastActive = id; const ws = this.activeWorkspace(); if (ws) for (const panel of [ws.left, ws.right]) void panel.refresh({ silent: true }); }); } // ---- 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(); } /** * Close every tab except one — the tab menu's "Close Other Tabs". * * Snapshot the ids FIRST: closeWorkspace mutates the list on every call, so * iterating the live signal would skip half of them. It also refuses the * last tab, which is exactly the wanted floor here. */ closeOtherWorkspaces(id: number): void { for (const other of this.workspaces().map((w) => w.id)) { if (other !== id) this.closeWorkspace(other); } this.setActiveWorkspace(id); } /** Close every tab to the right of one, in visual order. */ closeWorkspacesToRight(id: number): void { const list = this.workspaces(); const index = list.findIndex((w) => w.id === id); if (index < 0) return; for (const target of list.slice(index + 1).map((w) => w.id)) { this.closeWorkspace(target); } } /** * Drop both of a tab's remote sessions, keeping the tab itself. Unlike * `disconnectPanel` this targets a tab by id, because the right-clicked tab * is not necessarily the active one. */ async disconnectWorkspace(id: number): Promise { const workspace = this.workspaces().find((w) => w.id === id); if (!workspace) return; for (const panel of [workspace.left, workspace.right]) { const sessionId = panel.sessionId(); if (sessionId === 'local') continue; await this.rpc.request.disconnect({ sessionId }); this.resetToLocal(panel); } } 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(' ⇄ ') : this.i18n.t('common.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); } }