Skip to content

Commit 062ebbb

Browse files
committed
File explorer: Add explorer-state module store
- New `explorer-state.svelte.ts`: a module store owning the dual-pane navigation + UI-chrome state that `DualPaneExplorer` traps in component closures today — `focusedPane`, `showHiddenFiles`, `leftPaneWidthPercent`, and the two tab-manager holders. - State is module-private: `createExplorerState()` closes over `$state` locals and exposes only getters plus one named mutator per field (`setFocusedPane`, `setShowHiddenFiles`/`toggleHiddenFiles`, `setLeftPaneWidthPercent`, `getTabMgr`/`setTabMgr`). No writable surface leaks. - `getTabMgr(pane)` returns the live `$state<TabManager>` holder, never a copy/snapshot, so a `$derived` reading through it keeps tracking when the holder is swapped or the held manager mutates — the reactivity-transparency contract the `PaneAccess` getters rely on. - Factory-first for testability plus a module-level `explorerState` singleton (what the component will bind) and a `_resetForTesting()` reset, following the `snapshot-store` precedent. - TDD tests in `explorer-state.svelte.test.ts`: defaults, getter/mutator round-trips, factory isolation, `_resetForTesting`, and the live-reference reactivity contract via `$effect.root` + `flushSync`. - The tab managers stay values the store holds, not store fields: they keep their existing setter-based API, mutated via the `tab-state-manager` / `tab-operations` free functions. The store holds the holder reference and swaps it via `setTabMgr`. Documented the store, its writers list, and the scope boundary in `pane/CLAUDE.md`. - Not yet wired into the component (consumed in the next milestone).
1 parent 801bf51 commit 062ebbb

3 files changed

Lines changed: 361 additions & 0 deletions

File tree

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ list).
3232

3333
| File | Purpose |
3434
| -------------------------------- | --------------------------------------------------------------------------------------- |
35+
| `explorer-state.svelte.ts` | Explorer store: `focusedPane`, `showHiddenFiles`, layout split, the two tab-mgr holders |
3536
| `dialog-state.svelte.ts` | Dialog props + handlers (transfer, delete, mkdir, alert, error); factory |
3637
| `selection-state.svelte.ts` | `SvelteSet<number>` of indices + range anchor/end + `applyIndices` helpers |
3738
| `rename-flow.svelte.ts` | Rename validation, conflict + extension dialogs, save / cancel |
@@ -98,6 +99,35 @@ that state is the explorer-store phase, not this factoring. `moveCursorByName*`
9899
though they're called from component-resident writers (`moveCursor`, `restoreCursorByFilename`, `navigateToPath`); those
99100
callers reach back via `paneCommands.*`.
100101

