Skip to content

Commit 9886fcd

Browse files
committed
Save last dir for each volume
- Also add docs into `persistence-stores.md`
1 parent 36164b5 commit 9886fcd

7 files changed

Lines changed: 417 additions & 21 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Persistence stores
2+
3+
Rusty Commander uses three persistent storage mechanisms, each for a distinct purpose. All are stored in the app's data
4+
directory (on macOS: `~/Library/Application Support/com.rustycommander.app/`).
5+
6+
## 1. App status store (`app-status.json`)
7+
8+
**Purpose**: Stores the current state of the application - where the user "is" in the app.
9+
10+
**File**: `src/lib/app-status-store.ts`
11+
12+
**Contents**:
13+
14+
- `leftPath`, `rightPath` - Current directory paths for each pane
15+
- `focusedPane` - Which pane (`left` or `right`) has focus
16+
- `leftViewMode`, `rightViewMode` - View mode (`full` or `brief`) for each pane
17+
- `leftVolumeId`, `rightVolumeId` - Currently selected volume for each pane
18+
- `lastUsedPaths` - Map of `volumeId` -> last-used path; used to restore position when switching volumes
19+
20+
**Restored**: On app startup. Paths are validated and gracefully fall back to parent dirs or home if they no longer
21+
exist.
22+
23+
## 2. Settings store (`settings.json`)
24+
25+
**Purpose**: Stores user preferences—how the user likes to work.
26+
27+
**File**: `src/lib/settings-store.ts`
28+
29+
**Contents**:
30+
31+
- `showHiddenFiles` - Whether to show hidden files (also synced with the View menu)
32+
- `fullDiskAccessChoice` - Full disk access permission state (`allow`, `deny`, or `notAskedYet`)
33+
34+
**Restored**: On app startup. Settings can also be changed via the menu and are persisted immediately.
35+
36+
## 3. Window state (plugin-managed)
37+
38+
**Purpose**: Stores window size and position.
39+
40+
**File**: Managed by `@tauri-apps/plugin-window-state` (stores in a plugin-specific file)
41+
42+
**Contents**:
43+
44+
- Window position, size, and display
45+
46+
**Restored**: Automatically by the plugin on window creation. By default, saves on quit, but we also save on resize (see
47+
`src/lib/window-state.ts`) to persist across hot reloads.
48+
49+
## Design philosophy
50+
51+
- **Status vs settings**: Status is ephemeral state (where you are), settings are preferences (how you like things). If
52+
the user resets "status", they should start fresh. If they reset "settings", they should get default preferences back.
53+
- **Per-volume paths**: The `lastUsedPaths` map in app-status allows remembering where the user was on each volume.
54+
Switching volumes restores the last used directory. Favorites are shortcuts within volumes, so they don't store
55+
separate paths—they use the containing volume's path storage.
56+
- **Graceful degradation**: All stores silently fail if persistence fails—the app works with defaults.

docs/todo.md

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
## Listing
22

3-
- [x] Allow user to go up from favorites. In the volume selector, we currently put a tick next to the selected volume,
4-
but for favorites, they should never have the checkmark. When selected, the volume containing them should be
5-
ticked. And the "root" folder of the favorite should not actually be a root folder, but should contain ".." and
6-
act in every way like the volume that contains it. Basically, favorites should just be shortcuts, not volumes.
7-
- [ ] Save to state the last used directory per volume. Save it to the same state store where we save showHiddenFiles
3+
- [x] Save to state the last used directory per volume. Save it to the same state store where we save showHiddenFiles
84
and the such. Use it when switching volumes. (Favorites are not volumes, they are just shortcuts to directories,
95
so their state belongs to the volume that contains them.)
106
- [ ] Add Back/Forward feature. I want to add a Back/Forward feature to let the user navigate back and forth between

