From 51447b7371a56af9a158f5a517a93c271784ac3a Mon Sep 17 00:00:00 2001 From: Oz Date: Thu, 9 Jul 2026 15:18:29 +0000 Subject: [PATCH 1/4] Add desktop back and forward navigation Co-Authored-By: Oz --- apps/desktop/electron/main.ts | 19 ++ apps/desktop/electron/preload.ts | 2 + apps/desktop/src/App.tsx | 32 ++- apps/desktop/src/components/Toolbar.tsx | 38 ++++ apps/desktop/src/desktopApi/types.ts | 4 + apps/desktop/src/store/actions.test.ts | 120 +++++++++++ apps/desktop/src/store/actions.ts | 251 ++++++++++++++++++++++-- apps/desktop/src/store/state.ts | 16 ++ packages/ui/src/components/Toolbar.tsx | 36 +++- 9 files changed, 487 insertions(+), 31 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index d404f187..41ad1e06 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -134,6 +134,8 @@ let menuState: MenuState = { hasWorkspace: false, hasMarkdownNoteOpen: false, isSourceMode: false, + canGoBack: false, + canGoForward: false, }; let updateState: DesktopUpdateState = { isSupported: supportsAutoUpdates, @@ -806,6 +808,21 @@ function buildMenu() { { label: "View", submenu: [ + { + id: "go-back", + label: "Go Back", + accelerator: "CmdOrCtrl+[", + enabled: menuState.canGoBack, + click: () => sendToRenderer("desktop:menu-go-back"), + }, + { + id: "go-forward", + label: "Go Forward", + accelerator: "CmdOrCtrl+]", + enabled: menuState.canGoForward, + click: () => sendToRenderer("desktop:menu-go-forward"), + }, + { type: "separator" }, { id: "zoom-in", label: "Zoom In", @@ -1577,6 +1594,8 @@ function registerIpc() { hasWorkspace: state.hasWorkspace === true, hasMarkdownNoteOpen: state.hasMarkdownNoteOpen === true, isSourceMode: state.isSourceMode === true, + canGoBack: state.canGoBack === true, + canGoForward: state.canGoForward === true, }; buildMenu(); }); diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 6e9d79a4..29df66c9 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -109,6 +109,8 @@ const desktopApi = { subscribe("desktop:menu-sync-workspace", callback), onMenuToggleTerminal: (callback) => subscribe("desktop:menu-toggle-terminal", callback), + onMenuGoBack: (callback) => subscribe("desktop:menu-go-back", callback), + onMenuGoForward: (callback) => subscribe("desktop:menu-go-forward", callback), onMenuToggleSourceMode: (callback) => subscribe("desktop:menu-toggle-source-mode", callback), onWindowFocus: (callback) => subscribe("desktop:window-focus", callback), diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 029dfed0..7740256b 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -39,9 +39,13 @@ import { resolveRelativeLinkPath } from "./lib/relativeLinkPath"; import { resolveWikiPath } from "./lib/wikiPath"; import { SIDEBAR_NAV_SELECTOR } from "./selectors"; import { + canGoBack, + canGoForward, createWorkspaceWithSidebar, forceKeepLocalEdits, getPendingRenameTarget, + goBack, + goForward, handleExternalFileChange, loadPath, openWorkspace, @@ -60,6 +64,7 @@ import { } from "./store/actions"; import { chatCommandStore, + navigationHistoryStore, sidebarOpenStore, terminalPositionStore, uiStore, @@ -100,8 +105,13 @@ function App() { const state = useStoreValue(viewerStore); const workspacePath = useStoreValue(workspacePathStore); const sidebarOpen = useStoreValue(sidebarOpenStore); + const navigationHistory = useStoreValue(navigationHistoryStore); const terminalPosition = useStoreValue(terminalPositionStore); const hasWorkspace = workspacePath !== null; + const menuCanGoBack = navigationHistory.isNavigating ? false : canGoBack(); + const menuCanGoForward = navigationHistory.isNavigating + ? false + : canGoForward(); const [scrollContainerEl, setScrollContainerEl] = useState(null); const [settingsOpen, setSettingsOpen] = useState(false); @@ -201,8 +211,16 @@ function App() { hasMarkdownNoteOpen: typeof currentPath === "string" && hasMarkdownExtension(currentPath), isSourceMode: state.viewMode === "source", + canGoBack: menuCanGoBack, + canGoForward: menuCanGoForward, }); - }, [hasWorkspace, state.currentPath, state.viewMode]); + }, [ + hasWorkspace, + menuCanGoBack, + menuCanGoForward, + state.currentPath, + state.viewMode, + ]); useEffect(() => { if (!sidebarOpen) setFocusedSidebarPath(null); @@ -210,7 +228,15 @@ function App() { useEffect(() => { const onKeyDown = async (event: KeyboardEvent) => { - if (keymatch(event, "CmdOrCtrl+N")) { + if (keymatch(event, "CmdOrCtrl+[")) { + if (!canGoBack()) return; + event.preventDefault(); + await goBack(); + } else if (keymatch(event, "CmdOrCtrl+]")) { + if (!canGoForward()) return; + event.preventDefault(); + await goForward(); + } else if (keymatch(event, "CmdOrCtrl+N")) { event.preventDefault(); await createMarkdownFile(); } else if (keymatch(event, "CmdOrCtrl+,")) { @@ -295,6 +321,8 @@ function App() { ), desktopApi.onMenuSyncWorkspace(() => void refreshFiles()), desktopApi.onMenuToggleTerminal(() => toggleTerminal()), + desktopApi.onMenuGoBack(() => void goBack()), + desktopApi.onMenuGoForward(() => void goForward()), desktopApi.onMenuToggleSourceMode(() => { const current = viewerStore.get(); if ( diff --git a/apps/desktop/src/components/Toolbar.tsx b/apps/desktop/src/components/Toolbar.tsx index 406dff0d..2e7c220d 100644 --- a/apps/desktop/src/components/Toolbar.tsx +++ b/apps/desktop/src/components/Toolbar.tsx @@ -7,6 +7,8 @@ import { import { useStoreValue } from "@simplestack/store/react"; import { type CSSProperties, useEffect, useState } from "react"; import { toast } from "sonner"; +import MingcuteArrowLeftLine from "~icons/mingcute/arrow-left-line"; +import MingcuteArrowRightLine from "~icons/mingcute/arrow-right-line"; import MingcuteCodeLine from "~icons/mingcute/code-line"; import MingcuteCopy2Line from "~icons/mingcute/copy-2-line"; import MingcuteFolderOpenLine from "~icons/mingcute/folder-open-line"; @@ -17,6 +19,10 @@ import { copyText } from "../lib/clipboard"; import { hasMarkdownExtension } from "../lib/filePath"; import { revealFileLabel } from "../lib/revealFile"; import { + canGoBack, + canGoForward, + goBack, + goForward, renameCurrentMarkdownFile, requestChatAboutNote, setViewerMode, @@ -25,6 +31,7 @@ import { } from "../store/actions"; import { currentPathStore, + navigationHistoryStore, sidebarOpenStore, viewerStore, workspacePathStore, @@ -54,6 +61,7 @@ export function Toolbar({ const workspacePath = useStoreValue(workspacePathStore); const sidebarOpen = useStoreValue(sidebarOpenStore); const currentPath = useStoreValue(currentPathStore); + useStoreValue(navigationHistoryStore); const isFullScreen = useIsFullScreen(); return ( @@ -65,6 +73,7 @@ export function Toolbar({ platformInset={!isFullScreen} rootProps={{ style: dragRegionStyle }} onToggleSidebar={toggleSidebar} + leftSlot={} onRenameCurrentPath={(nextName) => void renameCurrentMarkdownFile(nextName) } @@ -91,6 +100,35 @@ export function Toolbar({ ); } +function NavigationControls() { + const backLabel = `Go Back (${formatShortcut("CmdOrCtrl+[")})`; + const forwardLabel = `Go Forward (${formatShortcut("CmdOrCtrl+]")})`; + return ( + <> + + + + ); +} + function NoteActionsMenu({ path, canChatAboutNote, diff --git a/apps/desktop/src/desktopApi/types.ts b/apps/desktop/src/desktopApi/types.ts index 24812f54..22ce1b90 100644 --- a/apps/desktop/src/desktopApi/types.ts +++ b/apps/desktop/src/desktopApi/types.ts @@ -42,6 +42,8 @@ export type MenuState = { hasWorkspace: boolean; hasMarkdownNoteOpen: boolean; isSourceMode: boolean; + canGoBack: boolean; + canGoForward: boolean; }; export type DesktopUpdateStatus = @@ -136,6 +138,8 @@ export type DesktopApi = { onMenuShowWorkspaceSwitcher(callback: () => void): Unsubscribe; onMenuSyncWorkspace(callback: () => void): Unsubscribe; onMenuToggleTerminal(callback: () => void): Unsubscribe; + onMenuGoBack(callback: () => void): Unsubscribe; + onMenuGoForward(callback: () => void): Unsubscribe; onMenuToggleSourceMode(callback: () => void): Unsubscribe; onWindowFocus(callback: () => void): Unsubscribe; onFullScreenChange(callback: (isFullScreen: boolean) => void): Unsubscribe; diff --git a/apps/desktop/src/store/actions.test.ts b/apps/desktop/src/store/actions.test.ts index 7ac9ffd9..39fc1963 100644 --- a/apps/desktop/src/store/actions.test.ts +++ b/apps/desktop/src/store/actions.test.ts @@ -986,6 +986,126 @@ describe("desktop loadPath", () => { vi.unstubAllGlobals(); }); + it("tracks back and forward history through successful opens", async () => { + const api = createDesktopApi(); + api.pathExists.mockResolvedValue(true); + api.readFileText.mockImplementation( + async (path: string) => `content:${path}`, + ); + const { + canGoBack, + canGoForward, + goBack, + goForward, + loadPath, + viewerStore, + } = await loadStoreActions(api); + + await loadPath("/workspace/a.md"); + await loadPath("/workspace/b.md"); + await loadPath("/workspace/c.md"); + + expect(canGoBack()).toBe(true); + expect(canGoForward()).toBe(false); + + await goBack(); + + expect(viewerStore.get().currentPath).toBe("/workspace/b.md"); + expect(viewerStore.get().content).toBe("content:/workspace/b.md"); + expect(canGoBack()).toBe(true); + expect(canGoForward()).toBe(true); + + await goForward(); + + expect(viewerStore.get().currentPath).toBe("/workspace/c.md"); + expect(canGoForward()).toBe(false); + }); + + it("truncates forward history when a new file opens after going back", async () => { + const api = createDesktopApi(); + api.pathExists.mockResolvedValue(true); + api.readFileText.mockImplementation( + async (path: string) => `content:${path}`, + ); + const { canGoForward, goBack, loadPath, viewerStore } = + await loadStoreActions(api); + + await loadPath("/workspace/a.md"); + await loadPath("/workspace/b.md"); + await loadPath("/workspace/c.md"); + await goBack(); + await loadPath("/workspace/d.md"); + + expect(viewerStore.get().currentPath).toBe("/workspace/d.md"); + expect(canGoForward()).toBe(false); + }); + + it("keeps navigation history separate per workspace", async () => { + const api = createDesktopApi(); + api.pathExists.mockResolvedValue(true); + api.readFileText.mockImplementation( + async (path: string) => `content:${path}`, + ); + const { appStore, canGoBack, loadPath } = await loadStoreActions(api); + + appStore.set((current) => ({ + ...current, + workspace: { ...current.workspace, workspacePath: "/workspace-a" }, + })); + await loadPath("/workspace-a/a.md"); + await loadPath("/workspace-a/b.md"); + expect(canGoBack()).toBe(true); + + appStore.set((current) => ({ + ...current, + workspace: { ...current.workspace, workspacePath: "/workspace-b" }, + })); + + expect(canGoBack()).toBe(false); + }); + + it("saves dirty content before navigating history", async () => { + const api = createDesktopApi(); + api.pathExists.mockResolvedValue(true); + api.readFileText.mockImplementation( + async (path: string) => `content:${path}`, + ); + const { goBack, loadPath, updateEditorContent } = + await loadStoreActions(api); + + await loadPath("/workspace/a.md"); + await loadPath("/workspace/b.md"); + updateEditorContent("/workspace/b.md", "dirty"); + + await goBack(); + + expect(api.writeFileText).toHaveBeenCalledWith("/workspace/b.md", "dirty"); + }); + + it("blocks history navigation while the current file has a disk conflict", async () => { + const api = createDesktopApi(); + api.pathExists.mockResolvedValue(true); + api.readFileText.mockImplementation( + async (path: string) => `content:${path}`, + ); + const { appStore, goBack, loadPath, viewerStore } = + await loadStoreActions(api); + + await loadPath("/workspace/a.md"); + await loadPath("/workspace/b.md"); + appStore.set((current) => ({ + ...current, + document: { + ...current.document, + externalChange: { kind: "conflict", diskContent: "disk" }, + }, + })); + + await goBack(); + + expect(viewerStore.get().currentPath).toBe("/workspace/b.md"); + }); + it("refreshes the sidebar when a selected file no longer exists", async () => { const api = createDesktopApi(); const missingPath = "/workspace/missing.md"; diff --git a/apps/desktop/src/store/actions.ts b/apps/desktop/src/store/actions.ts index c1f48134..7064110d 100644 --- a/apps/desktop/src/store/actions.ts +++ b/apps/desktop/src/store/actions.ts @@ -36,7 +36,10 @@ import { getBaseline, isInWorkspace, LOADING_DELAY_MS, + MAX_NAVIGATION_HISTORY, MAX_RECENT, + type NavigationHistoryStack, + navigationHistoryStore, pendingTerminalCommandStore, type SortMode, sidebarOpenStore, @@ -344,6 +347,119 @@ function uniqueFolderPath(parent: string): string { } const pendingRenames = new Map(); +const NO_WORKSPACE_HISTORY_KEY = "__hubble_no_workspace__"; + +type LoadPathOptions = { + history?: "push" | "none"; + missing?: "toast" | "silent"; +}; + +function currentHistoryKey() { + return workspaceStore.get().workspacePath ?? NO_WORKSPACE_HISTORY_KEY; +} + +function normalizeHistoryStack(stack?: NavigationHistoryStack) { + if (!stack || stack.entries.length === 0) { + return { entries: [], index: -1 }; + } + return { + entries: stack.entries, + index: Math.min(Math.max(stack.index, 0), stack.entries.length - 1), + }; +} + +function activeHistoryStack() { + return normalizeHistoryStack( + navigationHistoryStore.get().byWorkspace[currentHistoryKey()], + ); +} + +function setActiveHistoryStack(stack: NavigationHistoryStack) { + const key = currentHistoryKey(); + navigationHistoryStore.set((state) => ({ + ...state, + byWorkspace: { + ...state.byWorkspace, + [key]: stack, + }, + })); +} + +function pushHistoryPath(path: string) { + const stack = activeHistoryStack(); + if (stack.entries[stack.index] === path) return; + const entries = [...stack.entries.slice(0, stack.index + 1), path]; + const trimmed = entries.slice(-MAX_NAVIGATION_HISTORY); + setActiveHistoryStack({ + entries: trimmed, + index: trimmed.length - 1, + }); +} + +function clearActiveHistoryStack() { + setActiveHistoryStack({ entries: [], index: -1 }); +} + +function updateHistoryStacks( + updater: (stack: NavigationHistoryStack) => NavigationHistoryStack, +) { + navigationHistoryStore.set((state) => ({ + ...state, + byWorkspace: Object.fromEntries( + Object.entries(state.byWorkspace).map(([key, stack]) => [ + key, + normalizeHistoryStack(updater(normalizeHistoryStack(stack))), + ]), + ), + })); +} + +function rewriteHistoryPath( + fromPath: string, + toPath: string, + isFolder = false, +) { + updateHistoryStacks((stack) => ({ + ...stack, + entries: stack.entries.map((entry) => + isFolder + ? replacePathPrefix(entry, fromPath, toPath) + : entry === fromPath + ? toPath + : entry, + ), + })); +} + +function pruneHistoryPath(path: string, isFolder = false) { + updateHistoryStacks((stack) => { + const currentEntry = stack.entries[stack.index]; + const entries = stack.entries.filter((entry) => + isFolder ? !pathInFolder(entry, path) : entry !== path, + ); + const currentIndex = currentEntry ? entries.indexOf(currentEntry) : -1; + return { + entries, + index: + currentIndex >= 0 + ? currentIndex + : Math.min(stack.index, entries.length - 1), + }; + }); +} + +export function canGoBack() { + const history = navigationHistoryStore.get(); + if (history.isNavigating) return false; + return activeHistoryStack().index > 0; +} + +export function canGoForward() { + const history = navigationHistoryStore.get(); + if (history.isNavigating) return false; + const stack = activeHistoryStack(); + return stack.index >= 0 && stack.index < stack.entries.length - 1; +} export function getPendingRenameTarget(path: string) { return pendingRenames.get(path) ?? null; @@ -443,7 +559,7 @@ export async function openWorkspace(path?: string) { const lastFile = workspaceStore.get().lastOpenedPaths[nextPath]; if (lastFile) { - await loadPath(lastFile); + await loadPath(lastFile, { missing: "silent" }); return; } @@ -595,6 +711,7 @@ export async function renameMarkdownFile(path: string, nextName: string) { const movedFiles = [{ fromPath: path, toPath: nextPath }]; if (movedAssetFolder) movedFiles.push(movedAssetFolder); await updateMovedLinks(movedFiles, filesBeforeRename); + rewriteHistoryPath(path, nextPath); appStore.set((state) => ({ ...state, workspace: { @@ -713,6 +830,7 @@ export async function renameFolder( } await desktopApi.renameFile(path, nextPath); await deleteEmptySourceAncestors(path, nextPath, workspacePath); + rewriteHistoryPath(path, nextPath, true); appStore.set((state) => ({ ...state, workspace: { @@ -823,6 +941,7 @@ export async function moveSidebarItem( item.kind === "file" ? await moveAssociatedAssetFolder(sourcePath, nextPath) : null; + rewriteHistoryPath(sourcePath, nextPath, isFolder); appStore.set((state) => ({ ...state, workspace: { @@ -932,6 +1051,12 @@ export async function deleteMarkdownFile( ) { try { await desktopApi.deleteFile(path); + const wasCurrentFile = viewerStore.get().currentPath === path; + if (wasCurrentFile) { + clearActiveHistoryStack(); + } else { + pruneHistoryPath(path); + } appStore.set((state) => ({ ...state, workspace: { @@ -973,6 +1098,12 @@ export async function deleteMarkdownFile( export async function deleteFolder(path: string) { try { await desktopApi.deleteFile(path, { recursive: true }); + const currentPath = viewerStore.get().currentPath; + if (currentPath && pathInFolder(currentPath, path)) { + clearActiveHistoryStack(); + } else { + pruneHistoryPath(path, true); + } appStore.set((state) => ({ ...state, workspace: { @@ -1053,29 +1184,109 @@ export async function forceKeepLocalEdits() { await savePathContent(current.currentPath, current.content, { force: true }); } -export const loadPath = latest(async ({ isStale }, path: string) => { - const timer = window.setTimeout(() => { - if (isStale()) return; - viewerStore.set((state) => ({ ...state, status: "loading", error: null })); - }, LOADING_DELAY_MS); +export const loadPath = latest( + async ({ isStale }, path: string, options?: LoadPathOptions) => { + const historyMode = options?.history ?? "push"; + const missingMode = options?.missing ?? "toast"; + const timer = window.setTimeout(() => { + if (isStale()) return; + viewerStore.set((state) => ({ + ...state, + status: "loading", + error: null, + })); + }, LOADING_DELAY_MS); + + try { + const content = await desktopApi.readFileText(path); + if (isStale()) return; + appStore.set((state) => withOpenedDoc(state, path, content)); + if (historyMode === "push") pushHistoryPath(path); + } catch (err) { + if (isStale()) return; + const message = handleFileError(err); + if (missingMode === "toast") { + toast.error("Failed to open file", { description: message }); + viewerStore.set((state) => ({ + ...emptyDoc(state.lastOpenedPath), + status: "error", + error: message, + })); + } else { + appStore.set((state) => ({ + ...state, + workspace: { + ...state.workspace, + lastOpenedPaths: Object.fromEntries( + Object.entries(state.workspace.lastOpenedPaths).filter( + ([, openedPath]) => openedPath !== path, + ), + ), + }, + document: emptyDoc( + state.document.lastOpenedPath === path + ? null + : state.document.lastOpenedPath, + ), + })); + } + } finally { + window.clearTimeout(timer); + } + }, +); +async function navigateHistory(delta: -1 | 1) { + if (navigationHistoryStore.get().isNavigating) return; + const stack = activeHistoryStack(); + if (delta < 0 ? stack.index <= 0 : stack.index >= stack.entries.length - 1) { + return; + } + + const current = viewerStore.get(); + if (current.externalChange.kind === "conflict") return; + if (current.currentPath) { + await savePathContent(current.currentPath, current.content); + if (viewerStore.get().externalChange.kind === "conflict") return; + } + + navigationHistoryStore.set((state) => ({ ...state, isNavigating: true })); try { - const content = await desktopApi.readFileText(path); - if (isStale()) return; - appStore.set((state) => withOpenedDoc(state, path, content)); - } catch (err) { - if (isStale()) return; - const message = handleFileError(err); - toast.error("Failed to open file", { description: message }); - viewerStore.set((state) => ({ - ...emptyDoc(state.lastOpenedPath), - status: "error", - error: message, - })); + let nextIndex = stack.index + delta; + while (nextIndex >= 0 && nextIndex < stack.entries.length) { + const target = stack.entries[nextIndex]; + if (await desktopApi.pathExists(target)) { + setActiveHistoryStack({ entries: stack.entries, index: nextIndex }); + await loadPath(target, { history: "none", missing: "silent" }); + return; + } + const nextEntries = activeHistoryStack().entries.filter( + (entry) => entry !== target, + ); + setActiveHistoryStack({ + entries: nextEntries, + index: Math.min( + nextIndex - (delta > 0 ? 1 : 0), + nextEntries.length - 1, + ), + }); + nextIndex += delta; + } + toast.error( + delta < 0 ? "No previous file to open" : "No next file to open", + ); } finally { - window.clearTimeout(timer); + navigationHistoryStore.set((state) => ({ ...state, isNavigating: false })); } -}); +} + +export function goBack() { + return navigateHistory(-1); +} + +export function goForward() { + return navigateHistory(1); +} export async function togglePinnedNote(path: string) { const workspacePath = workspaceStore.get().workspacePath; diff --git a/apps/desktop/src/store/state.ts b/apps/desktop/src/store/state.ts index fb01dced..6c5f368c 100644 --- a/apps/desktop/src/store/state.ts +++ b/apps/desktop/src/store/state.ts @@ -38,6 +38,17 @@ const NO_CONFLICT: ExternalChange = { kind: "none" }; export const MAX_RECENT = 10; export const LOADING_DELAY_MS = 150; +export const MAX_NAVIGATION_HISTORY = 50; + +export type NavigationHistoryStack = { + entries: string[]; + index: number; +}; + +export type NavigationHistoryState = { + byWorkspace: Record; + isNavigating: boolean; +}; export const emptyDoc = ( lastOpenedPath: string | null = null, @@ -143,6 +154,11 @@ export const appStore = store(getInitialState(), { middleware: [localStoragePersist(STORAGE_KEY, serialize)], }); +export const navigationHistoryStore = store({ + byWorkspace: {}, + isNavigating: false, +}); + export const workspaceStore = appStore.select("workspace"); export const viewerStore = appStore.select("document"); export const uiStore = appStore.select("ui"); diff --git a/packages/ui/src/components/Toolbar.tsx b/packages/ui/src/components/Toolbar.tsx index 9a535a63..fc550553 100644 --- a/packages/ui/src/components/Toolbar.tsx +++ b/packages/ui/src/components/Toolbar.tsx @@ -20,14 +20,27 @@ const ACTIONS_BASIS = "114px"; const NO_DRAG_STYLE = { WebkitAppRegion: "no-drag", } as CSSProperties; - -function ToolbarActions({ children }: { children?: React.ReactNode }) { +function ToolbarActions({ + align = "start", + children, + sidebarOpen = false, +}: { + align?: "start" | "end"; + children?: React.ReactNode; + sidebarOpen?: boolean; +}) { + const basis = sidebarOpen + ? `calc(var(--sidebar-width, ${ACTIONS_BASIS}) - 1px)` + : ACTIONS_BASIS; return ( -
- {children} +
+
+ {children} +
); } @@ -108,10 +121,15 @@ export function Toolbar({ {...rootProps} className={`flex h-9 items-center ${borderClass} ${rootProps?.className ?? ""}`} > - +
{onToggleSidebar && ( - )} - {leftSlot} -
-
+ {onToggleSidebar && ( + + )} + {leftSlot ? ( + sidebarOpen ? ( +
{leftSlot}
+ ) : ( + leftSlot + ) + ) : null} +
{editingTitle ? ( )}
- -
- {rightSlot} -
-
+ + {rightSlot} +
); } From d3a01cdacf440f07344031064ff3f5e2cdd48f12 Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Sat, 11 Jul 2026 09:16:06 -0400 Subject: [PATCH 3/4] Snap sidebar selection to active file on navigation Co-Authored-By: Claude Fable 5 --- packages/ui/src/components/Sidebar.tsx | 32 ++++++++++++++++++- .../src/components/sidebarSelection.test.ts | 23 +++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/Sidebar.tsx b/packages/ui/src/components/Sidebar.tsx index 103eb5c3..643ce487 100644 --- a/packages/ui/src/components/Sidebar.tsx +++ b/packages/ui/src/components/Sidebar.tsx @@ -112,9 +112,35 @@ export type SidebarMoveCandidate = parentFolderId: string | null; }; +function sidebarFileKey(path: string) { + return `file:${path}`; +} + export function sidebarRowKey(row: SidebarRow): string | null { if (row.kind === "section") return null; - return row.kind === "file" ? `file:${row.file.path}` : `folder:${row.id}`; + return row.kind === "file" + ? sidebarFileKey(row.file.path) + : `folder:${row.id}`; +} + +/** + * Snaps the click selection to the active file. A file can become active + * without a row click (history navigation, note links), and the selection + * highlight would otherwise stay on the last clicked row. + */ +export function snapSidebarSelection( + selection: SidebarSelectionState, + activePath: string | null, +): SidebarSelectionState { + const key = activePath ? sidebarFileKey(activePath) : null; + const unchanged = key + ? selection.selectedKeys.size === 1 && selection.selectedKeys.has(key) + : selection.selectedKeys.size === 0; + if (unchanged) return selection; + return { + selectedKeys: new Set(key ? [key] : []), + anchorKey: key, + }; } export function applySidebarSelection({ @@ -608,6 +634,10 @@ export function Sidebar({ setSelection((current) => pruneSidebarSelection(current, rows)); }, [rows]); + useEffect(() => { + setSelection((current) => snapSidebarSelection(current, highlightPath)); + }, [highlightPath]); + const handleDragStart = useCallback( (event: DragStartEvent) => { const data = event.active.data.current as DragItemData | undefined; diff --git a/packages/ui/src/components/sidebarSelection.test.ts b/packages/ui/src/components/sidebarSelection.test.ts index 8f7045b6..69177d78 100644 --- a/packages/ui/src/components/sidebarSelection.test.ts +++ b/packages/ui/src/components/sidebarSelection.test.ts @@ -5,6 +5,7 @@ import { sidebarMoveCandidateFromRow, sidebarMoveItemsForDrag, sidebarRowKey, + snapSidebarSelection, } from "./Sidebar"; import type { SidebarRow } from "./useSidebarTree"; @@ -118,6 +119,28 @@ describe("sidebar selection helpers", () => { expect(selection.anchorKey).toBe("file:/workspace/c.md"); }); + it("snaps the selection to a file activated without a click", () => { + let selection = select(emptySelection, 1, "replace"); + selection = snapSidebarSelection(selection, "/workspace/c.md"); + + expect([...selection.selectedKeys]).toEqual(["file:/workspace/c.md"]); + expect(selection.anchorKey).toBe("file:/workspace/c.md"); + }); + + it("keeps the same selection object when already on the active file", () => { + const selection = select(emptySelection, 1, "replace"); + + expect(snapSidebarSelection(selection, "/workspace/a.md")).toBe(selection); + }); + + it("clears the selection when no file is active", () => { + const selection = select(emptySelection, 1, "replace"); + const snapped = snapSidebarSelection(selection, null); + + expect(snapped.selectedKeys.size).toBe(0); + expect(snapped.anchorKey).toBeNull(); + }); + it("ignores section rows", () => { const selection = select(emptySelection, 0, "replace"); From 23cc090d5f89b13e745e3051ca9e7d428578f888 Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Sat, 11 Jul 2026 09:19:47 -0400 Subject: [PATCH 4/4] Fix unexpected text selection when clicking disabled controls --- packages/ui/src/components/Toolbar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/Toolbar.tsx b/packages/ui/src/components/Toolbar.tsx index 69379f69..e33590ca 100644 --- a/packages/ui/src/components/Toolbar.tsx +++ b/packages/ui/src/components/Toolbar.tsx @@ -127,7 +127,7 @@ export function Toolbar({ return (
void commitTitleEdit()}