102+
**Explorer store (`explorer-state.svelte.ts`).** Module store owning the dual-pane navigation + UI-chrome state that
103+
`DualPaneExplorer` used to trap in component closures: `focusedPane`, `showHiddenFiles`, `leftPaneWidthPercent`, and the
104+
two tab-manager holders. State is module-private (A1): `createExplorerState()` closes over `$state` locals and exposes
105+
only getters + one named mutator per field. There's no exported writable surface — callers can't assign a field, only
106+
call a mutator (A2; the `cmdr/no-explorer-state-writes` lint rule makes this a hard wall once it lands in M5).
107+
`createExplorerState()` is factory-first for testability; the module-level `explorerState` singleton is what the
108+
component binds, with `_resetForTesting()` for tests that touch it.
109+
110+
The **writers** (A2 — exactly one mutator per field, all inside the store module):
111+
112+
| Field | Mutator(s) |
113+
| ---------------------- | ----------------------------------------- |
114+
| `focusedPane` | `setFocusedPane` |
115+
| `showHiddenFiles` | `setShowHiddenFiles`, `toggleHiddenFiles` |
116+
| `leftPaneWidthPercent` | `setLeftPaneWidthPercent` |
117+
| `leftTabMgr` | `setTabMgr('left', …)` |
118+
| `rightTabMgr` | `setTabMgr('right', …)` |
119+
120+
**A1/A2-vs-tab-manager scope boundary.** The private-state + one-mutator rules govern the store's **own** fields only.
121+
The tab managers are _values the store holds_, not store fields: they keep their existing setter-based API
122+
(`createTabManager`) and are mutated via the free functions in `tabs/tab-state-manager.svelte` / `tab-operations`. The
123+
store holds the holder reference and swaps it via `setTabMgr`; it never wraps tab-manager setters behind store intents.
124+
125+
**Live-reference getters.** `getTabMgr(pane)` returns the live `$state<TabManager>` holder, never a copy or a
126+
`$state.snapshot` — a `$derived` reading `getActiveTab(getTabMgr(p))` keeps tracking both when the holder is swapped and
127+
when the held manager mutates in place. Returning a snapshot would silently sever reactivity at the seam (the same rule
128+
`pane-access.ts` documents). What the store does NOT own: `cursorIndex`, selection, and listing UI state stay local to
129+
`FilePane` (perf invariant P3).
130+
101131
**Cross-pane drag.** `DualPaneExplorer.getFileAndPathUnderCursor()` prefers `FilePane.getPathUnderCursor()` over
102132
`${currentPath}/${filename}` so snapshot-pane drags carry real filesystem paths, not `search-results://sr-N/<name>`.
103133

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/**
2+
* Tests for the explorer store (`explorer-state.svelte.ts`).
3+
*
4+
* The store un-traps `DualPaneExplorer`'s navigation + UI-chrome state into one
5+
* module: `focusedPane`, `showHiddenFiles`, `leftPaneWidthPercent`, and the two
6+
* tab-manager holders. State is module-private; only getters and named mutators
7+
* cross the boundary (A1/A2).
8+
*
9+
* Coverage:
10+
* - factory isolation (two instances never share state),
11+
* - getter/mutator round-trips for every field,
12+
* - `_resetForTesting` clears the default instance to defaults,
13+
* - `getTabMgr` returns the LIVE `$state` holder reference, so a `$derived`
14+
* reading through it keeps tracking after `setTabMgr` swaps the holder (the
15+
* reactivity-transparency contract the Phase-0 `PaneAccess` getters rely on).
16+
*
17+
* This is a `.svelte.test.ts` so the test body itself gets rune compilation —
18+
* the live-reference assertion needs a real `$derived` + `$effect.root`.
19+
*/
20+
21+
import { describe, it, expect, beforeEach } from 'vitest'
22+
import { flushSync } from 'svelte'
23+
import { createExplorerState, explorerState, _resetForTesting } from './explorer-state.svelte'
24+
import { createTabManager, getActiveTab, type TabManager } from '../tabs/tab-state-manager.svelte'
25+
import { createInitialTabState } from './tab-operations'
26+
27+
/** A throwaway tab manager rooted at `path` on the default volume. */
28+
function mgrAt(path: string): TabManager {
29+
return createTabManager(createInitialTabState(path, 'root'))
30+
}
31+
32+
describe('createExplorerState: defaults', () => {
33+
it('starts left-focused, hidden files shown, panes split 50/50', () => {
34+
const s = createExplorerState()
35+
expect(s.getFocusedPane()).toBe('left')
36+
expect(s.getShowHiddenFiles()).toBe(true)
37+
expect(s.getLeftPaneWidthPercent()).toBe(50)
38+
})
39+
40+
it('starts with a tab manager per pane, each at the home folder', () => {
41+
const s = createExplorerState()
42+
expect(getActiveTab(s.getTabMgr('left')).path).toBe('~')
43+
expect(getActiveTab(s.getTabMgr('right')).path).toBe('~')
44+
expect(s.getTabMgr('left')).not.toBe(s.getTabMgr('right'))
45+
})
46+
})
47+
48+
describe('createExplorerState: getter/mutator round-trips', () => {
49+
it('setFocusedPane stores the focused pane', () => {
50+
const s = createExplorerState()
51+
s.setFocusedPane('right')
52+
expect(s.getFocusedPane()).toBe('right')
53+
s.setFocusedPane('left')
54+
expect(s.getFocusedPane()).toBe('left')
55+
})
56+
57+
it('setShowHiddenFiles stores the flag', () => {
58+
const s = createExplorerState()
59+
s.setShowHiddenFiles(false)
60+
expect(s.getShowHiddenFiles()).toBe(false)
61+
s.setShowHiddenFiles(true)
62+
expect(s.getShowHiddenFiles()).toBe(true)
63+
})
64+
65+
it('toggleHiddenFiles flips the flag', () => {
66+
const s = createExplorerState()
67+
expect(s.getShowHiddenFiles()).toBe(true)
68+
s.toggleHiddenFiles()
69+
expect(s.getShowHiddenFiles()).toBe(false)
70+
s.toggleHiddenFiles()
71+
expect(s.getShowHiddenFiles()).toBe(true)
72+
})
73+
74+
it('setLeftPaneWidthPercent stores the layout split', () => {
75+
const s = createExplorerState()
76+
s.setLeftPaneWidthPercent(33)
77+
expect(s.getLeftPaneWidthPercent()).toBe(33)
78+
})
79+
80+
it('setTabMgr swaps the holder for the given pane only', () => {
81+
// `$state<TabManager>` wraps the held object in a reactive proxy, so the
82+
// contract is behavioral (the active tab read through the holder), not
83+
// proxy identity — `getTabMgr` returns a live reference, which a `===`
84+
// check against the pre-proxy object would wrongly reject.
85+
const s = createExplorerState()
86+
s.setTabMgr('left', mgrAt('/left'))
87+
s.setTabMgr('right', mgrAt('/right'))
88+
expect(getActiveTab(s.getTabMgr('left')).path).toBe('/left')
89+
expect(getActiveTab(s.getTabMgr('right')).path).toBe('/right')
90+
})
91+
})
92+
93+
describe('createExplorerState: factory isolation', () => {
94+
it('two instances do not share scalar state', () => {
95+
const a = createExplorerState()
96+
const b = createExplorerState()
97+
a.setFocusedPane('right')
98+
a.setShowHiddenFiles(false)
99+
a.setLeftPaneWidthPercent(20)
100+
expect(b.getFocusedPane()).toBe('left')
101+
expect(b.getShowHiddenFiles()).toBe(true)
102+
expect(b.getLeftPaneWidthPercent()).toBe(50)
103+
})
104+
105+
it('two instances do not share tab-manager holders', () => {
106+
const a = createExplorerState()
107+
const b = createExplorerState()
108+
a.setTabMgr('left', mgrAt('/a-left'))
109+
expect(getActiveTab(a.getTabMgr('left')).path).toBe('/a-left')
110+
expect(getActiveTab(b.getTabMgr('left')).path).toBe('~')
111+
})
112+
})
113+
114+
describe('explorerState default instance: _resetForTesting', () => {
115+
beforeEach(() => {
116+
_resetForTesting()
117+
})
118+
119+
it('clears every field back to defaults', () => {
120+
explorerState.setFocusedPane('right')
121+
explorerState.setShowHiddenFiles(false)
122+
explorerState.setLeftPaneWidthPercent(70)
123+
explorerState.setTabMgr('left', mgrAt('/scratch'))
124+
125+
_resetForTesting()
126+
127+
expect(explorerState.getFocusedPane()).toBe('left')
128+
expect(explorerState.getShowHiddenFiles()).toBe(true)
129+
expect(explorerState.getLeftPaneWidthPercent()).toBe(50)
130+
expect(getActiveTab(explorerState.getTabMgr('left')).path).toBe('~')
131+
expect(getActiveTab(explorerState.getTabMgr('right')).path).toBe('~')
132+
})
133+
})
134+
135+
describe('getTabMgr live-reference reactivity', () => {
136+
beforeEach(() => {
137+
_resetForTesting()
138+
})
139+
140+
it('a $derived reading through getTabMgr re-runs after setTabMgr swaps the holder', () => {
141+
const s = createExplorerState()
142+
143+
let observed = ''
144+
const dispose = $effect.root(() => {
145+
// The derived reads the holder through the getter, exactly like the
146+
// 12 per-pane component deriveds will after M2. If the getter returned a
147+
// copy/snapshot, this would stop tracking once the holder is swapped.
148+
const activePath = $derived(getActiveTab(s.getTabMgr('left')).path)
149+
$effect(() => {
150+
observed = activePath
151+
})
152+
})
153+
flushSync()
154+
expect(observed).toBe('~')
155+
156+
s.setTabMgr('left', mgrAt('/swapped'))
157+
flushSync()
158+
expect(observed).toBe('/swapped')
159+
160+
dispose()
161+
})
162+
163+
it('a $derived re-runs when the held tab manager mutates in place', () => {
164+
const s = createExplorerState()
165+
166+
let observed: number | undefined
167+
const dispose = $effect.root(() => {
168+
const tabCount = $derived(s.getTabMgr('left').tabs.length)
169+
$effect(() => {
170+
observed = tabCount
171+
})
172+
})
173+
flushSync()
174+
expect(observed).toBe(1)
175+
176+
// Mutating the live holder's reactive `tabs` array must reach the derived.
177+
s.getTabMgr('left').tabs = [...s.getTabMgr('left').tabs, createInitialTabState('/extra', 'root')]
178+
flushSync()
179+
expect(observed).toBe(2)
180+
181+
dispose()
182+
})
183+
})
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* Explorer store: the dual-pane explorer's navigation + UI-chrome state, lifted
3+
* out of `DualPaneExplorer`'s component closures into one module so consumers
4+
* read state directly instead of through `explorerRef` getters.
5+
*
6+
* Owns four of the component's fields:
7+
* - `focusedPane` — which pane has focus (`'left' | 'right'`),
8+
* - `showHiddenFiles` — the dotfile-visibility toggle,
9+
* - `leftPaneWidthPercent` — the layout split (the right pane is the remainder),
10+
* - the two **tab-manager holders** `leftTabMgr` / `rightTabMgr`, each a
11+
* `$state<TabManager>` reference.
12+
*
13+
* ## What this store does NOT own
14+
*
15+
* The tab managers are _values the store holds_, not store fields. They keep
16+
* their existing setter-based API (`createTabManager`) and are mutated through
17+
* the free functions in `tabs/tab-state-manager.svelte` / `tab-operations`. The
18+
* store only holds the *holder reference* and swaps it via `setTabMgr`; the
19+
* A1/A2 private-state + one-mutator rules govern the store's own fields, never
20+
* the tab-manager internals. `cursorIndex`, selection, and listing UI state stay
21+
* local to `FilePane` (perf invariant P3) — they're not here.
22+
*
23+
* ## Shape (A1/A2)
24+
*
25+
* State is module-private: `createExplorerState()` closes over `$state` locals
26+
* and exposes only getters and one named mutator per field. There is no exported
27+
* writable surface — callers can't assign a field, only call a mutator. The
28+
* `cmdr/no-explorer-state-writes` lint rule (added in M5) makes that a hard wall;
29+
* until then it's convention.
30+
*
31+
* ## Live references (reactivity transparency)
32+
*
33+
* `getTabMgr(pane)` returns the **live** `$state<TabManager>` holder, never a
34+
* copy or a `$state.snapshot`. A `$derived` reading `getActiveTab(getTabMgr(p))`
35+
* keeps tracking both when the holder is swapped (`setTabMgr`) and when the held
36+
* manager mutates in place. This is the Phase-0 `PaneAccess` contract: the
37+
* factories never see reactivity sever at the seam when state moves from the
38+
* component into this store. Verified by `explorer-state.svelte.test.ts`.
39+
*
40+
* ## Factory + default instance
41+
*
42+
* `createExplorerState()` is factory-first for testability (vitest instantiates
43+
* fresh instances that never share state). The module-level `explorerState`
44+
* singleton is what the component binds. `_resetForTesting()` resets the
45+
* singleton to defaults for tests that exercise it; `SvelteSet`/`SvelteMap`
46+
* (none here yet) would reset via `.clear()`, never reassignment.
47+
*
48+
* Writers are enumerated in this module's colocated `pane/CLAUDE.md` (A2).
49+
*/
50+
51+
import { DEFAULT_VOLUME_ID } from '$lib/tauri-commands'
52+
import { createTabManager, type TabManager } from '../tabs/tab-state-manager.svelte'
53+
import { createInitialTabState } from './tab-operations'
54+
55+
/** Default left/right split: an even 50/50 layout. */
56+
const DEFAULT_PANE_WIDTH_PERCENT = 50
57+
58+
/** Builds the per-pane starting tab manager: a single tab at the home folder. */
59+
function createDefaultTabMgr(): TabManager {
60+
return createTabManager(createInitialTabState('~', DEFAULT_VOLUME_ID))
61+
}
62+
63+
/**
64+
* The explorer store's public surface: getters + one named mutator per field,
65+
* plus the live-reference tab-manager holder accessors. No writable state leaks.
66+
*/
67+
export interface ExplorerState {
68+
/** Returns the focused pane. Reactive. */
69+
getFocusedPane: () => 'left' | 'right'
70+
/** Sets the focused pane. The single writer of `focusedPane`. */
71+
setFocusedPane: (pane: 'left' | 'right') => void
72+
73+
/** Returns whether hidden (dot) files are shown. Reactive. */
74+
getShowHiddenFiles: () => boolean
75+
/** Sets the hidden-files flag to an explicit value. */
76+
setShowHiddenFiles: (value: boolean) => void
77+
/** Flips the hidden-files flag. */
78+
toggleHiddenFiles: () => void
79+
80+
/** Returns the left pane's width as a percentage; the right pane is the remainder. Reactive. */
81+
getLeftPaneWidthPercent: () => number
82+
/** Sets the left pane's width percentage. */
83+
setLeftPaneWidthPercent: (percent: number) => void
84+
85+
/** Returns the LIVE tab-manager holder for `pane` (never a copy/snapshot). Reactive. */
86+
getTabMgr: (pane: 'left' | 'right') => TabManager
87+
/** Swaps the tab-manager holder for `pane` (e.g. when loading persisted tabs). */
88+
setTabMgr: (pane: 'left' | 'right', mgr: TabManager) => void
89+
}
90+
91+
/**
92+
* Creates a fresh explorer-state instance. Tests use this for full isolation;
93+
* the app binds the module-level `explorerState` singleton instead.
94+
*/
95+
export function createExplorerState(): ExplorerState {
96+
let focusedPane = $state<'left' | 'right'>('left')
97+
let showHiddenFiles = $state(true)
98+
let leftPaneWidthPercent = $state(DEFAULT_PANE_WIDTH_PERCENT)
99+
let leftTabMgr = $state<TabManager>(createDefaultTabMgr())
100+
let rightTabMgr = $state<TabManager>(createDefaultTabMgr())
101+
102+
return {
103+
getFocusedPane: () => focusedPane,
104+
setFocusedPane: (pane) => {
105+
focusedPane = pane
106+
},
107+
108+
getShowHiddenFiles: () => showHiddenFiles,
109+
setShowHiddenFiles: (value) => {
110+
showHiddenFiles = value
111+
},
112+
toggleHiddenFiles: () => {
113+
showHiddenFiles = !showHiddenFiles
114+
},
115+
116+
getLeftPaneWidthPercent: () => leftPaneWidthPercent,
117+
setLeftPaneWidthPercent: (percent) => {
118+
leftPaneWidthPercent = percent
119+
},
120+
121+
getTabMgr: (pane) => (pane === 'left' ? leftTabMgr : rightTabMgr),
122+
setTabMgr: (pane, mgr) => {
123+
if (pane === 'left') {
124+
leftTabMgr = mgr
125+
} else {
126+
rightTabMgr = mgr
127+
}
128+
},
129+
}
130+
}
131+
132+
/** The app-wide explorer store. The component binds this; tests reset it via `_resetForTesting`. */
133+
export const explorerState = createExplorerState()
134+
135+
/**
136+
* Test-only reset of the `explorerState` singleton back to defaults: left-focused,
137+
* hidden files shown, an even split, and a fresh home-folder tab manager per pane.
138+
* Tests that touch the singleton call this in `beforeEach`. Not for production use;
139+
* tests import it via the file path. Keep it in sync with the factory's defaults
140+
* whenever a new field is added.
141+
*/
142+
export function _resetForTesting(): void {
143+
explorerState.setFocusedPane('left')
144+
explorerState.setShowHiddenFiles(true)
145+
explorerState.setLeftPaneWidthPercent(DEFAULT_PANE_WIDTH_PERCENT)
146+
explorerState.setTabMgr('left', createDefaultTabMgr())
147+
explorerState.setTabMgr('right', createDefaultTabMgr())
148+
}

0 commit comments

Comments
 (0)