Skip to content

Commit 22bf7e0

Browse files
swear01HAPI
andauthored
fix(web): persist file explorer expanded tree and scroll position across navigation (#911)
* fix(web): persist file explorer expanded tree and scroll position across navigation Expanded folder state and scroll position in the Directories tab were stored only in local React state, so navigating to a file and back would reset the tree to the root and scroll to top. Now both are saved to sessionStorage (keyed by sessionId) on every change and restored on remount, so the explorer resumes exactly where the user left off. Closes #910 via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(web): key DirectoryTree by sessionId to prevent stale expanded state across sessions When navigating between sessions, React can reuse the same DirectoryTree instance. The useState lazy initializer only runs on first mount, so the tree would hydrate with the wrong session's expanded set and then overwrite the new session's storage key. Adding key={sessionId} forces a fresh mount per session. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> --------- Co-authored-by: HAPI <noreply@hapi.run>
1 parent a8e08e8 commit 22bf7e0

2 files changed

Lines changed: 55 additions & 4 deletions

File tree

web/src/components/SessionFiles/DirectoryTree.tsx

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useMemo, useState } from 'react'
1+
import { useCallback, useEffect, useMemo, useState } from 'react'
22
import type { ApiClient } from '@/api/client'
33
import { FileIcon } from '@/components/FileIcon'
44
import { useSessionDirectory } from '@/hooks/queries/useSessionDirectory'
@@ -171,13 +171,40 @@ function DirectoryNode(props: {
171171
)
172172
}
173173

174+
const STORAGE_KEY_PREFIX = 'hapi-dir-expanded-'
175+
176+
function readExpanded(sessionId: string): Set<string> {
177+
try {
178+
const raw = sessionStorage.getItem(STORAGE_KEY_PREFIX + sessionId)
179+
if (raw) {
180+
const parsed = JSON.parse(raw)
181+
if (Array.isArray(parsed)) return new Set(parsed as string[])
182+
}
183+
} catch {
184+
// ignore
185+
}
186+
return new Set([''])
187+
}
188+
189+
function writeExpanded(sessionId: string, expanded: Set<string>) {
190+
try {
191+
sessionStorage.setItem(STORAGE_KEY_PREFIX + sessionId, JSON.stringify([...expanded]))
192+
} catch {
193+
// ignore
194+
}
195+
}
196+
174197
export function DirectoryTree(props: {
175198
api: ApiClient | null
176199
sessionId: string
177200
rootLabel: string
178201
onOpenFile: (path: string) => void
179202
}) {
180-
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(['']))
203+
const [expanded, setExpanded] = useState<Set<string>>(() => readExpanded(props.sessionId))
204+
205+
useEffect(() => {
206+
writeExpanded(props.sessionId, expanded)
207+
}, [props.sessionId, expanded])
181208

182209
const handleToggle = useCallback((path: string) => {
183210
setExpanded((prev) => {

web/src/routes/sessions/files.tsx

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useMemo, useState } from 'react'
1+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
22
import { useNavigate, useParams, useSearch } from '@tanstack/react-router'
33
import type { FileSearchItem, GitFileStatus } from '@/types/api'
44
import { FileIcon } from '@/components/FileIcon'
@@ -236,6 +236,8 @@ function FileListSkeleton(props: { label: string; rows?: number }) {
236236
)
237237
}
238238

239+
const SCROLL_KEY_PREFIX = 'hapi-dir-scroll-'
240+
239241
export default function FilesPage() {
240242
const { api } = useAppContext()
241243
const { t } = useTranslation()
@@ -246,10 +248,31 @@ export default function FilesPage() {
246248
const search = useSearch({ from: '/sessions/$sessionId/files' })
247249
const { session } = useSession(api, sessionId)
248250
const [searchQuery, setSearchQuery] = useState('')
251+
const scrollRef = useRef<HTMLDivElement>(null)
249252

250253
const initialTab = search.tab === 'directories' ? 'directories' : 'changes'
251254
const [activeTab, setActiveTab] = useState<'changes' | 'directories'>(initialTab)
252255

256+
useEffect(() => {
257+
const el = scrollRef.current
258+
if (!el) return
259+
const key = SCROLL_KEY_PREFIX + sessionId
260+
try {
261+
const saved = sessionStorage.getItem(key)
262+
if (saved !== null) el.scrollTop = Number(saved)
263+
} catch {
264+
// ignore
265+
}
266+
return () => {
267+
try {
268+
sessionStorage.setItem(key, String(el.scrollTop))
269+
} catch {
270+
// ignore
271+
}
272+
}
273+
// eslint-disable-next-line react-hooks/exhaustive-deps
274+
}, [sessionId])
275+
253276
const {
254277
status: gitStatus,
255278
error: gitError,
@@ -411,7 +434,7 @@ export default function FilesPage() {
411434
</div>
412435
) : null}
413436

414-
<div className="app-scroll-y flex-1 min-h-0">
437+
<div ref={scrollRef} className="app-scroll-y flex-1 min-h-0">
415438
<div className="mx-auto w-full max-w-content">
416439
{showGitErrorBanner && activeTab === 'changes' ? (
417440
<div className="border-b border-[var(--app-divider)] bg-amber-500/10 px-3 py-2 text-xs text-[var(--app-hint)]">
@@ -441,6 +464,7 @@ export default function FilesPage() {
441464
)
442465
) : activeTab === 'directories' ? (
443466
<DirectoryTree
467+
key={sessionId}
444468
api={api}
445469
sessionId={sessionId}
446470
rootLabel={rootLabel}

0 commit comments

Comments
 (0)