Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
13 changes: 8 additions & 5 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) => {
Expand Down
73 changes: 62 additions & 11 deletions src/renderer/src/components/browse/BrowseList.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
useEffect,
useLayoutEffect,
useMemo,
useRef,
Expand Down Expand Up @@ -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). */
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
: (() => {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(() =>
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -735,7 +786,7 @@ export function BrowseList(props: Props): JSX.Element {
<div
className="browse-row-layer"
style={{
transform: `translateY(${props.scrollTop + first * rowHeight - logicalTop}px)`
transform: `translateY(${liveTop + first * rowHeight - logicalTop}px)`
}}
>
{rendered.map((entry, offset) => {
Expand Down
3 changes: 3 additions & 0 deletions src/renderer/src/components/browse/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions src/renderer/src/components/sidebar/usePanelResize.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
51 changes: 44 additions & 7 deletions src/renderer/src/components/sidebar/usePanelResize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>({
key: 'prism.sidebar.width',
Expand Down Expand Up @@ -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<HTMLDivElement>) => {
e.preventDefault()
Expand All @@ -70,15 +103,19 @@ export function usePanelResize({ panel, right }: PanelResizeDeps): PanelResize {
(e: PointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
e.currentTarget.releasePointerCapture(e.pointerId)
setDragging(false)
}, [])
const onHandleUp = useCallback(
(e: PointerEvent<HTMLDivElement>) => {
e.currentTarget.releasePointerCapture(e.pointerId)
drag.end()
setDragging(false)
},
[drag]
)

const onHandleKey = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
Expand Down
29 changes: 29 additions & 0 deletions src/renderer/src/hooks/searchRefresh.test.ts
Original file line number Diff line number Diff line change
@@ -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/)
})
})
27 changes: 26 additions & 1 deletion src/renderer/src/hooks/useFolderBrowsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
searchBrowse,
travelBrowse,
setBrowseLocation,
setBrowseScroll,
setBrowseSurface,
setBrowsePreview,
isExplorerTab,
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -654,6 +678,7 @@ export function useFolderBrowsing(
navigate,
travel,
patch,
keepScroll,
searchFor,
showFolder,
openFile,
Expand Down
Loading
Loading