From ca16fb89f64f7e0476b3673f07051186be7ed876 Mon Sep 17 00:00:00 2001 From: kipavy Date: Mon, 19 Oct 2026 08:16:38 +0000 Subject: [PATCH 1/2] feat(hosts): return to the open folder after the last terminal closes Closing the last terminal remounts the Hosts page, which reset its folder path to the root. Folder navigation now remembers the open folder per object type for the app session, so Hosts, Snippets, Keychain and Port Forwarding (desktop and mobile) reopen where the user left them. Only the open folder id is kept; the breadcrumb is derived from its ancestors, so a folder renamed or moved meanwhile restores under its current name and parent, and a deleted or out-of-scope one falls back to the root. Mobile back handling uses a shared navigateUp. Closes #654 --- src/components/hosts/HostsPage.tsx | 2 +- src/components/keychain/KeychainPage.tsx | 2 +- src/components/mobile/MobileSnippetList.tsx | 2 +- .../mobile/folders/useMobileFolderScope.ts | 2 +- .../mobile/screens/MobileHostsScreen.tsx | 2 +- .../mobile/screens/MobileKeychainScreen.tsx | 2 +- .../screens/MobilePortForwardingScreen.tsx | 2 +- .../port_forwarding/PortForwardingPage.tsx | 2 +- src/components/snippets/SnippetsPage.tsx | 2 +- src/hooks/useFolderNavigation.test.ts | 72 +++++++++++++++++++ src/hooks/useFolderNavigation.ts | 50 ++++++++----- src/utils/folderTree.ts | 2 +- 12 files changed, 115 insertions(+), 27 deletions(-) diff --git a/src/components/hosts/HostsPage.tsx b/src/components/hosts/HostsPage.tsx index 303a09891..88e907eb5 100644 --- a/src/components/hosts/HostsPage.tsx +++ b/src/components/hosts/HostsPage.tsx @@ -213,7 +213,7 @@ export default function HostsPage() { navigateTo, navigateToRoot, onFolderDeleted, - } = useFolderNavigation(scopedFolders); + } = useFolderNavigation(scopedFolders, "connection"); // When inside a folder, show only that folder's items; otherwise show unfoldered items const filtered = useMemo(() => { diff --git a/src/components/keychain/KeychainPage.tsx b/src/components/keychain/KeychainPage.tsx index cb9bcd06d..fa55391a7 100644 --- a/src/components/keychain/KeychainPage.tsx +++ b/src/components/keychain/KeychainPage.tsx @@ -140,7 +140,7 @@ export default function KeychainPage() { navigateTo, navigateToRoot, onFolderDeleted, - } = useFolderNavigation(scopedFolders); + } = useFolderNavigation(scopedFolders, "keychain"); const availableTags = useMemo( () => [...new Set([...keys.flatMap((k) => k.tags), ...identities.flatMap((i) => i.tags)])].sort(), diff --git a/src/components/mobile/MobileSnippetList.tsx b/src/components/mobile/MobileSnippetList.tsx index d7b051616..4e3ef50b2 100644 --- a/src/components/mobile/MobileSnippetList.tsx +++ b/src/components/mobile/MobileSnippetList.tsx @@ -73,7 +73,7 @@ export default function MobileSnippetList({ return (
- {foldersEnabled && nav.folderPath.map((f) => nav.setFolderPath((p) => p.slice(0, -1))} />)} + {foldersEnabled && nav.folderPath.map((f) => )}
diff --git a/src/components/mobile/folders/useMobileFolderScope.ts b/src/components/mobile/folders/useMobileFolderScope.ts index e0e985ca1..be4b740c2 100644 --- a/src/components/mobile/folders/useMobileFolderScope.ts +++ b/src/components/mobile/folders/useMobileFolderScope.ts @@ -12,7 +12,7 @@ export function useMobileFolderScope(allFolders: Folder[], objectType: Folder["o () => allFolders.filter((f) => f.object_type === objectType && inScope(f)), [allFolders, objectType, inScope], ); - const nav = useFolderNavigation(folders); + const nav = useFolderNavigation(folders, objectType); const folderIds = useMemo(() => new Set(folders.map((f) => f.id)), [folders]); const targetVaultId = nav.folderPath[nav.folderPath.length - 1]?.vault_id ?? createVaultId; diff --git a/src/components/mobile/screens/MobileHostsScreen.tsx b/src/components/mobile/screens/MobileHostsScreen.tsx index 41ae3a2d0..e132882d8 100644 --- a/src/components/mobile/screens/MobileHostsScreen.tsx +++ b/src/components/mobile/screens/MobileHostsScreen.tsx @@ -156,7 +156,7 @@ export default function MobileHostsScreen() { return (
- {nav.folderPath.map((f) => nav.setFolderPath((p) => p.slice(0, -1))} />)} + {nav.folderPath.map((f) => )} setAddMode("menu") : undefined} />
- {nav.folderPath.map((f) => nav.setFolderPath((p) => p.slice(0, -1))} />)} + {nav.folderPath.map((f) => )} - {nav.folderPath.map((f) => nav.setFolderPath((p) => p.slice(0, -1))} />)} + {nav.folderPath.map((f) => )} { void loadSnippets(); diff --git a/src/hooks/useFolderNavigation.test.ts b/src/hooks/useFolderNavigation.test.ts index 85160ec3c..a3ec0b348 100644 --- a/src/hooks/useFolderNavigation.test.ts +++ b/src/hooks/useFolderNavigation.test.ts @@ -24,3 +24,75 @@ test("a path into folders that left the list falls back to the root", () => { rerender({ folders: vaultA }); expect(result.current.activeFolderId).toBeNull(); }); + +const nested = [ + { id: "servers", parent_folder_id: null, name: "Servers" }, + { id: "prod", parent_folder_id: "servers", name: "Production" }, +]; + +test("a remembered path is restored when the page mounts again", () => { + const first = renderHook(() => useFolderNavigation(nested, "remount")); + act(() => first.result.current.navigateInto(nested[0])); + act(() => first.result.current.navigateInto(nested[1])); + first.unmount(); + + const second = renderHook(() => useFolderNavigation(nested, "remount")); + expect(second.result.current.folderPath.map((f) => f.id)).toEqual(["servers", "prod"]); + expect(second.result.current.activeFolderId).toBe("prod"); +}); + +test("a remembered path into a folder deleted meanwhile falls back to the root", () => { + const first = renderHook(() => useFolderNavigation(nested, "deleted")); + act(() => first.result.current.navigateInto(nested[0])); + act(() => first.result.current.navigateInto(nested[1])); + first.unmount(); + + const second = renderHook(() => useFolderNavigation([nested[0]], "deleted")); + expect(second.result.current.folderPath).toEqual([]); + second.unmount(); + + const third = renderHook(() => useFolderNavigation(nested, "deleted")); + expect(third.result.current.activeFolderId).toBeNull(); +}); + +test("paths are remembered per key and not without one", () => { + const hosts = renderHook(() => useFolderNavigation(nested, "per-key")); + act(() => hosts.result.current.navigateInto(nested[0])); + hosts.unmount(); + + expect(renderHook(() => useFolderNavigation(nested, "other-key")).result.current.folderPath).toEqual([]); + expect(renderHook(() => useFolderNavigation(nested)).result.current.folderPath).toEqual([]); +}); + +test("the breadcrumb shows a folder's current name after a rename", () => { + const { result, rerender } = renderHook(({ folders }) => useFolderNavigation(folders), { initialProps: { folders: nested } }); + act(() => result.current.navigateInto(nested[0])); + rerender({ folders: [{ ...nested[0], name: "Boxes" }, nested[1]] }); + expect(result.current.folderPath[0].name).toBe("Boxes"); +}); + +test("navigateUp leaves one level", () => { + const { result } = renderHook(() => useFolderNavigation(nested)); + act(() => result.current.navigateInto(nested[0])); + act(() => result.current.navigateInto(nested[1])); + act(() => result.current.navigateUp()); + expect(result.current.activeFolderId).toBe("servers"); +}); + +test("a remembered folder moved elsewhere meanwhile restores under its new parent", () => { + const first = renderHook(() => useFolderNavigation(nested, "moved")); + act(() => first.result.current.navigateInto(nested[0])); + act(() => first.result.current.navigateInto(nested[1])); + first.unmount(); + + const moved = [{ id: "staging", parent_folder_id: null, name: "Staging" }, nested[0], { ...nested[1], parent_folder_id: "staging" }]; + const second = renderHook(() => useFolderNavigation(moved, "moved")); + expect(second.result.current.folderPath.map((f) => f.id)).toEqual(["staging", "prod"]); +}); + +test("a parent cycle does not loop", () => { + const cyclic = [{ id: "a", parent_folder_id: "b" }, { id: "b", parent_folder_id: "a" }]; + const { result } = renderHook(() => useFolderNavigation(cyclic)); + act(() => result.current.navigateInto(cyclic[0])); + expect(result.current.folderPath.map((f) => f.id)).toEqual(["b", "a"]); +}); diff --git a/src/hooks/useFolderNavigation.ts b/src/hooks/useFolderNavigation.ts index c7906dd14..7141ff4a7 100644 --- a/src/hooks/useFolderNavigation.ts +++ b/src/hooks/useFolderNavigation.ts @@ -6,42 +6,58 @@ export interface FolderNavigable { parent_folder_id?: string | null; } +const rememberedFolders = new Map(); + /** * Generic folder navigation hook — tracks breadcrumb path and derives - * visible folders at the current level. + * visible folders at the current level. With `rememberAs`, the open folder survives the page unmounting. */ -export function useFolderNavigation(allFolders: T[]) { - const [storedPath, setFolderPath] = useState([]); - const known = useMemo(() => new Set(allFolders.map((f) => f.id)), [allFolders]); - // A path into folders that left the list (vault switched, folder deleted elsewhere) falls back to the root. - const stale = storedPath.some((f) => !known.has(f.id)); +export function useFolderNavigation(allFolders: T[], rememberAs?: string) { + const [openId, setOpenId] = useState(() => (rememberAs && rememberedFolders.get(rememberAs)) || null); + const byId = useMemo(() => new Map(allFolders.map((f) => [f.id, f])), [allFolders]); + // A folder that left the list (vault switched, folder deleted elsewhere) falls back to the root. + const stale = openId !== null && !byId.has(openId); useEffect(() => { - if (stale) setFolderPath([]); + if (stale) setOpenId(null); }, [stale]); - const folderPath = stale ? [] : storedPath; - const activeFolderId = folderPath.length > 0 ? folderPath[folderPath.length - 1].id : null; + useEffect(() => { + if (rememberAs) rememberedFolders.set(rememberAs, openId); + }, [rememberAs, openId]); + const activeFolderId = stale ? null : openId; + + const folderPath = useMemo(() => { + const path: T[] = []; + for (let id = activeFolderId; id !== null && !path.some((f) => f.id === id);) { + const folder = byId.get(id)!; + path.unshift(folder); + id = rootedParentId(folder.parent_folder_id, byId); + } + return path; + }, [activeFolderId, byId]); const ejectTargetFolderId = folderPath.length > 1 ? folderPath[folderPath.length - 2].id : null; const visibleFolders = useMemo( - () => allFolders.filter((f) => rootedParentId(f.parent_folder_id, known) === activeFolderId), - [allFolders, known, activeFolderId], + () => allFolders.filter((f) => rootedParentId(f.parent_folder_id, byId) === activeFolderId), + [allFolders, byId, activeFolderId], ); - const navigateInto = (folder: T) => setFolderPath((p) => [...p, folder]); - const navigateTo = (index: number) => setFolderPath((p) => p.slice(0, index + 1)); - const navigateToRoot = () => setFolderPath([]); - const onFolderDeleted = (id: string) => - setFolderPath((p) => (p.some((f) => f.id === id) ? [] : p)); + const navigateInto = (folder: T) => setOpenId(folder.id); + const navigateTo = (index: number) => setOpenId(folderPath[index]?.id ?? null); + const navigateToRoot = () => setOpenId(null); + const navigateUp = () => setOpenId(ejectTargetFolderId); + const onFolderDeleted = (id: string) => { + if (folderPath.some((f) => f.id === id)) setOpenId(null); + }; return { folderPath, - setFolderPath, activeFolderId, ejectTargetFolderId, visibleFolders, navigateInto, navigateTo, navigateToRoot, + navigateUp, onFolderDeleted, }; } diff --git a/src/utils/folderTree.ts b/src/utils/folderTree.ts index 20819428e..2c9419e58 100644 --- a/src/utils/folderTree.ts +++ b/src/utils/folderTree.ts @@ -95,6 +95,6 @@ export function foldersOutsideSubtree(folders: Folder[], rootId: string): Folder return folders.filter((f) => !ids.has(f.id)); } -export function rootedParentId(parentId: string | null | undefined, knownIds: ReadonlySet): string | null { +export function rootedParentId(parentId: string | null | undefined, knownIds: { has(id: string): boolean }): string | null { return parentId && knownIds.has(parentId) ? parentId : null; } From c0608db8eef00c7b213613c98b0c3a792e06fad4 Mon Sep 17 00:00:00 2001 From: kipavy Date: Mon, 19 Oct 2026 08:20:51 +0000 Subject: [PATCH 2/2] feat(layout): return to the previous page when the last terminal closes Closing the last terminal always switched to Hosts, and closing the last tab from another page (Snippets, Keychain, ...) pulled the user to Hosts too. The UI store now remembers the last page shown before the terminal view, and leaveTerminal() goes back to it only when the terminal view is showing. Hosts remains the fallback. Closing a terminal while others remain still activates another session, unchanged. --- src/components/layout/TitleBar.tsx | 11 +++---- src/services/closeSession.ts | 2 +- src/stores/uiStore.leaveTerminal.test.ts | 37 ++++++++++++++++++++++++ src/stores/uiStore.ts | 14 +++++++-- 4 files changed, 53 insertions(+), 11 deletions(-) create mode 100644 src/stores/uiStore.leaveTerminal.test.ts diff --git a/src/components/layout/TitleBar.tsx b/src/components/layout/TitleBar.tsx index a10589e12..ccd90f8a6 100644 --- a/src/components/layout/TitleBar.tsx +++ b/src/components/layout/TitleBar.tsx @@ -56,6 +56,7 @@ function splitTitle(t: TFunction, label: string, more: number): string { export default function TitleBar() { const { t } = useTranslation(); const setActiveNav = useUIStore((s) => s.setActiveNav); + const leaveTerminal = useUIStore((s) => s.leaveTerminal); const activeNav = useUIStore((s) => s.activeNav); const rightPanelOpen = useUIStore((s) => s.rightPanelOpen); const setRightPanelOpen = useUIStore((s) => s.setRightPanelOpen); @@ -165,13 +166,9 @@ export default function TitleBar() { syncTitlebarOrder(visibleItemKeys, grouped ? titlebarKeyGroupOf : undefined); }, [syncTitlebarOrder, visibleItemKeys.join("|"), grouped]); - // Ensure the user never gets stuck on an empty terminal view. - // When all sessions are gone, fall back to Vaults. useEffect(() => { - if (sessions.length === 0 && activeNav === "terminal") { - setActiveNav("hosts"); - } - }, [sessions.length, activeNav, setActiveNav]); + if (sessions.length === 0) leaveTerminal(); + }, [sessions.length, activeNav, leaveTerminal]); const handleTabClick = (sessionId: string) => { if (shouldSuppressDragClick()) return; @@ -202,7 +199,7 @@ export default function TitleBar() { const ids = tab ? getPaneSessionIds(tab.root) : []; closeSplitTab(tabId); ids.forEach(closeSession); - if (sessions.length <= ids.length) setActiveNav("hosts"); + if (sessions.length <= ids.length) leaveTerminal(); }; const handleUnifiedTabClose = (e: React.MouseEvent, tabId: string) => { diff --git a/src/services/closeSession.ts b/src/services/closeSession.ts index 1130da80f..f6765a203 100644 --- a/src/services/closeSession.ts +++ b/src/services/closeSession.ts @@ -23,5 +23,5 @@ export function closeSessionTabs(sessionIds: string[]): void { closeSession(id); useLayoutStore.getState().removeSession(id); } - if (useSessionStore.getState().sessions.length === 0) useUIStore.getState().setActiveNav("hosts"); + if (useSessionStore.getState().sessions.length === 0) useUIStore.getState().leaveTerminal(); } diff --git a/src/stores/uiStore.leaveTerminal.test.ts b/src/stores/uiStore.leaveTerminal.test.ts new file mode 100644 index 000000000..55d8497e2 --- /dev/null +++ b/src/stores/uiStore.leaveTerminal.test.ts @@ -0,0 +1,37 @@ +// @vitest-environment jsdom +import { test, expect, beforeEach } from "vitest"; +import { useUIStore } from "@/stores/uiStore"; + +beforeEach(() => { + useUIStore.setState({ activeNav: "hosts", lastPageNav: "hosts" }); +}); + +test("leaving the terminal returns to the page shown before it", () => { + const ui = useUIStore.getState(); + ui.setActiveNav("snippets"); + ui.setActiveNav("terminal"); + ui.leaveTerminal(); + expect(useUIStore.getState().activeNav).toBe("snippets"); +}); + +test("leaving the terminal from another page stays on that page", () => { + const ui = useUIStore.getState(); + ui.setActiveNav("terminal"); + ui.setActiveNav("keychain"); + ui.leaveTerminal(); + expect(useUIStore.getState().activeNav).toBe("keychain"); +}); + +test("the members shortcuts count as the page shown before the terminal", () => { + const ui = useUIStore.getState(); + ui.openMembersNav(); + ui.setActiveNav("terminal"); + ui.leaveTerminal(); + expect(useUIStore.getState().activeNav).toBe("members"); +}); + +test("hosts is the fallback when no page was shown yet", () => { + useUIStore.setState({ activeNav: "terminal" }); + useUIStore.getState().leaveTerminal(); + expect(useUIStore.getState().activeNav).toBe("hosts"); +}); diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts index 4f41c1bd2..8c2722f62 100644 --- a/src/stores/uiStore.ts +++ b/src/stores/uiStore.ts @@ -3,6 +3,9 @@ import { persist } from "zustand/middleware"; import { pushSettingsChange, settingsStamp } from "./remoteApplyGuard"; export type NavItem = "hosts" | "keychain" | "port-forwarding" | "snippets" | "known-hosts" | "members" | "logs" | "terminal"; +export type PageNavItem = Exclude; + +const navTo = (nav: NavItem) => (nav === "terminal" ? { activeNav: nav } : { activeNav: nav, lastPageNav: nav }); export type BuiltinRightPanelSection = "snippets" | "history" | "notes" | "themes" | "ports" | "sftp"; /** Widened to allow plugin-contributed section IDs (prefixed with "plugin:") */ @@ -86,6 +89,7 @@ interface UIStore { sidebarOpen: boolean; homeView: boolean; activeNav: NavItem; + lastPageNav: PageNavItem; omniOpen: boolean; globalPanelOpen: Record; setGlobalPanelOpen: (id: string, open: boolean) => void; @@ -145,6 +149,8 @@ interface UIStore { setSidebarOpen: (open: boolean) => void; setHomeView: (v: boolean) => void; setActiveNav: (nav: NavItem) => void; + /** From the terminal view, go back to the page shown before it. */ + leaveTerminal: () => void; setOmniOpen: (open: boolean) => void; setSettingsOpen: (open: boolean) => void; setNotificationCenterOpen: (open: boolean) => void; @@ -216,6 +222,7 @@ export const useUIStore = create()( sidebarOpen: true, homeView: true, activeNav: "hosts" as NavItem, + lastPageNav: "hosts" as PageNavItem, omniOpen: false, globalPanelOpen: {}, dockedPanelWidth: 0, @@ -269,7 +276,8 @@ export const useUIStore = create()( toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })), setSidebarOpen: (open) => set({ sidebarOpen: open }), setHomeView: (v) => set({ homeView: v }), - setActiveNav: (nav) => set({ activeNav: nav }), + setActiveNav: (nav) => set(navTo(nav)), + leaveTerminal: () => set((s) => (s.activeNav === "terminal" ? { activeNav: s.lastPageNav } : {})), setOmniOpen: (open) => set({ omniOpen: open }), setGlobalPanelOpen: (id, open) => set((s) => ({ globalPanelOpen: { ...s.globalPanelOpen, [id]: open } })), toggleGlobalPanel: (id) => set((s) => ({ globalPanelOpen: { ...s.globalPanelOpen, [id]: !s.globalPanelOpen[id] } })), @@ -318,9 +326,9 @@ export const useUIStore = create()( setSnippetsLayoutMode: (v) => setPref({ snippetsLayoutMode: v }), setKnownHostsLayoutMode: (v) => setPref({ knownHostsLayoutMode: v }), setSnippetsPendingAction: (action) => set({ snippetsPendingAction: action }), - openMembersPanel: (panel) => set({ activeNav: "members", homeView: false, membersPanelPending: panel }), + openMembersPanel: (panel) => set({ ...navTo("members"), homeView: false, membersPanelPending: panel }), clearMembersPanelPending: () => set({ membersPanelPending: null }), - openMembersNav: () => set({ activeNav: "members", homeView: false }), + openMembersNav: () => set({ ...navTo("members"), homeView: false }), openVaultSharePending: () => set({ vaultSharePending: true }), clearVaultSharePending: () => set({ vaultSharePending: false }), openWhatsNew: () => set({ whatsNewOpen: true }),