mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { ApiClient } from '@/api/client'
|
||||
import { FileIcon } from '@/components/FileIcon'
|
||||
import { useSessionDirectory } from '@/hooks/queries/useSessionDirectory'
|
||||
@@ -171,13 +171,40 @@ function DirectoryNode(props: {
|
||||
)
|
||||
}
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'hapi-dir-expanded-'
|
||||
|
||||
function readExpanded(sessionId: string): Set<string> {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY_PREFIX + sessionId)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed)) return new Set(parsed as string[])
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return new Set([''])
|
||||
}
|
||||
|
||||
function writeExpanded(sessionId: string, expanded: Set<string>) {
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY_PREFIX + sessionId, JSON.stringify([...expanded]))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function DirectoryTree(props: {
|
||||
api: ApiClient | null
|
||||
sessionId: string
|
||||
rootLabel: string
|
||||
onOpenFile: (path: string) => void
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(['']))
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => readExpanded(props.sessionId))
|
||||
|
||||
useEffect(() => {
|
||||
writeExpanded(props.sessionId, expanded)
|
||||
}, [props.sessionId, expanded])
|
||||
|
||||
const handleToggle = useCallback((path: string) => {
|
||||
setExpanded((prev) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams, useSearch } from '@tanstack/react-router'
|
||||
import type { FileSearchItem, GitFileStatus } from '@/types/api'
|
||||
import { FileIcon } from '@/components/FileIcon'
|
||||
@@ -236,6 +236,8 @@ function FileListSkeleton(props: { label: string; rows?: number }) {
|
||||
)
|
||||
}
|
||||
|
||||
const SCROLL_KEY_PREFIX = 'hapi-dir-scroll-'
|
||||
|
||||
export default function FilesPage() {
|
||||
const { api } = useAppContext()
|
||||
const { t } = useTranslation()
|
||||
@@ -246,10 +248,31 @@ export default function FilesPage() {
|
||||
const search = useSearch({ from: '/sessions/$sessionId/files' })
|
||||
const { session } = useSession(api, sessionId)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const initialTab = search.tab === 'directories' ? 'directories' : 'changes'
|
||||
const [activeTab, setActiveTab] = useState<'changes' | 'directories'>(initialTab)
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
const key = SCROLL_KEY_PREFIX + sessionId
|
||||
try {
|
||||
const saved = sessionStorage.getItem(key)
|
||||
if (saved !== null) el.scrollTop = Number(saved)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return () => {
|
||||
try {
|
||||
sessionStorage.setItem(key, String(el.scrollTop))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId])
|
||||
|
||||
const {
|
||||
status: gitStatus,
|
||||
error: gitError,
|
||||
@@ -411,7 +434,7 @@ export default function FilesPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="app-scroll-y flex-1 min-h-0">
|
||||
<div ref={scrollRef} className="app-scroll-y flex-1 min-h-0">
|
||||
<div className="mx-auto w-full max-w-content">
|
||||
{showGitErrorBanner && activeTab === 'changes' ? (
|
||||
<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() {
|
||||
)
|
||||
) : activeTab === 'directories' ? (
|
||||
<DirectoryTree
|
||||
key={sessionId}
|
||||
api={api}
|
||||
sessionId={sessionId}
|
||||
rootLabel={rootLabel}
|
||||
|
||||
Reference in New Issue
Block a user