From 0caacbd6ae1655af3eb741a290f3663b58d8e476 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:22:14 +0200 Subject: [PATCH 1/9] fix(move): a large multi-selection no longer runs an O(n^2) containment loop on main Each selected path walks its own ancestors against a key set instead of asking every other path (4.6 s frozen for 2,000 items). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DzqPzR6LuAvhiDabNwqzpx --- src/main/moveOps.test.ts | 18 +++++++++++++++++- src/main/moveOps.ts | 32 ++++++++++++++++++++++++++------ 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/main/moveOps.test.ts b/src/main/moveOps.test.ts index e6ce9f64..16b4fa7b 100644 --- a/src/main/moveOps.test.ts +++ b/src/main/moveOps.test.ts @@ -3,7 +3,7 @@ import { spawn } from 'child_process' import { tmpdir } from 'os' import { join } from 'path' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { insideSelf, moveEntries } from './moveOps' +import { insideSelf, moveEntries, outermost } from './moveOps' let root: string const trash = vi.fn(async () => {}) @@ -32,6 +32,22 @@ describe('insideSelf', () => { }) }) +describe('outermost', () => { + it('drops what another selected path contains, keeps siblings and look-alikes', () => { + expect( + outermost(['C:\\a', 'c:/A/b', 'C:\\a\\b\\c.txt', 'C:\\ab', 'D:\\x', 'D:\\x\\']) + ).toEqual(['C:\\a', 'C:\\ab', 'D:\\x', 'D:\\x\\']) + expect(outermost(['C:\\', 'C:\\a'])).toEqual(['C:\\']) + }) + + it('stays fast on a huge selection (2026-10-16: the pairwise loop froze main)', () => { + const paths = Array.from({ length: 20000 }, (_, i) => `C:\\pics\\dump\\IMG_${i}.jpg`) + const t = performance.now() + expect(outermost(paths)).toHaveLength(20000) + expect(performance.now() - t).toBeLessThan(1000) + }) +}) + describe('moveEntries', () => { it('moves files and reports where they landed', async () => { const r = await moveEntries([join(root, 'a.txt')], join(root, 'dest'), 'ask', trash) diff --git a/src/main/moveOps.ts b/src/main/moveOps.ts index c5611a2c..fd7e51b0 100644 --- a/src/main/moveOps.ts +++ b/src/main/moveOps.ts @@ -21,6 +21,26 @@ export function insideSelf(src: string, dest: string): boolean { return isInside(resolve(src), resolve(dest), { orEqual: true }) } +/** + * The selected paths that no OTHER selected path contains: a folder and + * something inside it can both be selected, and moving the folder already + * carries the child. Each path walks up its own ancestors against a set of + * keys, O(n x depth). It used to ask every other path in turn, about four + * `resolve`s a pair with no await between them: 4.6 s of frozen main for a + * 2,000-item drag, minutes at 10k (2026-10-16). + */ +export function outermost(paths: readonly string[]): string[] { + const keys = new Set(paths.map(lower)) + return paths.filter((p) => { + let k = lower(p) + for (let i = k.lastIndexOf('\\'); i > 0; i = k.lastIndexOf('\\')) { + k = k.slice(0, i) + if (keys.has(k)) return false + } + return true + }) +} + /** * One move, rename first: a rename is atomic on the same volume. Only a * CROSS-VOLUME move falls back to copy-then-remove. All of it is async: copying @@ -140,16 +160,16 @@ export async function moveEntries( return { moved: [], clashes: [], failed: [...paths], replaced: [], busy: [] } } const candidates: string[] = [] - for (const p of paths) { - // A folder and something inside it can both be selected; moving the - // folder already carries the child, so the child is not a failure - it - // simply has nothing left to do. - if (paths.some((q) => lower(q) !== lower(p) && insideSelf(q, p))) continue + const destKey = lower(destDir) + // A folder and something inside it can both be selected; moving the + // folder already carries the child, so the child is not a failure - it + // simply has nothing left to do. + for (const p of outermost(paths)) { // A folder dropped on itself (or into its own subtree), or anything // dropped where it already lives: the gesture asks for nothing, so // nothing happens - and nothing is reported either. Only a path that has // GONE is a failure worth a word. - if (insideSelf(p, destDir) || lower(dirname(p)) === lower(destDir)) continue + if (insideSelf(p, destDir) || lower(dirname(p)) === destKey) continue candidates.push(p) } // Probed together (a multi-select drag can be hundreds), reported in order. From 0a0cf7c42e2c667f58caaf885ae773cc8d8442d0 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:24:02 +0200 Subject: [PATCH 2/9] fix(clipboard): the Paste presence check reads Electron's format list, no PowerShell per right-click Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DzqPzR6LuAvhiDabNwqzpx --- src/main/fileClipboard.test.ts | 23 +++++++++++++++++++++++ src/main/fileClipboard.ts | 17 +++++++++++++++++ src/main/ipc/files.ts | 26 ++++++-------------------- 3 files changed, 46 insertions(+), 20 deletions(-) create mode 100644 src/main/fileClipboard.test.ts diff --git a/src/main/fileClipboard.test.ts b/src/main/fileClipboard.test.ts new file mode 100644 index 00000000..0e46c976 --- /dev/null +++ b/src/main/fileClipboard.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest' + +const execFile = vi.fn() +vi.mock('child_process', () => ({ execFile })) + +const { hasWindowsFiles } = await import('./fileClipboard') + +describe('hasWindowsFiles', () => { + it('answers from the format list and spawns nothing (2026-10-16: a PowerShell per right-click)', () => { + expect(hasWindowsFiles(() => ['text/plain', 'text/uri-list'])).toBe(true) + expect(hasWindowsFiles(() => ['text/plain', 'image/png'])).toBe(false) + expect(hasWindowsFiles(() => [])).toBe(false) + expect(execFile).not.toHaveBeenCalled() + }) + + it('a clipboard that will not answer has no files', () => { + expect( + hasWindowsFiles(() => { + throw new Error('clipboard busy') + }) + ).toBe(false) + }) +}) diff --git a/src/main/fileClipboard.ts b/src/main/fileClipboard.ts index 951e2b26..f880e970 100644 --- a/src/main/fileClipboard.ts +++ b/src/main/fileClipboard.ts @@ -71,6 +71,23 @@ interface WindowsFileClipboard { cut: boolean | null } +/** + * Are there files on the clipboard? Asked every time a menu OPENS, to decide + * whether to draw Paste. It used to go through `readWindowsFiles` - a + * PowerShell with WinForms per right-click, so the Paste row arrived hundreds + * of ms late and shifted the menu, and fast clicks stacked processes + * (2026-10-16). Presence needs no list: Chromium reports CF_HDROP as + * 'text/uri-list' in `availableFormats()` (and the e2e fake does the same). + * PowerShell stays for the paste itself, which needs every path. + */ +export function hasWindowsFiles(availableFormats: () => string[]): boolean { + try { + return availableFormats().includes('text/uri-list') + } catch { + return false + } +} + /** Native copy/cut metadata takes precedence over Prism's legacy cut mark. */ export function readWindowsFiles(): Promise { if (E2E) { diff --git a/src/main/ipc/files.ts b/src/main/ipc/files.ts index 084d4771..9a265832 100644 --- a/src/main/ipc/files.ts +++ b/src/main/ipc/files.ts @@ -1,4 +1,4 @@ -import { app, shell, ipcMain } from 'electron' +import { app, clipboard, shell, ipcMain } from 'electron' import { CALL, PUSH } from '@shared/channels' import type { ArchiveAddResult, @@ -11,7 +11,7 @@ import type { import { basename, dirname, extname, join, resolve, sep } from 'path' import { copyFile, readFile, stat, writeFile } from 'fs/promises' import { execFile, spawn } from 'child_process' -import { copyWindowsFiles, readWindowsFiles } from '../fileClipboard' +import { copyWindowsFiles, hasWindowsFiles, readWindowsFiles } from '../fileClipboard' import { insideDesktop } from '../desktopAccess' import { container } from '../archiveBrowse' import { holders } from '../holders' @@ -295,20 +295,6 @@ export function registerShellIpc(ctx: MainCtx): void { return copyWindowsFiles([p], false, p) }) - /** - * The files on the clipboard, if any (2026-08-31). - * - * Through PowerShell, symmetrically with the copy above: Windows puts a - * multi-file copy on the clipboard as CF_HDROP, which Electron's own - * clipboard API does not expose - `readBuffer('FileNameW')` gives one - * path and nothing else. `Get-Clipboard -Format FileDropList` gives the - * list, and the copy side is already a PowerShell call for the same - * reason. - */ - async function clipboardFiles(): Promise { - return (await readWindowsFiles()).paths - } - /** * Paste whatever is on the clipboard into a folder. * @@ -319,10 +305,10 @@ export function registerShellIpc(ctx: MainCtx): void { * over what is in it. */ /** Are there files on the clipboard? Asked when a menu OPENS, so a Paste - * row that would do nothing is never drawn (2026-09-02). The same - * PowerShell route the paste itself uses - Electron's clipboard API - * exposes one path and Windows puts a multi-file copy on as CF_HDROP. */ - ipcMain.handle(CALL.clipboardHasFiles, async () => (await clipboardFiles()).length > 0) + * row that would do nothing is never drawn (2026-09-02). Electron's own + * format list, not PowerShell: a presence check needs no paths, and a + * process per right-click made the row late (2026-10-16). */ + ipcMain.handle(CALL.clipboardHasFiles, () => hasWindowsFiles(() => clipboard.availableFormats())) /** Every file under a path, with sizes, for the progress arithmetic. */ async function walkSizes(p: string): Promise { From 15f327a2120c916a29ba5dd016645bf3adb723cc Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:24:17 +0200 Subject: [PATCH 3/9] fix(launch): second-instance handoffs open their paths inside the chain, in arrival order Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DzqPzR6LuAvhiDabNwqzpx --- src/main/index.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 86fa6e66..9a3b72d4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1192,10 +1192,12 @@ if (!app.requestSingleInstanceLock()) { return } if (!startupRestored) pendingOpen.push(...paths) - else if (appState.mainWindow) - void (async () => { - for (const p of paths) await sendOpen(p) - })() + else if (appState.mainWindow) { + // Awaited INSIDE the chain (2026-10-16): fired and forgotten, a + // later handoff's opens could overtake this one's, and the wrong tab + // ended up in front. One path that fails still lets the rest open. + for (const p of paths) await sendOpen(p).catch(() => {}) + } }) .catch(() => { /* a handoff that cannot be read opens nothing */ From 94c00e7059c0a1a2a5f08347693b11d9a9768e15 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:25:16 +0200 Subject: [PATCH 4/9] fix(places): each drive probe has a time limit and Home needs no stat, so a hung drive cannot hold New Tab Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DzqPzR6LuAvhiDabNwqzpx --- src/main/browse.test.ts | 29 ++++++++++++++++++++++++++++- src/main/browse.ts | 39 +++++++++++++++++++++++++++++++++------ 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/main/browse.test.ts b/src/main/browse.test.ts index b7c27c30..e5bebe47 100644 --- a/src/main/browse.test.ts +++ b/src/main/browse.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { browseDirectory } from './browse' +import { browseDirectory, browseLocations } from './browse' import { grantDesktopDirectory, insideDesktop, @@ -190,3 +190,30 @@ it('preserves terminal pin slots for remapping and refuses nonexistent slots', ( { id: 'terminal', path: 'term:slot-1', dir: 'left', termSlot: 1 } ]) }) + +describe('browseLocations', () => { + const getPath = (key: string): string => `C:\\Users\\me\\${key}` + const dir = { isDirectory: () => true } + + it('a hung drive is left out after its time limit, not waited on (2026-10-16)', async () => { + const hung = new Promise(() => {}) + const probe = (p: string): Promise<{ isDirectory(): boolean }> => + p === 'Z:\\' || p.endsWith('desktop') ? hung : Promise.resolve(dir) + const t = Date.now() + const places = await browseLocations(getPath, probe, 50) + expect(Date.now() - t).toBeLessThan(1000) + expect(places.some((p) => p.path === 'Z:\\' || p.known === 'desktop')).toBe(false) + expect(places.find((p) => p.known === 'downloads')?.path).toBe('C:\\Users\\me\\downloads') + }) + + it('gives Home without asking the disk', async () => { + const asked: string[] = [] + const probe = (p: string): Promise<{ isDirectory(): boolean }> => { + asked.push(p) + return Promise.reject(new Error('ENOENT')) + } + const places = await browseLocations(getPath, probe, 50) + expect(places.map((p) => p.name)).toEqual(['Home']) + expect(asked).not.toContain('C:\\Users\\me\\home') + }) +}) diff --git a/src/main/browse.ts b/src/main/browse.ts index ebabf921..e5153ec0 100644 --- a/src/main/browse.ts +++ b/src/main/browse.ts @@ -86,8 +86,36 @@ export async function browseDirectory( type CommonPath = 'home' | 'desktop' | 'downloads' | 'documents' | 'pictures' | 'music' | 'videos' +/** How long one place may take to answer before it is left out. */ +const PLACE_STAT_MS = 1500 + +/** A stat that gives up: a hung mapped or network drive never answers in time. */ +function within(work: Promise, ms: number): Promise { + return new Promise((done) => { + const timer = setTimeout(() => done(null), ms) + work.then( + (value) => { + clearTimeout(timer) + done(value) + }, + () => { + clearTimeout(timer) + done(null) + } + ) + }) +} + +/** + * The Places list. Each place is probed with its own time limit, and Home is + * given without one (2026-10-16): all 26 drive letters were stat'ed with no + * limit, so one hung mapped or network drive held Places, and New Tab (which + * asks only to find Home), for as long as the SMB timeout. + */ export async function browseLocations( - getPath: (key: CommonPath) => string + getPath: (key: CommonPath) => string, + probe: (path: string) => Promise<{ isDirectory(): boolean }> = (p) => stat(p), + ms = PLACE_STAT_MS ): Promise { const common: Array<[CommonPath, string]> = [ ['home', 'Home'], @@ -116,11 +144,10 @@ export async function browseLocations( } const found = await Promise.all( shortcuts.map(async (entry) => { - try { - return (await stat(entry.path)).isDirectory() ? entry : null - } catch { - return null - } + // The user's own folder exists; it is what New Tab waits for. + if (entry.known === 'home') return entry + const info = await within(probe(entry.path), ms) + return info?.isDirectory() ? entry : null }) ) return found.filter((entry): entry is BrowseShortcut => entry !== null) From 6f4b6281a2ecadd2ab68ce34bee74c2b44773866 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:26:14 +0200 Subject: [PATCH 5/9] fix(thumbs): the cache size is kept in memory, scanned once and only again past the ceiling Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DzqPzR6LuAvhiDabNwqzpx --- src/main/thumbs.test.ts | 29 ++++++++++++++++- src/main/thumbs.ts | 70 +++++++++++++++++++++++++++++++++++------ 2 files changed, 88 insertions(+), 11 deletions(-) diff --git a/src/main/thumbs.test.ts b/src/main/thumbs.test.ts index acb80f58..7dbe3c23 100644 --- a/src/main/thumbs.test.ts +++ b/src/main/thumbs.test.ts @@ -1,5 +1,32 @@ import { describe, expect, it } from 'vitest' -import { THUMB_WIDTH, thumbArgs, thumbKey, toEvict } from './thumbs' +import { CacheBudget, THUMB_WIDTH, thumbArgs, thumbKey, toEvict } from './thumbs' + +describe('CacheBudget (2026-10-16: every thumbnail stat-scanned the whole cache)', () => { + it('scans once to learn the total, then only past the ceiling', () => { + const b = new CacheBudget(100) + expect(b.add(10)).toBe(true) // the total is unknown: learn it + b.settled(50) + expect(b.add(20)).toBe(false) // 70 + expect(b.add(30)).toBe(false) // 100, at the ceiling + expect(b.add(1)).toBe(true) // 101: over + }) + + it('never starts a second scan while one runs', () => { + const b = new CacheBudget(100) + expect(b.add(10)).toBe(true) + expect(b.add(10)).toBe(false) + expect(b.add(10)).toBe(false) + b.settled(90) + expect(b.add(5)).toBe(false) + }) + + it('a scan that failed leaves the total unknown, so the next thumbnail scans again', () => { + const b = new CacheBudget(100) + expect(b.add(10)).toBe(true) + b.settled(null) + expect(b.add(10)).toBe(true) + }) +}) describe('thumbKey', () => { it('is the file, its size and its modified time, case-insensitively, as a jpg name', () => { diff --git a/src/main/thumbs.ts b/src/main/thumbs.ts index a53c0523..9e39e195 100644 --- a/src/main/thumbs.ts +++ b/src/main/thumbs.ts @@ -27,6 +27,10 @@ export const THUMB_WIDTH = 320 /** A frame this far into a video: past the black leader most films open on. */ export const VIDEO_SEEK_S = 3 export const CACHE_MAX_BYTES = 200 * 1024 * 1024 +/** An eviction trims to this, so the next one is a long way off. */ +export const CACHE_TRIM_BYTES = 180 * 1024 * 1024 +/** Stats an eviction scan has in flight at once. */ +const SCAN_AT_ONCE = 16 const AT_ONCE = 4 export type ThumbKind = 'image' | 'video' @@ -81,7 +85,38 @@ export function toEvict( return out } +/** + * The cache's size, kept in memory (2026-10-16). Every new thumbnail used to + * readdir the cache and stat every JPEG in it (thousands in a warm 200 MB + * cache), all at once and up to four scans overlapping, which flooded the + * libuv pool a playing film reads through while a phone browsed a new folder. + * Now a scan runs once to learn the total, then only when the running total + * passes the ceiling, and never two at a time. A thumbnail landing mid-scan + * may be missed by that scan's count; the next scan puts it right. + */ +export class CacheBudget { + private total: number | null = null + private scanning = false + + constructor(private readonly max: number) {} + + /** A new entry of `bytes`: true when a scan should start now. */ + add(bytes: number): boolean { + if (this.total !== null) this.total += bytes + if (this.scanning || (this.total !== null && this.total <= this.max)) return false + this.scanning = true + return true + } + + /** A scan finished, with what the cache holds now (null: unknown). */ + settled(total: number | null): void { + this.scanning = false + this.total = total + } +} + export class Thumbs { + private readonly budget = new CacheBudget(CACHE_MAX_BYTES) private readonly inFlight = new Map>() private running = 0 private readonly queue: Array<() => void> = [] @@ -128,7 +163,8 @@ export class Thumbs { return null } await fsp.rename(tmp, out) - void this.evict() + const size = await fsp.stat(out).then((s) => s.size).catch(() => 0) + if (this.budget.add(size)) void this.evict() return out } catch { return null @@ -174,19 +210,33 @@ export class Thumbs { } private async evict(): Promise { + let total: number | null = null try { - const names = await fsp.readdir(this.dir) - const entries = await Promise.all( - names - .filter((n) => n.endsWith('.jpg')) - .map(async (name) => { - const s = await fsp.stat(join(this.dir, name)) - return { name, size: s.size, mtimeMs: s.mtimeMs } + const names = (await fsp.readdir(this.dir)).filter((n) => n.endsWith('.jpg')) + const entries: Array<{ name: string; size: number; mtimeMs: number }> = [] + for (let i = 0; i < names.length; i += SCAN_AT_ONCE) { + const batch = await Promise.all( + names.slice(i, i + SCAN_AT_ONCE).map(async (name) => { + const s = await fsp.stat(join(this.dir, name)).catch(() => null) + return s ? { name, size: s.size, mtimeMs: s.mtimeMs } : null }) - ) - for (const name of toEvict(entries, CACHE_MAX_BYTES)) await fsp.rm(join(this.dir, name), { force: true }) + ) + for (const e of batch) if (e) entries.push(e) + } + let held = entries.reduce((n, e) => n + e.size, 0) + if (held > CACHE_MAX_BYTES) { + const gone = new Set(toEvict(entries, CACHE_TRIM_BYTES)) + for (const e of entries) { + if (!gone.has(e.name)) continue + await fsp.rm(join(this.dir, e.name), { force: true }) + held -= e.size + } + } + total = held } catch { /* a cache that cannot be tidied is still a cache */ + } finally { + this.budget.settled(total) } } } From 98755ac536ab71f39fa38ecdc663456a0d27fa21 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:28:39 +0200 Subject: [PATCH 6/9] fix(tabs): the restore parses the strip once, and stats history and open folders only on local fixed disks An offline share in a tab's history no longer freezes main for an SMB timeout per entry, twice per page load. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DzqPzR6LuAvhiDabNwqzpx --- src/main/index.ts | 2 +- src/main/tabs.test.ts | 16 ++++++++++++++++ src/main/tabs.ts | 16 ++++++++++++++-- src/main/tabsSession.ts | 12 ++++++++---- 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 9a3b72d4..c7640ea0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -986,7 +986,7 @@ function createWindow(): void { const pinnedSaved = !!saved?.tabs.some((t) => t.role === 'explorer' && t.pinned) if (!pinnedSaved) await sendHome() if (stale()) return - const pinnedSent = saved ? await restoreTabs(send) : false + const pinnedSent = saved ? await restoreTabs(send, saved) : false if (stale()) return // A saved pinned Explorer whose folder has gone: the home one stands in. if (pinnedSaved && !pinnedSent) await sendHome() diff --git a/src/main/tabs.test.ts b/src/main/tabs.test.ts index d6e4cc10..54abf84a 100644 --- a/src/main/tabs.test.ts +++ b/src/main/tabs.test.ts @@ -228,6 +228,22 @@ describe('parseTabs', () => { }) }) +describe('the bulk of a strip off this PC (2026-10-16: sync stats waited out the SMB timeout)', () => { + it('keeps history and open folders on a share unchecked, and still drops gone local ones', () => { + const root = folder('here') + const gone = join(box, 'gone') + const share = '\\\\prism-test-offline.invalid\\share\\photos' + const at = (path: string): object => ({ path, selected: null, scrollTop: 0, query: '', sort: { key: 'name', direction: 'asc' } }) + const browse = { path: root, history: [at(share), at(gone), at(root)], cursor: 2, surface: 'folder', preview: false } + const parsed = parseTabs( + JSON.stringify({ tabs: [{ root, role: 'explorer', browse, open: [join(share, '2026'), join(root, 'gone')] }], active: 0 }) + ) + expect(parsed.tabs[0].browse?.history.map((h) => h.path)).toEqual([share, root]) + expect(parsed.tabs[0].browse?.cursor).toBe(1) + expect(parsed.tabs[0].open).toEqual([join(share, '2026')]) + }) +}) + describe('places inside an archive (#300)', () => { it('keeps history entries and open tree folders inside a zip that is still there', () => { const root = folder('downloads') diff --git a/src/main/tabs.ts b/src/main/tabs.ts index 1b9940d5..aae70bf3 100644 --- a/src/main/tabs.ts +++ b/src/main/tabs.ts @@ -1,4 +1,5 @@ import { containerSync } from './archiveBrowse' +import { isLocalFixed } from './driveKinds' import { existsSync, readFileSync, statSync } from 'fs' import { latestWriter } from './latestWrite' import { isAbsolute, resolve } from 'path' @@ -97,6 +98,17 @@ const isFolder = (p: string): boolean => { // sync-ok: startup, the tab restore (once per page load, before the strip shows) const isPlace = (p: string): boolean => isFolder(p) || containerSync(p) !== null +/** + * The BULK of a strip - up to 100 history entries and 400 open tree folders a + * tab - is checked only on this PC's own fixed disks (2026-10-16). Each check + * is a synchronous stat at startup, and on an offline share or mapped drive + * each one waited out the SMB timeout with main frozen: a tab whose history + * had wandered a share held the window for minutes. Anywhere else the entry + * is kept unchecked; a place that has gone is found when it is navigated to + * or expanded, as one deleted mid-session is. + */ +const mayBePlace = (p: string): boolean => !isLocalFixed(p) || isPlace(p) + /** History is a bounded suggestion. Each surviving location keeps its own view. */ export function parseBrowse(raw: unknown): SavedBrowse | undefined { if (!raw || typeof raw !== 'object') return undefined @@ -112,7 +124,7 @@ export function parseBrowse(raw: unknown): SavedBrowse | undefined { typeof location !== 'object' || typeof location.path !== 'string' || !isAbsolute(location.path) || - !isPlace(location.path) + !mayBePlace(location.path) ) return if (index === value.cursor) cursor = history.length @@ -271,7 +283,7 @@ export function parseTabs(raw: string): SavedTabs { typeof p === 'string' && isAbsolute(p) && (inside(root, p) || !!keptBrowse?.history.some((entry) => inside(entry.path, p))) && - isPlace(p) + mayBePlace(p) ) if (folders.length) tab.open = folders } diff --git a/src/main/tabsSession.ts b/src/main/tabsSession.ts index 5a8065b8..343e69f3 100644 --- a/src/main/tabsSession.ts +++ b/src/main/tabsSession.ts @@ -2,7 +2,7 @@ import { dirname, resolve } from 'path' import { addRoot } from './roots' import { grantDesktopDirectory } from './desktopAccess' import { browseDirectory, type BrowseRead } from './browse' -import { readTabs, restoredFileIndex, tabsWriter, type SavedTabs } from './tabs' +import { restoredFileIndex, tabsWriter, type SavedTabs } from './tabs' import { userPaths } from './paths' import { CODEX_RESUME, claudeSessionsAsync } from 'prism-term-core/main/agentResume' import type { BrowseDirectory } from '@shared/browse' @@ -23,7 +23,7 @@ export interface TabsSessionDeps { } export interface TabsSession { - restoreTabs(send: (payload: OpenPayload) => void): Promise + restoreTabs(send: (payload: OpenPayload) => void, saved: SavedTabs): Promise saveTabs(state: SavedTabs): void flushTabs(): void } @@ -79,6 +79,8 @@ export function createTabsSession(deps: TabsSessionDeps): TabsSession { /** * Restore last session's strip: register each surviving root so the wall * accepts it, then hand the renderer the payloads to rebuild the tabs from. + * The strip is the one index.ts already read (2026-10-16): this used to read + * and parse it a second time, every sync stat of it twice per page load. * * EXPLORER TABS FIRST, EVERYTHING ELSE AS IT IS READY (#271). The first * Explorer rows used to wait on every saved tab in turn: each project's @@ -89,8 +91,10 @@ export function createTabsSession(deps: TabsSessionDeps): TabsSession { * in SAVED order however the payloads arrive. Returns whether a pinned * Explorer tab was sent. */ - async function restoreTabs(send: (payload: OpenPayload) => void): Promise { - const saved = readTabs(userPaths.tabs()) + async function restoreTabs( + send: (payload: OpenPayload) => void, + saved: SavedTabs + ): Promise { const ids = saved.tabs.map((t, i) => t.id ?? `restored-${i}-${Date.now()}`) for (const [i, t] of saved.tabs.entries()) { // The SAVED root, not the file's folder: navigating into a subfolder and From 11909e49f4be67dd332a870d06c7e44e8035360c Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:31:31 +0200 Subject: [PATCH 7/9] fix(sizes): the invalidation journal is kept in memory, bursts collapse to the outermost folders, and dirChanged no longer waits on marker writes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DzqPzR6LuAvhiDabNwqzpx --- src/main/folderSizeCache.test.ts | 53 ++++++++++- src/main/folderSizeCache.ts | 147 +++++++++++++++++++++++++++---- src/main/index.ts | 16 ++-- 3 files changed, 191 insertions(+), 25 deletions(-) diff --git a/src/main/folderSizeCache.test.ts b/src/main/folderSizeCache.test.ts index ef3b50eb..77f1516d 100644 --- a/src/main/folderSizeCache.test.ts +++ b/src/main/folderSizeCache.test.ts @@ -4,7 +4,8 @@ import { tmpdir } from 'os' import { join } from 'path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { FolderSizeResult } from '@shared/folderSize' -import { FolderSizeCache } from './folderSizeCache' +import { existsSync, readdirSync } from 'fs' +import { FolderSizeCache, outermostKeys } from './folderSizeCache' vi.mock('fs/promises', async (importOriginal) => ({ ...(await importOriginal()) @@ -360,3 +361,53 @@ describe('persistent folder size cache', () => { expect((await readdir(directory)).filter((name) => name.endsWith('.json'))).toHaveLength(2) }) }) + +describe('the invalidation journal (2026-10-16: re-read per lookup, written per folder)', () => { + it('collapses changed folders to the outermost ones', () => { + const k = (...parts: string[]): string => join(...parts).toLowerCase() + const a = k(box, 'a') + expect(outermostKeys([k(a, 'x', 'y'), a, k(a, 'x'), k(box, 'ab'), a]).sort()).toEqual( + [a, k(box, 'ab')].sort() + ) + const drive = join(box.slice(0, 3)).toLowerCase() + expect(outermostKeys([drive, a])).toEqual([drive]) + }) + + it('writes one marker for a burst inside one changed folder', async () => { + const cache = new FolderSizeCache({ directory }) + const real = await fs.realpath(root) + await cache.invalidate([ + real, + ...Array.from({ length: 300 }, (_, i) => join(real, 'node_modules', `pkg-${i}`, 'lib')) + ]) + expect(await readdir(join(directory, 'invalidations'))).toHaveLength(1) + expect(await fs.readFile(join(directory, 'generation'), 'utf8').catch(() => '')).toBe('') + }) + + it('reads the marker files once, not once per lookup', async () => { + const cache = new FolderSizeCache({ directory }) + await cache.get(root, controller().signal) + await new FolderSizeCache({ directory }).invalidate([join(box, 'elsewhere')]) + await cache.readCached([root]) + const readFile = vi.spyOn(fs, 'readFile') + await cache.readCached([root]) + await cache.readCached([root]) + const markerReads = readFile.mock.calls.filter(([path]) => String(path).includes('invalidations')) + expect(markerReads).toHaveLength(0) + }) + + it('answers with a change before its marker is on disk', async () => { + const cache = new FolderSizeCache({ directory }) + await cache.get(root, controller().signal) + let early: Promise> | null = null + let onDisk: string[] | null = null + await cache.invalidate([join(await fs.realpath(root), 'data')], () => { + onDisk = existsSync(join(directory, 'invalidations')) + ? readdirSync(join(directory, 'invalidations')) + : [] + early = cache.readCached([root]) + }) + expect(onDisk).toEqual([]) + expect((await early!)[root].stale).toBe(true) + }) +}) diff --git a/src/main/folderSizeCache.ts b/src/main/folderSizeCache.ts index ebb76af9..3bc28550 100644 --- a/src/main/folderSizeCache.ts +++ b/src/main/folderSizeCache.ts @@ -33,10 +33,35 @@ interface Calculation { promise: Promise } +interface Marker { + path: string + id: string + at: number +} + interface InvalidationSnapshot { global: string globalAt?: number - markers: { path: string; id: string; at: number }[] + markers: Marker[] +} + +/** Marker files written at once, so a burst never floods libuv's pool. */ +const MARKER_WRITES_AT_ONCE = 8 + +/** The changed folders no other changed folder contains. A marker matches + * everything inside its folder and every ancestor of it, so a child's marker + * says nothing its changed parent's does not. */ +export function outermostKeys(keys: readonly string[]): string[] { + const all = new Set(keys) + return [...all].filter((k) => { + for (let at = k.lastIndexOf(sep); at > 0; at = k.lastIndexOf(sep, at - 1)) { + // A drive root keeps its separator (`c:\`), every other folder drops it. + const parent = k.slice(0, at) + const root = k.slice(0, at + 1) + if (all.has(parent) || (root !== k && all.has(root))) return false + } + return true + }) } function key(path: string): string { @@ -94,6 +119,10 @@ export class FolderSizeCache { private pruning: Promise | null = null private writesSincePrune = 0 private readingInvalidations: Promise | null = null + /** The journal as last read, and the signature it was read under. */ + private journal: { signature: string; value: InvalidationSnapshot } | null = null + /** This process's markers from the moment they exist until they are on disk. */ + private readonly pending = new Map() private indexedQueries = 0 private readonly indexedWaiters = new Set<() => void>() @@ -110,10 +139,49 @@ export class FolderSizeCache { ) } - private invalidations(): Promise { + /** + * THE MARKER JOURNAL IS KEPT IN MEMORY (2026-10-16). Every size lookup, each + * `generation()` and `indexIsSettling()`, used to readdir the journal and + * read and parse every marker in it (64 to 128 files), so a page of size rows + * was thousands of small reads. Now two stats (the journal folder and the + * global generation file) tell whether anything changed since the last read, + * which still sees another Prism process's markers at once. Markers this + * process has made but not yet written count from the moment they exist. + */ + private async invalidations(): Promise { + const base = await this.journalOnDisk() + if (!this.pending.size) return base + const ours = [...this.pending.values()] + return { + ...base, + markers: [...base.markers.filter((m) => !this.pending.has(m.path)), ...ours] + } + } + + private async journalSignature(): Promise { + const part = async (path: string): Promise => { + try { + const s = await stat(path, { bigint: true }) + return `${s.mtimeNs}:${s.size}` + } catch { + return '-' + } + } + const [markers, global] = await Promise.all([ + part(join(this.options.directory, 'invalidations')), + part(join(this.options.directory, 'generation')) + ]) + return `${markers}|${global}` + } + + private journalOnDisk(): Promise { if (this.readingInvalidations) return this.readingInvalidations const markerDirectory = join(this.options.directory, 'invalidations') const read = (async () => { + // Taken BEFORE the read: a marker landing mid-read changes it, so the + // next lookup reads again rather than trusting this answer. + const signature = await this.journalSignature() + if (this.journal?.signature === signature) return this.journal.value const names = await readdir(markerDirectory).catch(() => []) const markers = await Promise.all( names @@ -145,13 +213,13 @@ export class FolderSizeCache { } catch { // Older caches used a plain UUID for the overflow generation. } - return { + const value: InvalidationSnapshot = { global, globalAt, - markers: markers.filter( - (marker): marker is { path: string; id: string; at: number } => marker !== null - ) + markers: markers.filter((marker): marker is Marker => marker !== null) } + this.journal = { signature, value } + return value })().finally(() => { if (this.readingInvalidations === read) this.readingInvalidations = null }) @@ -287,20 +355,55 @@ export class FolderSizeCache { /** Invalidate after filesystem mutations, including writes beneath a root. * Shared path markers also invalidate ancestors in other Prism processes. - * Old snapshots remain available while the next request refreshes them. */ - async invalidate(paths: readonly string[]): Promise { - if (!paths.length) return + * Old snapshots remain available while the next request refreshes them. + * + * `noted` runs once this process already answers with the new markers, and + * BEFORE they are written (2026-10-16): the watcher's dirChanged used to wait + * for one atomic write per changed folder (node_modules included, all at + * once), so during an npm install the tree refreshed late and the writes + * flooded the pool a playing film reads through. The changed folders are now + * collapsed to the outermost ones and written a few at a time. */ + async invalidate(paths: readonly string[], noted?: () => void): Promise { + if (!paths.length) { + noted?.() + return + } const directory = join(this.options.directory, 'invalidations') - const normalized = await Promise.all(paths.filter(isAbsolute).map(canonicalKey)) - await Promise.all( - [...new Set(normalized)].map((path) => - this.atomicWrite( - join(directory, createHash('sha256').update(path).digest('hex') + '.json'), - JSON.stringify({ path, id: randomUUID(), at: this.now() }) - ) + const absolute = [...new Set(paths.filter(isAbsolute))] + const canonical: string[] = [] + for (let offset = 0; offset < absolute.length; offset += 16) + canonical.push(...(await Promise.all(absolute.slice(offset, offset + 16).map(canonicalKey)))) + const changed = outermostKeys(canonical) + if (changed.length > 128) { + // A burst bigger than the journal: one global invalidation says it all. + await this.atomicWrite( + join(this.options.directory, 'generation'), + JSON.stringify({ id: randomUUID(), at: this.now() }) ) - ) - this.readingInvalidations = null + this.forgetJournal() + noted?.() + return + } + const markers = changed.map((path) => ({ path, id: randomUUID(), at: this.now() })) + for (const marker of markers) this.pending.set(marker.path, marker) + noted?.() + try { + for (let offset = 0; offset < markers.length; offset += MARKER_WRITES_AT_ONCE) + await Promise.all( + markers + .slice(offset, offset + MARKER_WRITES_AT_ONCE) + .map((marker) => + this.atomicWrite( + join(directory, createHash('sha256').update(marker.path).digest('hex') + '.json'), + JSON.stringify(marker) + ) + ) + ) + } finally { + for (const marker of markers) + if (this.pending.get(marker.path) === marker) this.pending.delete(marker.path) + this.forgetJournal() + } const names = await readdir(directory).catch(() => []) if (names.length > 128) { // Rare mutation bursts fall back to one global invalidation before @@ -321,10 +424,16 @@ export class FolderSizeCache { await Promise.all( entries.slice(64).map(({ path }) => rm(path, { force: true }).catch(() => {})) ) - this.readingInvalidations = null + this.forgetJournal() } } + /** This process's own writes are read back fresh, whatever the stats say. */ + private forgetJournal(): void { + this.readingInvalidations = null + this.journal = null + } + async readCached(paths: readonly string[]): Promise> { const result: Record = {} for (let offset = 0; offset < paths.length; offset += 16) { diff --git a/src/main/index.ts b/src/main/index.ts index c7640ea0..a202afca 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -519,13 +519,19 @@ function folderChanged(change: { root: string; dirs: string[] }): void { return rel !== '' && (rel.startsWith('..' + sep) || rel === '..' || isAbsolute(rel)) }) ) + // The page hears as soon as the sizes answer with the change, not once every + // marker is on disk (2026-10-16); once either way, a failure included. + let told = false + const tell = (): void => { + if (told) return + told = true + if (appState.mainWindow && !appState.mainWindow.isDestroyed()) + appState.mainWindow.webContents.send(PUSH.dirChanged, change) + } void folderSizes - .invalidate(directories) + .invalidate(directories, tell) .catch(() => {}) - .finally(() => { - if (appState.mainWindow && !appState.mainWindow.isDestroyed()) - appState.mainWindow.webContents.send(PUSH.dirChanged, change) - }) + .finally(tell) } onRootsChanged((root, open) => { From 16e956dccdc76d94ad235c484d5fde48d9d5e400 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:36:00 +0200 Subject: [PATCH 8/9] fix(sizes): a journal read is kept only when no change can have slipped past its signature A file time moves in clock ticks, so a marker written in the same tick as the signature's stat (another process's, or ours while a read was in flight) left the signature unchanged and the cached journal missed it until the next change. A read is now kept only when the journal is at least 100 ms old and this process wrote nothing since the read began. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DzqPzR6LuAvhiDabNwqzpx --- src/main/folderSizeCache.test.ts | 2 ++ src/main/folderSizeCache.ts | 23 +++++++++++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/main/folderSizeCache.test.ts b/src/main/folderSizeCache.test.ts index 77f1516d..3a2b2764 100644 --- a/src/main/folderSizeCache.test.ts +++ b/src/main/folderSizeCache.test.ts @@ -388,6 +388,8 @@ describe('the invalidation journal (2026-10-16: re-read per lookup, written per const cache = new FolderSizeCache({ directory }) await cache.get(root, controller().signal) await new FolderSizeCache({ directory }).invalidate([join(box, 'elsewhere')]) + // A journal changed in the last clock ticks is never kept; let it settle. + await new Promise((settle) => setTimeout(settle, 150)) await cache.readCached([root]) const readFile = vi.spyOn(fs, 'readFile') await cache.readCached([root]) diff --git a/src/main/folderSizeCache.ts b/src/main/folderSizeCache.ts index 3bc28550..0c3cfa90 100644 --- a/src/main/folderSizeCache.ts +++ b/src/main/folderSizeCache.ts @@ -48,6 +48,11 @@ interface InvalidationSnapshot { /** Marker files written at once, so a burst never floods libuv's pool. */ const MARKER_WRITES_AT_ONCE = 8 +/** A journal changed this recently is read but not kept: a file time moves in + * clock ticks (up to ~16 ms on Windows), so a second write in the same tick + * as the one the signature saw would leave the signature unchanged. */ +const JOURNAL_SETTLE_MS = 100 + /** The changed folders no other changed folder contains. A marker matches * everything inside its folder and every ancestor of it, so a child's marker * says nothing its changed parent's does not. */ @@ -123,6 +128,9 @@ export class FolderSizeCache { private journal: { signature: string; value: InvalidationSnapshot } | null = null /** This process's markers from the moment they exist until they are on disk. */ private readonly pending = new Map() + /** Bumped whenever this process changes the journal: a read that began + * before then is used once but never kept. */ + private journalEpoch = 0 private indexedQueries = 0 private readonly indexedWaiters = new Set<() => void>() @@ -158,10 +166,13 @@ export class FolderSizeCache { } } - private async journalSignature(): Promise { + private async journalSignature(): Promise<{ signature: string; settled: boolean }> { + const asked = Date.now() + let settled = true const part = async (path: string): Promise => { try { const s = await stat(path, { bigint: true }) + if (asked - Number(s.mtimeNs / 1_000_000n) < JOURNAL_SETTLE_MS) settled = false return `${s.mtimeNs}:${s.size}` } catch { return '-' @@ -171,7 +182,7 @@ export class FolderSizeCache { part(join(this.options.directory, 'invalidations')), part(join(this.options.directory, 'generation')) ]) - return `${markers}|${global}` + return { signature: `${markers}|${global}`, settled } } private journalOnDisk(): Promise { @@ -180,7 +191,8 @@ export class FolderSizeCache { const read = (async () => { // Taken BEFORE the read: a marker landing mid-read changes it, so the // next lookup reads again rather than trusting this answer. - const signature = await this.journalSignature() + const epoch = this.journalEpoch + const { signature, settled } = await this.journalSignature() if (this.journal?.signature === signature) return this.journal.value const names = await readdir(markerDirectory).catch(() => []) const markers = await Promise.all( @@ -218,7 +230,9 @@ export class FolderSizeCache { globalAt, markers: markers.filter((marker): marker is Marker => marker !== null) } - this.journal = { signature, value } + // Kept only when nothing can have slipped in unseen: no write of ours + // since the read began, and no change in the same clock tick. + if (settled && epoch === this.journalEpoch) this.journal = { signature, value } return value })().finally(() => { if (this.readingInvalidations === read) this.readingInvalidations = null @@ -430,6 +444,7 @@ export class FolderSizeCache { /** This process's own writes are read back fresh, whatever the stats say. */ private forgetJournal(): void { + this.journalEpoch++ this.readingInvalidations = null this.journal = null } From adfc5af55b8dfceb8718871946e0130d15fd6857 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:09:27 +0200 Subject: [PATCH 9/9] chore: 0.117.6 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DzqPzR6LuAvhiDabNwqzpx --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index b8995448..f827800a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism", - "version": "0.117.5", + "version": "0.117.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism", - "version": "0.117.5", + "version": "0.117.6", "license": "MIT", "dependencies": { "@codemirror/commands": "^6.11.0", diff --git a/package.json b/package.json index f8909762..6bcd66f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prism", - "version": "0.117.5", + "version": "0.117.6", "description": "A fast, universal media viewer for Windows.", "author": "Max", "license": "MIT",