Skip to content

Commit d94187b

Browse files
committed
Search: M8a snapshot store and nav history cap
- New `lib/search/snapshot-store.svelte.ts` per plan §3.7: refcounted map of search-result snapshots with monotonic `sr-N` ids, 10,000-entry cap with label annotation on truncation, and a "last dialog attempt" slot. - `navigation-history.ts`: `MAX_HISTORY_PER_TAB = 100`, `push()` now returns `{ history, droppedEntries }` aggregating both truncated-forward and oldest-evicted entries. `pushPath` stays as a backward-compatible delegate. - `tab-state-manager.svelte.ts`: new `pushHistoryEntry` and `transferSnapshotRefs` helpers wire snapshot refcounting in one place. Close transfers refs to the closed-tab stack; reopen pops without inc/dec; stack eviction (cap overflow / trim) is the actual release point. Non-recording `closeTab` / `closeOtherTabs` release immediately. - `DualPaneExplorer.svelte`: 7 push callsites switched to `pushHistoryEntry`. - Tests: 99 new/updated assertions across `snapshot-store.svelte.ts.test.ts` (refcount lifecycle, last-attempt slot swaps, entries cap), `navigation-history.test.ts` (cap eviction, dropped-entries surface, cross-volume cap, pushPath delegate), and `tab-state-manager.test.ts` (push releases dropped refs; close/reopen/evict/trim ref lifecycle for both recording and non-recording paths). - Docs: search/CLAUDE.md gains a "Snapshot store (M8a)" section; navigation/CLAUDE.md documents the new `push()` return shape and cap; tabs/CLAUDE.md documents the transfer-then-release closed-stack model.
1 parent e52c6de commit d94187b

10 files changed

Lines changed: 1130 additions & 131 deletions

File tree

apps/desktop/src/lib/file-explorer/navigation/CLAUDE.md

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,28 @@ Purely functional: all operations return new objects, never mutate.
2727
```
2828
NavigationHistory = { stack: HistoryEntry[], currentIndex: number }
2929
HistoryEntry = { volumeId: string, path: string, networkHost?: NetworkHost }
30+
PushResult = { history: NavigationHistory, droppedEntries: HistoryEntry[] }
3031
```
3132

3233
Key functions: `createHistory`, `push`, `pushPath`, `back`, `forward`, `getCurrentEntry`, `getCurrentPath`, `canGoBack`,
33-
`canGoForward`, `setCurrentIndex`, `getEntryAt`.
34-
35-
`push` returns the **same reference** when the new entry equals the current one (deduplication). Callers can use
36-
reference equality to skip re-renders.
34+
`canGoForward`, `setCurrentIndex`, `getEntryAt`. Plus the constant `MAX_HISTORY_PER_TAB = 100`.
35+
36+
`push` returns `{ history, droppedEntries }`. `history` is the new stack; `droppedEntries` aggregates every entry the
37+
push removed: the truncated-forward tail (when pushing after `back()`) and the oldest entries evicted to honor
38+
`MAX_HISTORY_PER_TAB`. Callers that need to release per-entry resources iterate `droppedEntries`; the search-results
39+
snapshot store (`lib/search/snapshot-store.svelte.ts`) is the only consumer today. When the new entry equals the current
40+
entry, `push()` returns the **same `history` reference** with an empty `droppedEntries`, so callers using `===`
41+
deduplication still work.
42+
43+
`pushPath` is a thin delegate that calls `push` and returns just the new history (discarding `droppedEntries`). It's
44+
backwards-compatible for callers that don't care about released resources. Callers that need refcount-decrements (the
45+
tab-state manager) must use `push()` directly — or, more conveniently, the `pushHistoryEntry` helper exposed by
46+
`lib/file-explorer/tabs/tab-state-manager.svelte.ts`, which wraps the `push()` call and releases search-results snapshot
47+
refs in one step.
48+
49+
The cap (100) applies to every volume — local, network, MTP, search-results — uniformly. Tightening below 100 would
50+
start to hurt power users who navigate deeply and rely on `⌘[` for orientation. Bumping above 100 isn't necessary; each
51+
`HistoryEntry` is three string fields, so the memory headroom is comfortable.
3752

