Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/hosts/HostsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
2 changes: 1 addition & 1 deletion src/components/keychain/KeychainPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
11 changes: 4 additions & 7 deletions src/components/layout/TitleBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) => {
Expand Down
2 changes: 1 addition & 1 deletion src/components/mobile/MobileSnippetList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export default function MobileSnippetList({

return (
<div className="flex-1 flex flex-col overflow-hidden">
{foldersEnabled && nav.folderPath.map((f) => <FolderBackTrap key={f.id} onBack={() => nav.setFolderPath((p) => p.slice(0, -1))} />)}
{foldersEnabled && nav.folderPath.map((f) => <FolderBackTrap key={f.id} onBack={nav.navigateUp} />)}
<div className="shrink-0 px-3 py-2">
<div className="flex items-center gap-2 rounded-xl px-3 h-10" style={{ background: "var(--t-bg-card)", border: "1px solid var(--t-border)" }}>
<Icon icon="lucide:search" width={16} className="text-(--t-text-dim)" />
Expand Down
2 changes: 1 addition & 1 deletion src/components/mobile/folders/useMobileFolderScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion src/components/mobile/screens/MobileHostsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ export default function MobileHostsScreen() {

return (
<div className="flex-1 flex flex-col overflow-hidden">
{nav.folderPath.map((f) => <FolderBackTrap key={f.id} onBack={() => nav.setFolderPath((p) => p.slice(0, -1))} />)}
{nav.folderPath.map((f) => <FolderBackTrap key={f.id} onBack={nav.navigateUp} />)}
<MobileHeader onAdd={canCreateHost || canCreateFolder ? () => setAddMode("menu") : undefined} />
<div className="shrink-0 px-3 py-2">
<div className="flex items-center gap-2 rounded-xl px-3 h-10"
Expand Down
2 changes: 1 addition & 1 deletion src/components/mobile/screens/MobileKeychainScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export default function MobileKeychainScreen() {

return (
<div className="absolute inset-0 z-30 flex flex-col bg-(--t-bg-base)">
{nav.folderPath.map((f) => <FolderBackTrap key={f.id} onBack={() => nav.setFolderPath((p) => p.slice(0, -1))} />)}
{nav.folderPath.map((f) => <FolderBackTrap key={f.id} onBack={nav.navigateUp} />)}
<MobilePanelHeader
title={t("mobile.morePages.keychain")}
right={canCreateKey || canCreateIdentity || canCreateFolder ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export default function MobilePortForwardingScreen() {

return (
<div className="absolute inset-0 z-30 flex flex-col bg-(--t-bg-base)">
{nav.folderPath.map((f) => <FolderBackTrap key={f.id} onBack={() => nav.setFolderPath((p) => p.slice(0, -1))} />)}
{nav.folderPath.map((f) => <FolderBackTrap key={f.id} onBack={nav.navigateUp} />)}
<MobilePanelHeader
title={t("mobile.morePages.portForwarding")}
right={canCreateRule || canCreateFolder ? (
Expand Down
2 changes: 1 addition & 1 deletion src/components/port_forwarding/PortForwardingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export function PortForwardingPage() {
navigateTo,
navigateToRoot,
onFolderDeleted,
} = useFolderNavigation(scopedFolders);
} = useFolderNavigation(scopedFolders, "port_forwarding");

const q = search.trim();
const match = useSearchMatcher(q);
Expand Down
2 changes: 1 addition & 1 deletion src/components/snippets/SnippetsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ export function SnippetsPage() {
navigateTo,
navigateToRoot,
onFolderDeleted,
} = useFolderNavigation(scopedFolders);
} = useFolderNavigation(scopedFolders, "snippet");

useEffect(() => {
void loadSnippets();
Expand Down
72 changes: 72 additions & 0 deletions src/hooks/useFolderNavigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
});
50 changes: 33 additions & 17 deletions src/hooks/useFolderNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,42 +6,58 @@ export interface FolderNavigable {
parent_folder_id?: string | null;
}

const rememberedFolders = new Map<string, string | null>();

/**
* 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<T extends FolderNavigable>(allFolders: T[]) {
const [storedPath, setFolderPath] = useState<T[]>([]);
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<T extends FolderNavigable>(allFolders: T[], rememberAs?: string) {
const [openId, setOpenId] = useState<string | null>(() => (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,
};
}
2 changes: 1 addition & 1 deletion src/services/closeSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
37 changes: 37 additions & 0 deletions src/stores/uiStore.leaveTerminal.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
14 changes: 11 additions & 3 deletions src/stores/uiStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NavItem, "terminal">;

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:") */
Expand Down Expand Up @@ -86,6 +89,7 @@ interface UIStore {
sidebarOpen: boolean;
homeView: boolean;
activeNav: NavItem;
lastPageNav: PageNavItem;
omniOpen: boolean;
globalPanelOpen: Record<string, boolean>;
setGlobalPanelOpen: (id: string, open: boolean) => void;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -216,6 +222,7 @@ export const useUIStore = create<UIStore>()(
sidebarOpen: true,
homeView: true,
activeNav: "hosts" as NavItem,
lastPageNav: "hosts" as PageNavItem,
omniOpen: false,
globalPanelOpen: {},
dockedPanelWidth: 0,
Expand Down Expand Up @@ -269,7 +276,8 @@ export const useUIStore = create<UIStore>()(
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] } })),
Expand Down Expand Up @@ -318,9 +326,9 @@ export const useUIStore = create<UIStore>()(
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 }),
Expand Down
2 changes: 1 addition & 1 deletion src/utils/folderTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>): string | null {
export function rootedParentId(parentId: string | null | undefined, knownIds: { has(id: string): boolean }): string | null {
return parentId && knownIds.has(parentId) ? parentId : null;
}
Loading