diff --git a/docs/architecture.md b/docs/architecture.md index ee83b5b..77d1d5b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -114,6 +114,12 @@ would float over editor windows). callback. mtime seconds→ms; mode masked `& 0o7777`. - `fs/registry.ts` — sessionId → provider map; `"local"` is built-in; `add()` mints `sftp-`; `drop()` (session died) vs `remove()` (clean). +- `fs/watcher.ts` — `LocalDirWatcher`: `fs.watch` handles keyed by dir, + debounced ~150 ms (watch fires bursts). Drives local auto-refresh: the main + webview sends `setWatchedDirs` (the active tab's local panel cwds); a change + fires `onChange` → `rpc.send.pathRefresh({sessionId:"local", dir})`. + `onChange` is late-bound in index.ts (the `events` pattern). SFTP has no + watch, so remote panels are polled UI-side instead (see panels.service). - `transfers/engine.ts` — FIFO job queue. `Semaphore` with live resize (`wake()` reserves slots synchronously — an async waiter increment was a race). Per-job worker pool (`MAX_WORKERS_PER_JOB=8`) draws from the shared @@ -172,7 +178,15 @@ would float over editor windows). auto-load `localDir` into an untouched local panel). `connectToken` supersedes stale connects. `Workspace` = tab = left/right pair + activeSide. Remote goes RIGHT on connect/open-stack (commander convention); per-panel - dropdown connects target their own panel. + dropdown connects target their own panel. **Auto-refresh** (always on): + `refresh({silent})` re-lists without the loading/busy flags and touches no + DOM (scroll/cursor/selection/filter preserved), snapshotting sessionId+cwd + to discard results if the user navigated mid-flight. Only the active tab is + live: its local panels are fs.watched (backend `setWatchedDirs` effect + + `pathRefresh` push → silent refresh), its remote panels polled every 3s + (`REMOTE_POLL_MS`); switching tabs silently refreshes the newly-active pair. + A future on/off setting is stubbed in commented breadcrumbs (types.ts, + settings.service, tab-bar). - `transfers.service.ts` — jobMap from `transferUpdate` callbacks; EMA speed (`SPEED_SMOOTHING=0.7`); `copyToOtherPanel` guards same-fs+same-dir before enqueue and runs the collision flow (same-path auto-skip, type-mismatch diff --git a/docs/gotchas.md b/docs/gotchas.md index fc873be..f742485 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -85,6 +85,12 @@ touching the adjacent code. don't shrink the 400 ms debounce. - `panel.touched` gates localDir auto-navigation; `connectToken` guards against a slow connect resolving into a panel the user already reused. +- **Silent auto-refresh must snapshot sessionId+cwd and discard a mismatched + result.** A normal `refresh()` is masked by `busy` (input blocked), but the + silent variant (poll/watch) runs while the user can navigate — without the + guard, a poll that started in dir A resolves and stamps A's listing over the + dir B the user just opened. It also never calls `scrollCursorIntoView` + (scroll must stay put) and returns early if `busy()` or already refreshing. ## Build / tooling diff --git a/src/bun/fs/watcher.ts b/src/bun/fs/watcher.ts new file mode 100644 index 0000000..1d5a28c --- /dev/null +++ b/src/bun/fs/watcher.ts @@ -0,0 +1,64 @@ +import { type FSWatcher, watch } from "node:fs"; + +const DEBOUNCE_MS = 150; + +/** + * Watches a set of local directories with node's fs.watch and fires onChange + * (debounced per dir — fs.watch emits bursts) when their contents change. Used + * to auto-refresh the active tab's local panels instantly; SFTP has no + * equivalent, so remote panels are polled UI-side instead. + * + * onChange is late-bound (assigned after the RPC exists, like AppDeps.events) + * so it can push pathRefresh to the main webview. + */ +export class LocalDirWatcher { + onChange: (dir: string) => void = () => {}; + private readonly handles = new Map(); + private readonly debounces = new Map>(); + + /** Replace the watched set: drop handles no longer wanted, open new ones. */ + setWatched(dirs: string[]): void { + const wanted = new Set(dirs); + for (const [dir, handle] of this.handles) { + if (!wanted.has(dir)) { + handle.close(); + this.handles.delete(dir); + } + } + for (const dir of wanted) { + if (this.handles.has(dir)) continue; + try { + const handle = watch(dir, () => this.fire(dir)); + // a watched dir that gets deleted/unmounted errors — just drop it + handle.on("error", () => this.drop(dir)); + this.handles.set(dir, handle); + } catch { + // dir vanished between listing and watching — ignore + } + } + } + + private fire(dir: string): void { + const pending = this.debounces.get(dir); + if (pending) clearTimeout(pending); + this.debounces.set( + dir, + setTimeout(() => { + this.debounces.delete(dir); + this.onChange(dir); + }, DEBOUNCE_MS), + ); + } + + private drop(dir: string): void { + this.handles.get(dir)?.close(); + this.handles.delete(dir); + } + + dispose(): void { + for (const handle of this.handles.values()) handle.close(); + this.handles.clear(); + for (const pending of this.debounces.values()) clearTimeout(pending); + this.debounces.clear(); + } +} diff --git a/src/bun/index.ts b/src/bun/index.ts index dbce496..b3348b2 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -6,6 +6,7 @@ import { PanelContextMenu } from "./context-menu"; import { EditManager } from "./edits"; import { EditorWindows } from "./editor-windows"; import { SessionRegistry } from "./fs/registry"; +import { LocalDirWatcher } from "./fs/watcher"; import { ManagerWindow } from "./manager-window"; import { applyApplicationMenu } from "./menu"; import { createAppRPC } from "./rpc-handlers"; @@ -45,6 +46,7 @@ const store = new ConnectionStore(); const settings = new SettingsStore(); const edits = new EditManager(registry, engine, settings); const contextMenu = new PanelContextMenu(); +const watcher = new LocalDirWatcher(); const events = { sessionClosed: (_info: { sessionId: string; reason: string }) => {}, connectionsChanged: () => {}, @@ -53,7 +55,9 @@ const events = { openConnectionManager: () => {}, showAbout: () => {}, }; -const rpc = createAppRPC({ registry, engine, store, settings, edits, contextMenu, events }); +const rpc = createAppRPC({ registry, engine, store, settings, edits, contextMenu, watcher, events }); +// late-bound like events: a watched local dir changed -> tell the main webview +watcher.onChange = (dir) => rpc.send.pathRefresh({ sessionId: "local", dir }); const url = await getMainViewUrl(); // Loaded before any window exists: the persisted theme is baked into every diff --git a/src/bun/rpc-handlers.ts b/src/bun/rpc-handlers.ts index ae3ff7d..633ba77 100644 --- a/src/bun/rpc-handlers.ts +++ b/src/bun/rpc-handlers.ts @@ -5,6 +5,7 @@ import type { PanelContextMenu } from "./context-menu"; import { readFileText, writeFileText, type EditManager } from "./edits"; import { listLocalDrives } from "./fs/local"; import type { SessionRegistry } from "./fs/registry"; +import type { LocalDirWatcher } from "./fs/watcher"; import { SftpProvider } from "./fs/sftp"; import type { ConnectionStore } from "./store/connections"; import type { SettingsStore } from "./store/settings"; @@ -24,6 +25,7 @@ export interface AppDeps { settings: SettingsStore; edits: EditManager; contextMenu: PanelContextMenu; + watcher: LocalDirWatcher; /** Assigned after the rpc object exists (it needs rpc.send). */ events: { sessionClosed: (info: { sessionId: string; reason: string }) => void; @@ -121,6 +123,7 @@ export function createAppRPC({ settings, edits, contextMenu, + watcher, events, }: AppDeps) { return BrowserView.defineRPC({ @@ -160,6 +163,12 @@ export function createAppRPC({ return { path: dir, entries: await fs.list(dir) }; }), + setWatchedDirs: ({ dirs }) => + wrap(async () => { + watcher.setWatched(dirs); + return {}; + }), + mkdir: ({ sessionId, path }) => wrap(async () => { await registry.get(sessionId).mkdir(path); diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts index 6909dd1..215f4c2 100644 --- a/src/shared/rpc.ts +++ b/src/shared/rpc.ts @@ -44,6 +44,9 @@ export type AppRPC = { params: { sessionId: string; path: string | null }; // null -> home dir response: Result<{ path: string; entries: FileEntry[] }>; }; + // Local dirs to fs.watch for auto-refresh (the active tab's local panels); + // a change pushes pathRefresh. Remote panels are polled UI-side instead. + setWatchedDirs: { params: { dirs: string[] }; response: Result }; mkdir: { params: { sessionId: string; path: string }; response: Result }; createFile: { params: { sessionId: string; path: string }; response: Result }; rename: { diff --git a/src/shared/types.ts b/src/shared/types.ts index 44c0ebd..7595433 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -95,6 +95,10 @@ export interface AppSettings { editor: "builtin" | "custom"; /** Full path to the external editor .app; empty = OS default application. */ editorApp: string; + // Auto-refresh the active tab's panels (local watched, remote polled). Always + // on for now; uncomment (+ the settings.service and tab-bar breadcrumbs, and + // gate the poll/watch in panels.service) to expose a toggle later. + // autoRefresh: boolean; } export const DEFAULT_SETTINGS: AppSettings = { @@ -110,6 +114,7 @@ export const DEFAULT_SETTINGS: AppSettings = { autoClearCompleted: true, editor: "builtin", editorApp: "", + // autoRefresh: true, }; export function effectiveConcurrency(settings: AppSettings): number { diff --git a/ui/src/app/panels.service.ts b/ui/src/app/panels.service.ts index 373b798..a8f3429 100644 --- a/ui/src/app/panels.service.ts +++ b/ui/src/app/panels.service.ts @@ -1,4 +1,4 @@ -import { Injectable, computed, inject, signal, type WritableSignal } from '@angular/core'; +import { Injectable, computed, effect, inject, signal, type WritableSignal } from '@angular/core'; import type { ColumnKey, ConnectionConfig, FileEntry, SavedConnection } from '@shared/types'; import { fileKind } from './file-icons'; import { FocusService } from './focus.service'; @@ -108,19 +108,34 @@ export class PanelState { } } - /** Reload cwd, keeping cursor position and selection by name. */ - async refresh(): Promise { + 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(); - this.loading.set(true); - const slowTimer = setTimeout(() => this.slowLoad.set(true), 300); + 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: this.sessionId(), - path: this.cwd(), + sessionId: forSession, + path: forDir, }); + if (this.sessionId() !== forSession || this.cwd() !== forDir) return; if (!res.ok) { - this.deps.toast.error(res.error); + if (!silent) this.deps.toast.error(res.error); return; } this.cwd.set(res.path); @@ -130,9 +145,12 @@ export class PanelState { 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); + if (slowTimer) clearTimeout(slowTimer); + if (!silent) { + this.slowLoad.set(false); + this.loading.set(false); + } + this.refreshing = false; } } @@ -237,12 +255,58 @@ export class PanelsService { } }); - // an editor window saved a file — refresh panels showing that directory + // 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(); + 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 ---- diff --git a/ui/src/app/settings.service.ts b/ui/src/app/settings.service.ts index 9be92e0..eb5507e 100644 --- a/ui/src/app/settings.service.ts +++ b/ui/src/app/settings.service.ts @@ -16,6 +16,11 @@ export class SettingsService { readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted); readonly editor = signal(DEFAULT_SETTINGS.editor); readonly editorApp = signal(DEFAULT_SETTINGS.editorApp); + // Auto-refresh is always on for now (see panels.service). To make it + // user-controllable: uncomment the field in types.ts, then here add + // readonly autoRefresh = signal(DEFAULT_SETTINGS.autoRefresh); + // load it in the constructor, add setAutoRefresh(), include it in settings(), + // gate the poll/watch in panels.service on it, and add the tab-bar row below. private readonly rpc = inject(RpcService); diff --git a/ui/src/app/tab-bar/tab-bar.ts b/ui/src/app/tab-bar/tab-bar.ts index 0df3151..d4279db 100644 --- a/ui/src/app/tab-bar/tab-bar.ts +++ b/ui/src/app/tab-bar/tab-bar.ts @@ -342,6 +342,23 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const; + +