diff --git a/package-lock.json b/package-lock.json
index cbfe209b..2737f910 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "prism",
- "version": "0.117.3",
+ "version": "0.117.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "prism",
- "version": "0.117.3",
+ "version": "0.117.4",
"license": "MIT",
"dependencies": {
"@codemirror/commands": "^6.11.0",
diff --git a/package.json b/package.json
index 40428c4d..bebdb96e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "prism",
- "version": "0.117.3",
+ "version": "0.117.4",
"description": "A fast, universal media viewer for Windows.",
"author": "Max",
"license": "MIT",
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index d5839f17..bbe04f51 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -1275,12 +1275,15 @@ export default function App(): JSX.Element {
// open file's position among them. Sorting reuses the same file objects, so
// mapping back to rawIndex keeps working. (A kind filter lived here once,
// removed 2026-08-20: a forgotten filter read as missing files.)
+ // Keyed on the files, not the tab (review, 2026-10-16): the tab is a new
+ // object on every patch of its browse state, and each one re-sorted them.
const sort = useSort()
+ const activeFiles = active?.files
const view = useMemo(() => {
- if (!active || rawIndex < 0 || !active.files.length) return null
- const files = sortFiles(active.files, sort.field, sort.dir)
- return { files, index: Math.max(0, files.indexOf(active.files[rawIndex])) }
- }, [active, rawIndex, sort])
+ if (!activeFiles || rawIndex < 0 || !activeFiles.length) return null
+ const files = sortFiles(activeFiles, sort.field, sort.dir)
+ return { files, index: Math.max(0, files.indexOf(activeFiles[rawIndex])) }
+ }, [activeFiles, rawIndex, sort])
const go = useCallback(
(delta: number) => {
@@ -2174,7 +2177,7 @@ export default function App(): JSX.Element {
}}
onSelect={browsing.select}
onOpen={(file) => void browsing.openFile(file)}
- onScroll={(scrollTop) => browsing.patch({ scrollTop })}
+ onScroll={browsing.keepScroll}
onQueryChange={browsing.searchFor}
// A header click is a pick made here (#285): Downloads keeps it.
onSortChange={(sort) => {
diff --git a/src/renderer/src/components/browse/BrowseList.tsx b/src/renderer/src/components/browse/BrowseList.tsx
index bfd301f9..0ad82e92 100644
--- a/src/renderer/src/components/browse/BrowseList.tsx
+++ b/src/renderer/src/components/browse/BrowseList.tsx
@@ -1,4 +1,5 @@
import {
+ useEffect,
useLayoutEffect,
useMemo,
useRef,
@@ -33,6 +34,7 @@ import { nextSort } from '../../lib/downloadsView'
import { listKey, stepTo, type ListKey } from '../../lib/listKeys'
import { nearerEscape } from '../../lib/nearerEscape'
import { hiddenTitle } from '../../lib/hiddenTitle'
+import { createScrollSettle } from '../../lib/scrollSettle'
const OVERSCAN = 12
/** The common keys the list answers itself (#330). */
@@ -152,9 +154,58 @@ export function BrowseList(props: Props): JSX.Element {
observer.observe(node)
return () => observer.disconnect()
}, [])
+ /**
+ * THE LIVE SCROLL IS THE LIST'S OWN (review, 2026-10-16). Every native
+ * scroll event used to patch the tab, which re-rendered the whole App,
+ * re-sorted the folder and sent every tab's history to main, per frame.
+ * Now a scroll re-renders this list alone, and the position is handed up
+ * once it settles (lib/scrollSettle.ts) or at once for a key's move. The
+ * tab's `scrollTop` is taken over only when it changes from outside (a
+ * restore, a sort's reset, Back) or the place on screen does; one this list
+ * handed up itself is already where it is (`from`), so the list never
+ * jumps back to a position a frame old.
+ */
+ const placeKey = `${props.directory}\u0000${props.query}\u0000${props.sort.key}${props.sort.direction}`
+ const [liveState, setLive] = useState(() => ({
+ key: placeKey,
+ from: props.scrollTop,
+ top: props.scrollTop,
+ taken: 0
+ }))
+ let live = liveState
+ if (live.key !== placeKey || live.from !== props.scrollTop) {
+ live = { key: placeKey, from: props.scrollTop, top: props.scrollTop, taken: live.taken + 1 }
+ setLive(live)
+ }
+ const [settle] = useState(createScrollSettle)
+ useEffect(() => () => settle.flush(), [settle])
+ const handOwner = useRef(props.onScroll)
+ useLayoutEffect(() => {
+ handOwner.current = props.onScroll
+ })
+ /** The list scrolled itself: re-render this list, hand it up when it settles. */
+ const scrolledTo = (top: number): void => {
+ setLive((l) => (l.top === top ? l : { ...l, top }))
+ const up = props.onScroll
+ const key = placeKey
+ settle.scrolled(up, top, (settled) => {
+ setLive((l) => (l.key === key && handOwner.current === up ? { ...l, from: settled } : l))
+ up(settled)
+ })
+ }
+ /** A key moved the list: hand the position up at once. */
+ const handUp = (top: number): void => {
+ settle.cancel()
+ setLive((l) => ({ ...l, top, from: top }))
+ props.onScroll(top)
+ }
+ const liveTop = live.top
useLayoutEffect(() => {
- if (scroller.current && !props.loading) scroller.current.scrollTop = props.scrollTop
- }, [props.directory, props.scrollTop, props.loading])
+ if (scroller.current && !props.loading) scroller.current.scrollTop = live.top
+ // Only when a position is TAKEN from the tab (or the load ends), never
+ // per scroll: the list is already where it scrolled to.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [live.taken, props.loading])
// DOWNLOADS' DATE GROUPS (#285): a divider is a row of the list's own
// height, so the rows below it simply move down one index; `rowAt` answers
@@ -186,9 +237,9 @@ export function BrowseList(props: Props): JSX.Element {
const spaceHeight = Math.min(count * rowHeight, 4000000)
const scale = Math.max(1, (count * rowHeight - height) / Math.max(1, spaceHeight - height))
const logicalTop =
- props.scrollTop >= spaceHeight - height - 1
+ liveTop >= spaceHeight - height - 1
? Math.max(0, count * rowHeight - height)
- : props.scrollTop * scale
+ : liveTop * scale
const loadedSelectedIndex = props.indexedRows
? ([...props.indexedRows].find(([, entry]) => entry?.path === props.selectedPath)?.[0] ?? -1)
: (() => {
@@ -283,7 +334,7 @@ export function BrowseList(props: Props): JSX.Element {
// measured scroll, not the rendered one, so it holds while the list
// auto-scrolls under it. Known and accepted: in the compressed scroll
// (`scale > 1`, about 180k rows and up) the rows are placed from the
- // RENDERED `props.scrollTop`, a frame later, so while the list
+ // RENDERED `liveTop`, a frame later, so while the list
// auto-scrolls there the box and the rows can disagree for a frame by
// the scroll step times `scale - 1`. Below that `logicalTopAt(s) === s`.
const b = bandBox(band)
@@ -381,8 +432,8 @@ export function BrowseList(props: Props): JSX.Element {
)
}, [props.entries, divided, logicalTop, height, onVisibleFolders, indexed, rowHeight])
const onSelect = props.onSelect
- const onScroll = props.onScroll
- const scrollTop = props.scrollTop
+ const onScroll = handUp
+ const scrollTop = liveTop
useLayoutEffect(() => {
const pending = pendingFocus.current
const node = scroller.current
@@ -459,7 +510,7 @@ export function BrowseList(props: Props): JSX.Element {
if (top < visibleTop) node.scrollTop = top / scale
else if (index * rowHeight + rowHeight > visibleTop + node.clientHeight)
node.scrollTop = (index * rowHeight + rowHeight - node.clientHeight) / scale
- props.onScroll(node.scrollTop)
+ handUp(node.scrollTop)
const entry = rowAt(index)
if (!entry) return null
requestAnimationFrame(() =>
@@ -480,7 +531,7 @@ export function BrowseList(props: Props): JSX.Element {
if (top < visibleTop) node.scrollTop = top / scale
else if (index * rowHeight + rowHeight > visibleTop + node.clientHeight)
node.scrollTop = (index * rowHeight + rowHeight - node.clientHeight) / scale
- props.onScroll(node.scrollTop)
+ handUp(node.scrollTop)
const entry = rowAt(index)
if (entry) {
pendingFocus.current = null
@@ -706,7 +757,7 @@ export function BrowseList(props: Props): JSX.Element {
columnScroller.current.scrollLeft !== e.currentTarget.scrollLeft
)
columnScroller.current.scrollLeft = e.currentTarget.scrollLeft
- if (!props.loading) props.onScroll(e.currentTarget.scrollTop)
+ if (!props.loading) scrolledTo(e.currentTarget.scrollTop)
}}
// Empty space clears the pick QUIETLY: it is not a request to stop what
// the preview plays (owner, 2026-10-03: "i should have to click the
@@ -735,7 +786,7 @@ export function BrowseList(props: Props): JSX.Element {
{rendered.map((entry, offset) => {
diff --git a/src/renderer/src/components/browse/types.ts b/src/renderer/src/components/browse/types.ts
index d1777159..0a883ded 100644
--- a/src/renderer/src/components/browse/types.ts
+++ b/src/renderer/src/components/browse/types.ts
@@ -101,6 +101,9 @@ export interface FolderBrowserProps {
* the selected path only; the preview and playback are left alone. */
onSelect: (path: string | null, quiet?: boolean) => void
onOpen: (file: ViewerFile) => void
+ /** A SETTLED position, never one per scroll event (review, 2026-10-16):
+ * the list keeps its live scroll itself. Each history entry gets its own
+ * handler, which a position settling after a navigation is still sent to. */
onScroll: (top: number) => void
onQueryChange: (query: string) => void
onSortChange: (sort: BrowseSort) => void
diff --git a/src/renderer/src/components/sidebar/usePanelResize.test.ts b/src/renderer/src/components/sidebar/usePanelResize.test.ts
new file mode 100644
index 00000000..e1d69dcf
--- /dev/null
+++ b/src/renderer/src/components/sidebar/usePanelResize.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it, vi } from 'vitest'
+import { MAX_W, MIN_W, createPanelDrag } from './usePanelResize'
+
+describe('the tree panel edge drag paints, it does not render (review, 2026-10-16)', () => {
+ it('paints every move and keeps the width ONCE, on release', () => {
+ const paint = vi.fn()
+ const commit = vi.fn()
+ const drag = createPanelDrag(paint, commit)
+ for (let x = 200; x <= 400; x += 10) drag.move(x)
+ expect(paint).toHaveBeenCalledTimes(21)
+ expect(commit).not.toHaveBeenCalled()
+ drag.end()
+ expect(commit).toHaveBeenCalledTimes(1)
+ expect(commit).toHaveBeenCalledWith(400)
+ })
+
+ it('clamps what it paints and keeps, and a release with no move keeps nothing', () => {
+ const paint = vi.fn()
+ const commit = vi.fn()
+ const drag = createPanelDrag(paint, commit)
+ drag.end()
+ expect(commit).not.toHaveBeenCalled()
+ drag.move(5)
+ expect(paint).toHaveBeenLastCalledWith(MIN_W)
+ drag.move(5000)
+ expect(paint).toHaveBeenLastCalledWith(MAX_W)
+ drag.end()
+ drag.end()
+ expect(commit).toHaveBeenCalledTimes(1)
+ expect(commit).toHaveBeenCalledWith(MAX_W)
+ })
+})
diff --git a/src/renderer/src/components/sidebar/usePanelResize.ts b/src/renderer/src/components/sidebar/usePanelResize.ts
index d18ce0d0..447a8121 100644
--- a/src/renderer/src/components/sidebar/usePanelResize.ts
+++ b/src/renderer/src/components/sidebar/usePanelResize.ts
@@ -18,7 +18,28 @@ export const MIN_W = 170
export const MAX_W = 520
export const DEFAULT_W = 260
-const clampWidth = (n: number): number => Math.round(Math.min(MAX_W, Math.max(MIN_W, n)))
+export const clampWidth = (n: number): number => Math.round(Math.min(MAX_W, Math.max(MIN_W, n)))
+
+/**
+ * A DRAG PAINTS, IT DOES NOT RENDER (review, 2026-10-16; the Explorer grips'
+ * rule, useExplorerWidths). Every pointer move used to set the width state,
+ * re-rendering the whole Sidebar, and write the pref. A move now paints the
+ * clamped width straight onto the panel; the release keeps it, once.
+ */
+export function createPanelDrag(paint: (width: number) => void, commit: (width: number) => void) {
+ let live: number | null = null
+ return {
+ move(width: number): void {
+ live = clampWidth(width)
+ paint(live)
+ },
+ end(): void {
+ const width = live
+ live = null
+ if (width !== null) commit(width)
+ }
+ }
+}
const widthPref = createPref({
key: 'prism.sidebar.width',
@@ -57,6 +78,18 @@ export function usePanelResize({ panel, right }: PanelResizeDeps): PanelResize {
setWidth(w)
widthPref.set(w)
}, [])
+ // The panel and its body carry the width (Sidebar.tsx); a SHUT panel stays
+ // at zero, as it would have rendered.
+ const [drag] = useState(() =>
+ createPanelDrag((w) => {
+ const box = panel.current
+ if (!box) return
+ if (box.style.width !== '0px') box.style.width = `${w}px`
+ const body = box.firstElementChild
+ if (body instanceof HTMLElement) body.style.width = `${w}px`
+ box.querySelector(':scope > [role="separator"]')?.setAttribute('aria-valuenow', String(w))
+ }, resize)
+ )
const onHandleDown = useCallback((e: PointerEvent) => {
e.preventDefault()
@@ -70,15 +103,19 @@ export function usePanelResize({ panel, right }: PanelResizeDeps): PanelResize {
(e: PointerEvent) => {
if (!dragging || !panel.current) return
const box = panel.current.getBoundingClientRect()
- resize(right ? box.right - e.clientX : e.clientX - box.left)
+ drag.move(right ? box.right - e.clientX : e.clientX - box.left)
},
- [dragging, resize, right, panel]
+ [dragging, drag, right, panel]
)
- const onHandleUp = useCallback((e: PointerEvent) => {
- e.currentTarget.releasePointerCapture(e.pointerId)
- setDragging(false)
- }, [])
+ const onHandleUp = useCallback(
+ (e: PointerEvent) => {
+ e.currentTarget.releasePointerCapture(e.pointerId)
+ drag.end()
+ setDragging(false)
+ },
+ [drag]
+ )
const onHandleKey = useCallback(
(e: KeyboardEvent) => {
diff --git a/src/renderer/src/hooks/searchRefresh.test.ts b/src/renderer/src/hooks/searchRefresh.test.ts
new file mode 100644
index 00000000..455a87b7
--- /dev/null
+++ b/src/renderer/src/hooks/searchRefresh.test.ts
@@ -0,0 +1,29 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+// A SEARCH DOES NOT RESTART ON FOCUS (review, 2026-10-16). Read as source,
+// the way noFocusRings.test.ts reads the renderer: the hook's `revision` is
+// bumped by the window's focus and by the folder's watcher, and handing it to
+// useBrowseSearch made a new search key each time, which blanked the results,
+// reset the window to the top and restarted the walk. Only `refreshKey` (F5,
+// a file operation) may re-run a search. (The hook's effects need a live
+// window and IPC, which the node test environment does not have.)
+const src = readFileSync(resolve(__dirname, 'useFolderBrowsing.ts'), 'utf8').replace(/\r\n/g, '\n')
+
+describe('the Explorer search key', () => {
+ const call = src.match(/useBrowseSearch\(([\s\S]*?)\n {2}\)/)?.[1] ?? ''
+ const args = call
+ .split('\n')
+ .map((line) => line.replace(/\/\/.*$/, '').trim())
+ .filter(Boolean)
+ .join(' ')
+
+ it('found the call', () => {
+ expect(args).toContain('refreshKey')
+ })
+
+ it('leaves the focus and watcher revision out', () => {
+ expect(args).not.toMatch(/\brevision\b/)
+ })
+})
diff --git a/src/renderer/src/hooks/useFolderBrowsing.ts b/src/renderer/src/hooks/useFolderBrowsing.ts
index 115b30b6..a17d9451 100644
--- a/src/renderer/src/hooks/useFolderBrowsing.ts
+++ b/src/renderer/src/hooks/useFolderBrowsing.ts
@@ -16,6 +16,7 @@ import {
searchBrowse,
travelBrowse,
setBrowseLocation,
+ setBrowseScroll,
setBrowseSurface,
setBrowsePreview,
isExplorerTab,
@@ -192,7 +193,12 @@ export function useFolderBrowsing(
path,
location?.query ?? '',
folder,
- refreshKey + revision,
+ // Only an explicit refresh (F5, a file operation) re-runs a search, never
+ // `revision` (review, 2026-10-16): the window's focus and the folder's
+ // watcher bump that, and a new key blanked the results, reset the window
+ // to the top and restarted the walk, every time the user came back to
+ // Prism, and over and over in a folder that kept changing.
+ refreshKey,
sort,
location?.selected ?? null
)
@@ -420,6 +426,24 @@ export function useFolderBrowsing(
},
[id, setState]
)
+ /** A settled scroll, for the history entry on screen NOW (review,
+ * 2026-10-16): the list calls the one it was given when it scrolled, so a
+ * position that settles after a navigation still lands where it was made
+ * (`setBrowseScroll`). Never per scroll event: the list keeps its own. */
+ const at = active?.browse.cursor
+ const atQuery = location?.query
+ const atSort = location?.sort
+ const keepScroll = useCallback(
+ (scrollTop: number) => {
+ if (!id || at === undefined || path === undefined || atQuery === undefined || !atSort) return
+ const place = { cursor: at, path, query: atQuery, sort: atSort }
+ setState((s) => {
+ const tabs = setBrowseScroll(s.tabs, id, place, scrollTop)
+ return tabs === s.tabs ? s : { ...s, tabs }
+ })
+ },
+ [id, at, path, atQuery, atSort, setState]
+ )
/** Start, refine or clear the search: a place in the history (#281). */
const searchFor = useCallback(
(query: string) => {
@@ -654,6 +678,7 @@ export function useFolderBrowsing(
navigate,
travel,
patch,
+ keepScroll,
searchFor,
showFolder,
openFile,
diff --git a/src/renderer/src/hooks/useFolderSizes.test.ts b/src/renderer/src/hooks/useFolderSizes.test.ts
index c1427c4e..b0c2b723 100644
--- a/src/renderer/src/hooks/useFolderSizes.test.ts
+++ b/src/renderer/src/hooks/useFolderSizes.test.ts
@@ -1,6 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FolderSizeResult } from '@shared/folderSize'
-import { createVisibleFolderSizes, forgetFolderSizes, rememberedFolderSizes } from './useFolderSizes'
+import { createElement, useState } from 'react'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { createVisibleFolderSizes, forgetFolderSizes, rememberedFolderSizes, useFolderSizes } from './useFolderSizes'
const size = (bytes: number): FolderSizeResult => ({
bytes,
@@ -182,3 +184,19 @@ describe('visible folder size requests', () => {
expect(rememberedFolderSizes(['a'])).toEqual({ a: size(7) })
})
})
+
+describe('useFolderSizes keeps one object across renders (review, 2026-10-16)', () => {
+ it('a render that changes nothing (a scroll) hands back the SAME sizes, so nothing re-sorts', () => {
+ const seen: object[] = []
+ function Probe(): null {
+ // A render-phase update re-runs the component once, with its hooks kept.
+ const [pass, setPass] = useState(0)
+ seen.push(useFolderSizes(['C:\\a', 'C:\\b'], true, ['C:\\a']))
+ if (pass === 0) setPass(1)
+ return null
+ }
+ renderToStaticMarkup(createElement(Probe))
+ expect(seen).toHaveLength(2)
+ expect(seen[1]).toBe(seen[0])
+ })
+})
diff --git a/src/renderer/src/hooks/useFolderSizes.ts b/src/renderer/src/hooks/useFolderSizes.ts
index 07beaf00..44550e01 100644
--- a/src/renderer/src/hooks/useFolderSizes.ts
+++ b/src/renderer/src/hooks/useFolderSizes.ts
@@ -167,5 +167,9 @@ export function useFolderSizes(
controller.current?.setVisible(visiblePaths)
}, [visiblePaths, scanKey])
const seeded = useMemo(() => rememberedFolderSizes(stablePaths), [stablePaths])
- return { ...seeded, ...(state.key === scanKey && enabled ? state.sizes : {}) }
+ // ONE object until something changes (review, 2026-10-16): a fresh one per
+ // render made every Explorer render, a scroll's included, look like new
+ // sizes, so FolderBrowser re-sorted the whole folder each time.
+ const sizes = state.key === scanKey && enabled ? state.sizes : null
+ return useMemo(() => (sizes ? { ...seeded, ...sizes } : seeded), [seeded, sizes])
}
diff --git a/src/renderer/src/lib/scrollSettle.test.ts b/src/renderer/src/lib/scrollSettle.test.ts
new file mode 100644
index 00000000..cedee64a
--- /dev/null
+++ b/src/renderer/src/lib/scrollSettle.test.ts
@@ -0,0 +1,44 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { SCROLL_SETTLE_MS, createScrollSettle } from './scrollSettle'
+
+describe('createScrollSettle (review, 2026-10-16)', () => {
+ beforeEach(() => vi.useFakeTimers())
+ afterEach(() => vi.useRealTimers())
+
+ it('hands a burst of scroll events up ONCE, with the last position, after it settles', () => {
+ const settle = createScrollSettle()
+ const commit = vi.fn()
+ for (let top = 0; top <= 600; top += 20) {
+ settle.scrolled('a', top, commit)
+ vi.advanceTimersByTime(16)
+ }
+ expect(commit).not.toHaveBeenCalled()
+ vi.advanceTimersByTime(SCROLL_SETTLE_MS)
+ expect(commit).toHaveBeenCalledTimes(1)
+ expect(commit).toHaveBeenCalledWith(600)
+ })
+
+ it('a new owner first hands up the old one, so a navigation mid-scroll keeps its place', () => {
+ const settle = createScrollSettle()
+ const first = vi.fn()
+ const second = vi.fn()
+ settle.scrolled('a', 300, first)
+ settle.scrolled('b', 0, second)
+ expect(first).toHaveBeenCalledWith(300)
+ expect(second).not.toHaveBeenCalled()
+ vi.advanceTimersByTime(SCROLL_SETTLE_MS)
+ expect(second).toHaveBeenCalledWith(0)
+ })
+
+ it('flush hands up at once; cancel drops it', () => {
+ const settle = createScrollSettle()
+ const commit = vi.fn()
+ settle.scrolled('a', 40, commit)
+ settle.flush()
+ expect(commit).toHaveBeenCalledWith(40)
+ settle.scrolled('a', 80, commit)
+ settle.cancel()
+ vi.advanceTimersByTime(SCROLL_SETTLE_MS * 2)
+ expect(commit).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/src/renderer/src/lib/scrollSettle.ts b/src/renderer/src/lib/scrollSettle.ts
new file mode 100644
index 00000000..dcc6d402
--- /dev/null
+++ b/src/renderer/src/lib/scrollSettle.ts
@@ -0,0 +1,58 @@
+/**
+ * A SCROLL IS HANDED UP WHEN IT SETTLES (review, 2026-10-16). The Explorer's
+ * list used to hand every native scroll event to the tab, which re-rendered
+ * the whole App and re-sorted the folder per frame. The list keeps the live
+ * position itself and hands it up here, once, after the scrolling stops.
+ *
+ * `owner` is whoever the position belongs to (the history entry's handler):
+ * a scroll for a new owner first hands up the old one's last position, so
+ * nothing is lost to a navigation in mid-scroll. Kept apart from React so
+ * the timing is testable.
+ */
+export const SCROLL_SETTLE_MS = 150
+
+export interface ScrollSettle {
+ /** The list scrolled to `top`: hand it up once it has stayed there. */
+ scrolled: (owner: unknown, top: number, commit: (top: number) => void) => void
+ /** Hand up what is pending now (the list is going away). */
+ flush: () => void
+ /** Drop what is pending (it was handed up some other way). */
+ cancel: () => void
+}
+
+export function createScrollSettle(
+ delay = SCROLL_SETTLE_MS,
+ timers: {
+ set: (run: () => void, ms: number) => unknown
+ clear: (handle: unknown) => void
+ } = {
+ set: (run, ms) => setTimeout(run, ms),
+ clear: (handle) => clearTimeout(handle as ReturnType)
+ }
+): ScrollSettle {
+ let pending: { owner: unknown; top: number; commit: (top: number) => void } | null = null
+ let timer: unknown = null
+ const stop = (): void => {
+ if (timer !== null) timers.clear(timer)
+ timer = null
+ }
+ const flush = (): void => {
+ stop()
+ const was = pending
+ pending = null
+ was?.commit(was.top)
+ }
+ return {
+ scrolled(owner, top, commit) {
+ if (pending && pending.owner !== owner) flush()
+ stop()
+ pending = { owner, top, commit }
+ timer = timers.set(flush, delay)
+ },
+ flush,
+ cancel() {
+ stop()
+ pending = null
+ }
+ }
+}
diff --git a/src/renderer/src/lib/tabs/tabs.test.ts b/src/renderer/src/lib/tabs/tabs.test.ts
index e7a7cb57..53d49c16 100644
--- a/src/renderer/src/lib/tabs/tabs.test.ts
+++ b/src/renderer/src/lib/tabs/tabs.test.ts
@@ -6,6 +6,8 @@ import {
travelBrowse,
setBrowsePreview,
replaceBrowsePath,
+ setBrowseScroll,
+ searchBrowse,
addExplorerTab,
addProjectTab,
ensurePinnedExplorer,
@@ -727,3 +729,35 @@ describe('going into a folder clears the preview (#300 review)', () => {
expect(went.browse.previewHeld).toBeUndefined()
})
})
+
+describe('setBrowseScroll (review, 2026-10-16)', () => {
+ const start = (): Tab[] => [{ ...tabOf('C:\\a', []), id: 't1', browse: newBrowse('C:\\a') }]
+ const placeOf = (tabs: Tab[]) => {
+ const b = tabs[0].browse
+ const e = b.history[b.cursor]
+ return { cursor: b.cursor, path: e.path, query: e.query, sort: e.sort }
+ }
+
+ it('a position that settles after a navigation lands on the entry it was made in', () => {
+ const tabs = start()
+ const at = placeOf(tabs)
+ const went = navigateBrowse(tabs, 't1', 'C:\\a\\b')
+ const kept = setBrowseScroll(went, 't1', at, 480)
+ expect(kept[0].browse.history[0].scrollTop).toBe(480)
+ expect(kept[0].browse.history[1].scrollTop).toBe(0)
+ // ...so Back finds it there.
+ expect(travelBrowse(kept, 't1', -1)[0].browse.history[0].scrollTop).toBe(480)
+ })
+
+ it('answers the same array when there is nothing to change, so the setState does nothing', () => {
+ const tabs = start()
+ expect(setBrowseScroll(tabs, 't1', placeOf(tabs), 0)).toBe(tabs)
+ })
+
+ it('leaves an entry that has since changed what it shows', () => {
+ const tabs = searchBrowse(start(), 't1', 'cat')
+ const at = placeOf(tabs)
+ const refined = searchBrowse(tabs, 't1', 'cats')
+ expect(setBrowseScroll(refined, 't1', at, 900)).toBe(refined)
+ })
+})
diff --git a/src/renderer/src/lib/tabs/tabs.ts b/src/renderer/src/lib/tabs/tabs.ts
index 562d2036..3498830d 100644
--- a/src/renderer/src/lib/tabs/tabs.ts
+++ b/src/renderer/src/lib/tabs/tabs.ts
@@ -283,6 +283,51 @@ export function setBrowseLocation(
)
}
+/** The history entry a scroll position was made in: its place in the history
+ * and what it showed there. */
+export interface BrowseScrollAt {
+ cursor: number
+ path: string
+ query: string
+ sort: BrowseLocation['sort']
+}
+
+/**
+ * A SETTLED SCROLL, HANDED UP ONCE (review, 2026-10-16). The list used to
+ * patch the tab on every native scroll event, which re-rendered the whole App
+ * and re-sorted the folder per frame, and sent every tab's history to main.
+ * The list now scrolls on its own and hands the position up when it settles,
+ * which can be after a navigation, so it lands on the entry it was MADE in
+ * (`at`), never on whatever is current. An entry that has since changed what
+ * it shows (a refined search, a new sort) keeps its own position. Nothing to
+ * change answers the same array, so the setState does nothing.
+ */
+export function setBrowseScroll(
+ tabs: Tab[],
+ tabId: string,
+ at: BrowseScrollAt,
+ scrollTop: number
+): Tab[] {
+ const index = tabs.findIndex((tab) => tab.id === tabId)
+ const tab = tabs[index]
+ if (!tab || tab.kind === 'settings') return tabs
+ const entry = tab.browse.history[at.cursor]
+ if (
+ !entry ||
+ entry.scrollTop === scrollTop ||
+ entry.path !== at.path ||
+ entry.query !== at.query ||
+ entry.sort.key !== at.sort.key ||
+ entry.sort.direction !== at.sort.direction
+ )
+ return tabs
+ const history = tab.browse.history.slice()
+ history[at.cursor] = { ...entry, scrollTop }
+ const next = tabs.slice()
+ next[index] = { ...tab, browse: { ...tab.browse, history } }
+ return next
+}
+
/**
* The place on screen turned out to be another (#300: a folder gone from
* inside a zip answers the nearest one still there). The CURRENT history