src/lib/app-status-store.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,49 @@ export async function saveAppStatus(status: Partial<AppStatus>): Promise<void> {
126126
if (status.rightVolumeId !== undefined) {
127127
await store.set('rightVolumeId', status.rightVolumeId)
128128
}
129+
await store.save()
130+
} catch {
131+
// Silently fail - persistence is nice-to-have
132+
}
133+
}
134+
135+
/** Map of volumeId -> last used path for that volume */
136+
export type VolumePathMap = Record<string, string>
137+
138+
function isValidPathMap(value: unknown): value is VolumePathMap {
139+
if (typeof value !== 'object' || value === null) return false
140+
return Object.entries(value).every(([k, v]) => typeof k === 'string' && typeof v === 'string')
141+
}
142+
143+
/**
144+
* Gets the last used path for a specific volume.
145+
* Returns undefined if no path is stored.
146+
*/
147+
export async function getLastUsedPathForVolume(volumeId: string): Promise<string | undefined> {
148+
try {
149+
const store = await getStore()
150+
const lastUsedPaths = await store.get('lastUsedPaths')
151+
if (isValidPathMap(lastUsedPaths)) {
152+
return lastUsedPaths[volumeId]
153+
}
154+
return undefined
155+
} catch {
156+
return undefined
157+
}
158+
}
159+
160+
/**
161+
* Saves the last used path for a specific volume.
162+
* This is more efficient than loading/saving the full status.
163+
*/
164+
export async function saveLastUsedPathForVolume(volumeId: string, path: string): Promise<void> {
165+
try {
166+
const store = await getStore()
167+
const lastUsedPaths = await store.get('lastUsedPaths')
168+
const paths: VolumePathMap = isValidPathMap(lastUsedPaths) ? lastUsedPaths : {}
169+
paths[volumeId] = path
170+
await store.set('lastUsedPaths', paths)
171+
await store.save()
129172
} catch {
130173
// Silently fail - persistence is nice-to-have
131174
}

src/lib/file-explorer/DualPaneExplorer.svelte

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,20 @@
22
import { onMount, onDestroy } from 'svelte'
33
import FilePane from './FilePane.svelte'
44
import LoadingIcon from '../LoadingIcon.svelte'
5-
import { loadAppStatus, saveAppStatus, type ViewMode } from '$lib/app-status-store'
5+
import {
6+
loadAppStatus,
7+
saveAppStatus,
8+
getLastUsedPathForVolume,
9+
saveLastUsedPathForVolume,
10+
type ViewMode,
11+
} from '$lib/app-status-store'
612
import { loadSettings, saveSettings, subscribeToSettingsChanges } from '$lib/settings-store'
713
import {
814
pathExists,
915
listen,
1016
listVolumes,
1117
getDefaultVolumeId,
18+
findContainingVolume,
1219
DEFAULT_VOLUME_ID,
1320
type UnlistenFn,
1421
} from '$lib/tauri-commands'
@@ -40,27 +47,63 @@
4047
function handleLeftPathChange(path: string) {
4148
leftPath = path
4249
void saveAppStatus({ leftPath: path })
50+
void saveLastUsedPathForVolume(leftVolumeId, path)
4351
// Re-focus to maintain keyboard handling after navigation
4452
containerElement?.focus()
4553
}
4654
4755
function handleRightPathChange(path: string) {
4856
rightPath = path
4957
void saveAppStatus({ rightPath: path })
58+
void saveLastUsedPathForVolume(rightVolumeId, path)
5059
// Re-focus to maintain keyboard handling after navigation
5160
containerElement?.focus()
5261
}
5362
54-
function handleLeftVolumeChange(volumeId: string, volumePath: string, targetPath: string) {
63+
async function handleLeftVolumeChange(volumeId: string, volumePath: string, targetPath: string) {
64+
// Save the current path for the old volume before switching
65+
void saveLastUsedPathForVolume(leftVolumeId, leftPath)
66+
67+
const pathToNavigate = await determineNavigationPath(volumeId, volumePath, targetPath)
68+
5569
leftVolumeId = volumeId
56-
leftPath = targetPath
57-
void saveAppStatus({ leftVolumeId: volumeId, leftPath: targetPath })
70+
leftPath = pathToNavigate
71+
void saveAppStatus({ leftVolumeId: volumeId, leftPath: pathToNavigate })
5872
}
5973
60-
function handleRightVolumeChange(volumeId: string, volumePath: string, targetPath: string) {
74+
async function handleRightVolumeChange(volumeId: string, volumePath: string, targetPath: string) {
75+
// Save the current path for the old volume before switching
76+
void saveLastUsedPathForVolume(rightVolumeId, rightPath)
77+
78+
const pathToNavigate = await determineNavigationPath(volumeId, volumePath, targetPath)
79+
6180
rightVolumeId = volumeId
62-
rightPath = targetPath
63-
void saveAppStatus({ rightVolumeId: volumeId, rightPath: targetPath })
81+
rightPath = pathToNavigate
82+
void saveAppStatus({ rightVolumeId: volumeId, rightPath: pathToNavigate })
83+
}
84+
85+
/**
86+
* Determines which path to navigate to when switching volumes.
87+
* - If targetPath !== volumePath: user selected a favorite → go there directly
88+
* - Otherwise: look up last used path, or default to ~ for main volume, volume root for others
89+
*/
90+
async function determineNavigationPath(volumeId: string, volumePath: string, targetPath: string): Promise<string> {
91+
// User selected a favorite - go to the favorite's path directly
92+
if (targetPath !== volumePath) {
93+
return targetPath
94+
}
95+
96+
// Look up the last used path for this volume
97+
const lastUsedPath = await getLastUsedPathForVolume(volumeId)
98+
if (lastUsedPath && (await pathExists(lastUsedPath))) {
99+
return lastUsedPath
100+
}
101+
102+
// Default: ~ for main volume (root), volume path for others
103+
if (volumeId === DEFAULT_VOLUME_ID) {
104+
return '~'
105+
}
106+
return volumePath
64107
}
65108
66109
function handleLeftFocus() {
@@ -126,10 +169,15 @@
126169
leftViewMode = status.leftViewMode
127170
rightViewMode = status.rightViewMode
128171
129-
// Validate persisted volume IDs exist, fallback to default if not
172+
// Determine the correct volume IDs by finding which volume contains each path
173+
// This is more reliable than trusting the stored volumeId, which may be stale
130174
const defaultId = await getDefaultVolumeId()
131-
leftVolumeId = volumes.some((v) => v.id === status.leftVolumeId) ? status.leftVolumeId : defaultId
132-
rightVolumeId = volumes.some((v) => v.id === status.rightVolumeId) ? status.rightVolumeId : defaultId
175+
const [leftContaining, rightContaining] = await Promise.all([
176+
findContainingVolume(status.leftPath),
177+
findContainingVolume(status.rightPath),
178+
])
179+
leftVolumeId = leftContaining?.id ?? defaultId
180+
rightVolumeId = rightContaining?.id ?? defaultId
133181
134182
initialized = true
135183

src/lib/file-explorer/DualPaneExplorer.test.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ vi.mock('$lib/app-status-store', () => ({
1313
rightVolumeId: 'root',
1414
}),
1515
saveAppStatus: vi.fn().mockResolvedValue(undefined),
16+
getLastUsedPathForVolume: vi.fn().mockResolvedValue(undefined),
17+
saveLastUsedPathForVolume: vi.fn().mockResolvedValue(undefined),
1618
}))
1719

1820
vi.mock('@tauri-apps/api/event', () => ({
@@ -81,11 +83,13 @@ describe('DualPaneExplorer', () => {
8183
const target = document.createElement('div')
8284
mount(DualPaneExplorer, { target })
8385

84-
// Wait for async initialization (paths, volumes, settings)
85-
await tick()
86-
await tick()
87-
await tick()
88-
await tick()
86+
// Wait for async initialization (paths, volumes, settings, findContainingVolume)
87+
// The initialization now includes more async calls, so we need more ticks
88+
for (let i = 0; i < 10; i++) {
89+
await tick()
90+
}
91+
// Small additional delay to ensure all promises resolve
92+
await new Promise((resolve) => setTimeout(resolve, 10))
8993
await tick()
9094

9195
const panes = target.querySelectorAll('.file-pane')

src/lib/file-explorer/FilePane.svelte

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -503,8 +503,10 @@
503503
{currentPath}
504504
onVolumeChange={(newVolumeId: string, newVolumePath: string, targetPath: string) => {
505505
// Navigate to the target path (may differ from volume root for favorites)
506+
// Note: We intentionally don't call onPathChange here - the volume change handler
507+
// in DualPaneExplorer takes care of saving both the old volume's path and the new path.
508+
// Calling onPathChange would save the new path under the OLD volume ID (race condition).
506509
currentPath = targetPath
507-
onPathChange?.(targetPath)
508510
onVolumeChange?.(newVolumeId, newVolumePath, targetPath)
509511
void loadDirectory(targetPath)
510512
}}

0 commit comments

Comments
 (0)