3853
Entries carry full `volumeId` (navigating back can cross volume boundaries, for example from an external drive back to
3954
`root`).

apps/desktop/src/lib/file-explorer/navigation/navigation-history.test.ts

Lines changed: 190 additions & 47 deletions
Large diffs are not rendered by default.

apps/desktop/src/lib/file-explorer/navigation/navigation-history.ts

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,19 @@
1313

1414
import type { NetworkHost } from '../types'
1515

16+
/**
17+
* Per-tab cap on the navigation stack. When `push()` would grow the stack past this
18+
* count, the oldest entries are dropped (FIFO) and returned in `droppedEntries` so
19+
* the caller can release any per-entry resources (search-results snapshot refs, in
20+
* the M8a wiring).
21+
*
22+
* 100 keeps a few sessions of casual browsing in reach while bounding memory: each
23+
* `HistoryEntry` is ~3 string fields, so even 100 entries per tab × 10 tabs × 2
24+
* panes is negligible. Tightening below 100 would start to hurt power users who
25+
* navigate deeply and rely on `⌘[` for orientation.
26+
*/
27+
export const MAX_HISTORY_PER_TAB = 100
28+
1629
/** A single entry in the navigation history */
1730
export interface HistoryEntry {
1831
/** The volume ID (like 'root', 'network', '/Volumes/MyDrive') */
@@ -30,6 +43,22 @@ export interface NavigationHistory {
3043
currentIndex: number
3144
}
3245

46+
/**
47+
* Result of `push()`. `history` is the new stack; `droppedEntries` aggregates
48+
* everything `push()` removed in service of the call:
49+
* - forward entries truncated when pushing after a `back()`
50+
* - oldest entries evicted to honor `MAX_HISTORY_PER_TAB`
51+
*
52+
* Callers that don't care about released resources can ignore `droppedEntries`
53+
* (and most do; the search-results snapshot store is the only consumer today).
54+
* `pushPath` is one such caller — it discards the field and returns just the
55+
* history for backwards compatibility.
56+
*/
57+
export interface PushResult {
58+
history: NavigationHistory
59+
droppedEntries: HistoryEntry[]
60+
}
61+
3362
/**
3463
* Creates a new history with the initial entry.
3564
*/
@@ -55,29 +84,53 @@ function entriesEqual(a: HistoryEntry, b: HistoryEntry): boolean {
5584

5685
/**
5786
* Pushes a new entry to the history stack.
58-
* Truncates any forward history (entries after currentIndex).
59-
* If the new entry is the same as the current entry, returns unchanged history.
87+
*
88+
* - Truncates any forward history (entries after currentIndex) and reports the
89+
* removed entries in `droppedEntries`.
90+
* - If the new entry equals the current entry, returns the unchanged history with
91+
* an empty `droppedEntries` (reference-equal on `history` so callers using `===`
92+
* dedup still work).
93+
* - Caps the stack at `MAX_HISTORY_PER_TAB`. Overflow evicts the **oldest** entries
94+
* (front of the stack); evicted entries are included in `droppedEntries` and the
95+
* `currentIndex` is adjusted so it still points at the same logical position.
96+
*
97+
* `droppedEntries` covers both kinds of removals; callers that care about released
98+
* resources (the search-results snapshot store, today) iterate the array once.
6099
*/
61-
export function push(history: NavigationHistory, entry: HistoryEntry): NavigationHistory {
100+
export function push(history: NavigationHistory, entry: HistoryEntry): PushResult {
62101
const currentEntry = history.stack[history.currentIndex]
63102
if (entriesEqual(entry, currentEntry)) {
64-
return history
103+
return { history, droppedEntries: [] }
65104
}
66105

67-
// Truncate forward history and add the new entry
68-
const newStack = [...history.stack.slice(0, history.currentIndex + 1), entry]
106+
// 1. Truncate forward history (entries after currentIndex are dropped).
107+
const truncated = history.stack.slice(history.currentIndex + 1)
108+
// 2. Append the new entry.
109+
let newStack = [...history.stack.slice(0, history.currentIndex + 1), entry]
110+
// 3. Evict the oldest entries if the cap was exceeded.
111+
let evictedFromFront: HistoryEntry[] = []
112+
if (newStack.length > MAX_HISTORY_PER_TAB) {
113+
const overflow = newStack.length - MAX_HISTORY_PER_TAB
114+
evictedFromFront = newStack.slice(0, overflow)
115+
newStack = newStack.slice(overflow)
116+
}
69117
return {
70-
stack: newStack,
71-
currentIndex: newStack.length - 1,
118+
history: {
119+
stack: newStack,
120+
currentIndex: newStack.length - 1,
121+
},
122+
droppedEntries: [...evictedFromFront, ...truncated],
72123
}
73124
}
74125

75126
/**
76127
* Convenience function to push just a path change (same volume).
128+
* Delegates to `push()` and returns only the new history (discards `droppedEntries`).
129+
* Callers that need to release per-entry resources should use `push()` directly.
77130
*/
78131
export function pushPath(history: NavigationHistory, path: string): NavigationHistory {
79132
const currentEntry = history.stack[history.currentIndex]
80-
return push(history, { volumeId: currentEntry.volumeId, path })
133+
return push(history, { volumeId: currentEntry.volumeId, path }).history
81134
}
82135

83136
/**

apps/desktop/src/lib/file-explorer/pane/DualPaneExplorer.svelte

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@
5050
5151
import {
5252
createHistory,
53-
push,
5453
pushPath,
5554
back,
5655
forward,
@@ -67,6 +66,7 @@
6766
getTabCount,
6867
closeTabRecording,
6968
closeOtherTabsRecording,
69+
pushHistoryEntry,
7070
trimClosedStack,
7171
getClosedStackSize,
7272
MAX_TABS_PER_PANE,
@@ -510,7 +510,7 @@
510510
function handleNetworkHostChange(pane: 'left' | 'right', host: NetworkHost | null) {
511511
setPaneHistory(
512512
pane,
513-
push(getPaneHistory(pane), {
513+
pushHistoryEntry(getPaneHistory(pane), {
514514
volumeId: 'network',
515515
path: 'smb://',
516516
networkHost: host ?? undefined,
@@ -648,7 +648,7 @@
648648
// In-place navigation (normal flow or pinned tab at cap)
649649
setPaneVolumeId(pane, volumeId)
650650
setPanePath(pane, targetPath)
651-
setPaneHistory(pane, push(getPaneHistory(pane), { volumeId, path: targetPath }))
651+
setPaneHistory(pane, pushHistoryEntry(getPaneHistory(pane), { volumeId, path: targetPath }))
652652
focusedPane = pane
653653
654654
saveAppStatus({
@@ -677,7 +677,7 @@
677677
if (generation !== volumeChangeGeneration) return
678678
if (betterPath !== targetPath && betterPath !== getPanePath(pane)) {
679679
setPanePath(pane, betterPath)
680-
setPaneHistory(pane, push(getPaneHistory(pane), { volumeId, path: betterPath }))
680+
setPaneHistory(pane, pushHistoryEntry(getPaneHistory(pane), { volumeId, path: betterPath }))
681681
saveAppStatus({ [paneKey(pane, 'path')]: betterPath })
682682
saveTabsForPaneSide(pane)
683683
}
@@ -758,7 +758,7 @@
758758
759759
setPaneVolumeId(pane, defaultVolumeId)
760760
setPanePath(pane, defaultPath)
761-
setPaneHistory(pane, push(getPaneHistory(pane), { volumeId: defaultVolumeId, path: defaultPath }))
761+
setPaneHistory(pane, pushHistoryEntry(getPaneHistory(pane), { volumeId: defaultVolumeId, path: defaultPath }))
762762
saveAppStatus({ [paneKey(pane, 'volumeId')]: defaultVolumeId, [paneKey(pane, 'path')]: defaultPath })
763763
saveTabsForPaneSide(pane)
764764
}
@@ -780,7 +780,7 @@
780780
tab.unreachable = null
781781
setPaneVolumeId(pane, volumeId)
782782
setPanePath(pane, originalPath)
783-
setPaneHistory(pane, push(getPaneHistory(pane), { volumeId, path: originalPath }))
783+
setPaneHistory(pane, pushHistoryEntry(getPaneHistory(pane), { volumeId, path: originalPath }))
784784
saveTabsForPaneSide(pane)
785785
786786
// Sync the volume selector; retry may have fixed a mount that was stale
@@ -800,7 +800,7 @@
800800
const homePath = '~'
801801
setPaneVolumeId(pane, defaultId)
802802
setPanePath(pane, homePath)
803-
setPaneHistory(pane, push(getPaneHistory(pane), { volumeId: defaultId, path: homePath }))
803+
setPaneHistory(pane, pushHistoryEntry(getPaneHistory(pane), { volumeId: defaultId, path: homePath }))
804804
saveTabsForPaneSide(pane)
805805
log.info('Unreachable tab opened home folder for {pane} pane', { pane })
806806
}
@@ -2478,7 +2478,7 @@
24782478
targetPaneRef?.setNetworkHost(host)
24792479
setPaneHistory(
24802480
target,
2481-
push(getPaneHistory(target), {
2481+
pushHistoryEntry(getPaneHistory(target), {
24822482
volumeId: 'network',
24832483
path: 'smb://',
24842484
networkHost: host ?? undefined,

apps/desktop/src/lib/file-explorer/tabs/CLAUDE.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,22 @@ Each entry stores `{ tab, originalIndex }` where `tab` is a `$state.snapshot` of
9898
`closeOtherTabsRecording` pushes closed tabs in right-to-left order (rightmost first). Popping in reverse and
9999
re-inserting at `originalIndex` restores the exact pre-close arrangement.
100100

101+
**Search-results snapshot refs (M8a)**: the closed-tab stack also carries snapshot-ref obligations for any
102+
`search-results://<id>` paths in the closed tab's history. The model is "transfer on close, release on eviction":
103+
104+
- `closeTabRecording` / `closeOtherTabsRecording` do **not** decrement snapshot refs when they push the closed tab onto
105+
the stack. The refs effectively transfer ownership from the live tab's history to the closed-stack entry, keeping the
106+
snapshot alive so a `⌘⇧T` reopen restores a usable pane.
107+
- `reopenLastClosedTab` just pops the entry back; refs are still alive, no inc/dec needed.
108+
- The stack's own eviction (`pushClosed` cap overflow or `trimClosedStack`) is the actual decrement point: each evicted
109+
entry's history is walked and every `search-results://` path releases a ref.
110+
- The non-recording `closeTab` / `closeOtherTabs` (used in tests and programmatic flows) release refs immediately, since
111+
the close isn't recorded anywhere.
112+
113+
The bookkeeping is concentrated in `tab-state-manager.svelte.ts`'s
114+
`transferSnapshotRefs(closedTab, 'transfer' | 'release')` helper, called once at each transition. See
115+
`lib/search/CLAUDE.md` § "Snapshot store (M8a, §3.7)" for the broader picture.
116+
101117
The Tab menu's "Reopen closed tab" item enables/disables based on the focused pane's stack via the
102118
`set_reopen_closed_tab_enabled` Tauri command (mirrors the `update_pin_tab_menu` pattern). Frontend pushes the state
103119
after every close, reopen, and focus change. Empty-stack reopen shows a toast ("No recently closed tabs in this pane.");

0 commit comments

Comments
 (0)