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..e633abe8 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -42,6 +42,8 @@ import { createWorkspaceWithSidebar, forceKeepLocalEdits, getPendingRenameTarget, + goBack, + goForward, handleExternalFileChange, loadPath, openWorkspace, @@ -58,6 +60,8 @@ import { toggleTerminal, updateEditorContent, } from "./store/actions"; +import { canGoBack, canGoForward } from "./store/history"; +import { useHistoryNav } from "./store/hooks"; import { chatCommandStore, sidebarOpenStore, @@ -102,6 +106,8 @@ function App() { const sidebarOpen = useStoreValue(sidebarOpenStore); const terminalPosition = useStoreValue(terminalPositionStore); const hasWorkspace = workspacePath !== null; + const { canGoBack: menuCanGoBack, canGoForward: menuCanGoForward } = + useHistoryNav(); const [scrollContainerEl, setScrollContainerEl] = useState(null); const [settingsOpen, setSettingsOpen] = useState(false); @@ -201,8 +207,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 +224,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 +317,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 ( @@ -349,7 +373,8 @@ function App() { ? workspace.lastOpenedPaths[workspace.workspacePath] : undefined); if (lastPath) { - await loadPath(lastPath); + // Restore must stay quiet when the remembered file was deleted on disk. + await loadPath(lastPath, { missing: "silent" }); } }; void init(); diff --git a/apps/desktop/src/components/Toolbar.tsx b/apps/desktop/src/components/Toolbar.tsx index 406dff0d..f036ff14 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,12 +19,15 @@ import { copyText } from "../lib/clipboard"; import { hasMarkdownExtension } from "../lib/filePath"; import { revealFileLabel } from "../lib/revealFile"; import { + goBack, + goForward, renameCurrentMarkdownFile, requestChatAboutNote, setViewerMode, toggleSidebar, toggleTerminal, } from "../store/actions"; +import { useHistoryNav } from "../store/hooks"; import { currentPathStore, sidebarOpenStore, @@ -65,6 +70,7 @@ export function Toolbar({ platformInset={!isFullScreen} rootProps={{ style: dragRegionStyle }} onToggleSidebar={toggleSidebar} + leftSlot={} onRenameCurrentPath={(nextName) => void renameCurrentMarkdownFile(nextName) } @@ -91,6 +97,36 @@ export function Toolbar({ ); } +function NavigationControls() { + const { canGoBack, canGoForward } = useHistoryNav(); + 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..42522214 100644 --- a/apps/desktop/src/store/actions.test.ts +++ b/apps/desktop/src/store/actions.test.ts @@ -43,8 +43,9 @@ async function loadStoreActions(api: MockDesktopApi) { }); const actions = await import("./actions"); + const history = await import("./history"); const state = await import("./state"); - return { ...actions, ...state }; + return { ...actions, ...history, ...state }; } describe("desktop savePathContent", () => { @@ -986,6 +987,201 @@ 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("stays on the current file when opening a missing file fails", async () => { + const api = createDesktopApi(); + api.pathExists.mockResolvedValue(true); + api.readFileText.mockImplementation(async (path: string) => { + if (path === "/workspace/missing.md") { + throw new Error("ENOENT: no such file or directory"); + } + return `content:${path}`; + }); + const { canGoBack, canGoForward, goBack, loadPath, viewerStore } = + await loadStoreActions(api); + + await loadPath("/workspace/a.md"); + await loadPath("/workspace/b.md"); + await loadPath("/workspace/missing.md"); + + expect(viewerStore.get().currentPath).toBe("/workspace/b.md"); + expect(viewerStore.get().content).toBe("content:/workspace/b.md"); + expect(viewerStore.get().status).toBe("ready"); + expect(canGoBack()).toBe(true); + expect(canGoForward()).toBe(false); + + await goBack(); + + expect(viewerStore.get().currentPath).toBe("/workspace/a.md"); + }); + + 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("silently clears a missing restore path without toasting", async () => { + const api = createDesktopApi(); + const missingPath = "/workspace/missing.md"; + api.readFileText.mockRejectedValue( + new Error(`ENOENT: no such file or directory, open '${missingPath}'`), + ); + const toastError = vi.fn(); + vi.doMock("sonner", () => ({ toast: { error: toastError } })); + const { appStore, loadPath, viewerStore } = await loadStoreActions(api); + + appStore.set((current) => ({ + ...current, + workspace: { + ...current.workspace, + workspacePath: "/workspace", + lastOpenedPaths: { "/workspace": missingPath }, + }, + document: { + ...current.document, + lastOpenedPath: missingPath, + }, + })); + + await loadPath(missingPath, { missing: "silent" }); + + expect(viewerStore.get().currentPath).toBeNull(); + expect(viewerStore.get().status).toBe("idle"); + expect(viewerStore.get().lastOpenedPath).toBeNull(); + expect(appStore.get().workspace.lastOpenedPaths).toEqual({}); + expect(toastError).not.toHaveBeenCalled(); + }); + + it("does not push history when reloading a renamed current file", async () => { + const api = createDesktopApi(); + api.pathExists.mockResolvedValue(true); + api.readFileText.mockImplementation( + async (path: string) => `content:${path}`, + ); + const { canGoBack, loadPath, viewerStore } = await loadStoreActions(api); + + await loadPath("/workspace/a.md"); + await loadPath("/workspace/b.md"); + await loadPath("/workspace/b-renamed.md", { history: "none" }); + + expect(viewerStore.get().currentPath).toBe("/workspace/b-renamed.md"); + expect(canGoBack()).toBe(true); + }); + 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..feaf19ed 100644 --- a/apps/desktop/src/store/actions.ts +++ b/apps/desktop/src/store/actions.ts @@ -23,6 +23,17 @@ import { pathAfterMove, rewriteMovedLinks, } from "../lib/markdownLinkRewrite"; +import { + activeHistory, + canGoBack, + canGoForward, + clearHistory, + normalizeStack, + pruneHistory, + pushHistory, + rewriteHistory, + setHistory, +} from "./history"; import type { TerminalPosition } from "./persistence"; import { DEFAULT_CHAT_COMMAND } from "./settings"; import { @@ -34,6 +45,7 @@ import { type FileEntry, type FolderEntry, getBaseline, + historyStore, isInWorkspace, LOADING_DELAY_MS, MAX_RECENT, @@ -345,6 +357,11 @@ function uniqueFolderPath(parent: string): string { const pendingRenames = new Map(); +type LoadPathOptions = { + history?: "push" | "none"; + missing?: "toast" | "silent"; +}; + export function getPendingRenameTarget(path: string) { return pendingRenames.get(path) ?? null; } @@ -443,7 +460,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 +612,7 @@ export async function renameMarkdownFile(path: string, nextName: string) { const movedFiles = [{ fromPath: path, toPath: nextPath }]; if (movedAssetFolder) movedFiles.push(movedAssetFolder); await updateMovedLinks(movedFiles, filesBeforeRename); + rewriteHistory(path, nextPath); appStore.set((state) => ({ ...state, workspace: { @@ -629,7 +647,8 @@ export async function renameMarkdownFile(path: string, nextName: string) { await syncPinnedNotes(); await refreshFiles(); if (isCurrentFile) { - await loadPath(nextPath); + // Path rewrite already updated history; reload content without a new visit. + await loadPath(nextPath, { history: "none" }); } } catch (err) { pendingRenames.delete(path); @@ -713,6 +732,7 @@ export async function renameFolder( } await desktopApi.renameFile(path, nextPath); await deleteEmptySourceAncestors(path, nextPath, workspacePath); + rewriteHistory(path, nextPath, true); appStore.set((state) => ({ ...state, workspace: { @@ -823,6 +843,7 @@ export async function moveSidebarItem( item.kind === "file" ? await moveAssociatedAssetFolder(sourcePath, nextPath) : null; + rewriteHistory(sourcePath, nextPath, isFolder); appStore.set((state) => ({ ...state, workspace: { @@ -932,6 +953,12 @@ export async function deleteMarkdownFile( ) { try { await desktopApi.deleteFile(path); + const wasCurrentFile = viewerStore.get().currentPath === path; + if (wasCurrentFile) { + clearHistory(); + } else { + pruneHistory(path); + } appStore.set((state) => ({ ...state, workspace: { @@ -973,6 +1000,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)) { + clearHistory(); + } else { + pruneHistory(path, true); + } appStore.set((state) => ({ ...state, workspace: { @@ -1053,29 +1086,107 @@ 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") pushHistory(path); + } catch (err) { + if (isStale()) return; + const message = handleFileError(err); + if (missingMode === "toast") { + toast.error("Failed to open file", { description: message }); + // Stay on the current document; the toast is the only failure + // surface. Only undo the delayed loading flip if it fired. + viewerStore.set((state) => + state.status === "loading" + ? { ...state, status: state.currentPath ? "ready" : "idle" } + : state, + ); + } 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 (!(delta < 0 ? canGoBack() : canGoForward())) return; + + const current = viewerStore.get(); + if (current.externalChange.kind === "conflict") return; + // Block concurrent history ops for the whole leave (save + load). + historyStore.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, - })); + if (current.currentPath) { + await savePathContent(current.currentPath, current.content); + if (viewerStore.get().externalChange.kind === "conflict") return; + } + + let working = activeHistory(); + let nextIndex = working.index + delta; + while (nextIndex >= 0 && nextIndex < working.entries.length) { + const target = working.entries[nextIndex]; + if (await desktopApi.pathExists(target)) { + setHistory({ entries: working.entries, index: nextIndex }); + await loadPath(target, { history: "none", missing: "silent" }); + return; + } + const entries = working.entries.filter((entry) => entry !== target); + working = normalizeStack({ + entries, + index: Math.min(nextIndex - (delta > 0 ? 1 : 0), entries.length - 1), + }); + setHistory(working); + // Forward keeps nextIndex (successor shifted in); back steps left. + nextIndex += delta > 0 ? 0 : -1; + } + toast.error( + delta < 0 ? "No previous file to open" : "No next file to open", + ); } finally { - window.clearTimeout(timer); + historyStore.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/history.ts b/apps/desktop/src/store/history.ts new file mode 100644 index 00000000..7fe6a7d4 --- /dev/null +++ b/apps/desktop/src/store/history.ts @@ -0,0 +1,143 @@ +import { pathInFolder, replacePathPrefix } from "../lib/filePath"; +import { + type HistoryStack, + type HistoryState, + historyStore, + MAX_HISTORY, + workspaceStore, +} from "./state"; + +/** + * Per-workspace back/forward stacks over opened file paths. + * + * This module owns reads and writes of `historyStore`. Navigation itself + * (save current doc, load target) lives in actions.ts to avoid an import + * cycle with `loadPath`. + */ + +/** Stack key for files opened with no workspace. */ +const LOOSE_HISTORY_KEY = "__none__"; + +function historyKey(workspacePath = workspaceStore.get().workspacePath) { + return workspacePath ?? LOOSE_HISTORY_KEY; +} + +/** Clamps a persisted or edited stack so `index` always points at an entry. */ +export function normalizeStack(stack?: HistoryStack): HistoryStack { + 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 stackFor(history: HistoryState, workspacePath: string | null) { + return normalizeStack(history.byWorkspace[historyKey(workspacePath)]); +} + +/** The current workspace's stack. */ +export function activeHistory() { + return stackFor(historyStore.get(), workspaceStore.get().workspacePath); +} + +/** Replaces the current workspace's stack. */ +export function setHistory(stack: HistoryStack) { + const key = historyKey(); + historyStore.set((state) => ({ + ...state, + byWorkspace: { ...state.byWorkspace, [key]: stack }, + })); +} + +/** + * Records a visit: drops any forward entries, appends `path`, and trims to + * `MAX_HISTORY`. No-op when `path` is already the current entry. + */ +export function pushHistory(path: string) { + const stack = activeHistory(); + if (stack.entries[stack.index] === path) return; + const entries = [...stack.entries.slice(0, stack.index + 1), path].slice( + -MAX_HISTORY, + ); + setHistory({ entries, index: entries.length - 1 }); +} + +/** Empties the current workspace's stack. */ +export function clearHistory() { + setHistory({ entries: [], index: -1 }); +} + +/** Applies `update` to every workspace's stack, normalizing around it. */ +function mapHistory(update: (stack: HistoryStack) => HistoryStack) { + historyStore.set((state) => ({ + ...state, + byWorkspace: Object.fromEntries( + Object.entries(state.byWorkspace).map(([key, stack]) => [ + key, + normalizeStack(update(normalizeStack(stack))), + ]), + ), + })); +} + +/** + * Points history at a file's new location after a rename or move so back and + * forward keep working. With `isFolder`, rewrites the path prefix of every + * entry inside the folder. Runs across all workspaces because a rename can + * touch paths recorded under another workspace's stack. + */ +export function rewriteHistory( + fromPath: string, + toPath: string, + isFolder = false, +) { + mapHistory((stack) => ({ + ...stack, + entries: stack.entries.map((entry) => + isFolder + ? replacePathPrefix(entry, fromPath, toPath) + : entry === fromPath + ? toPath + : entry, + ), + })); +} + +/** + * Drops a deleted file (or with `isFolder`, a folder's contents) from every + * stack. Keeps the index on the current entry when it survives; otherwise + * clamps toward the nearest remaining entry. + */ +export function pruneHistory(path: string, isFolder = false) { + mapHistory((stack) => { + const current = stack.entries[stack.index]; + const entries = stack.entries.filter((entry) => + isFolder ? !pathInFolder(entry, path) : entry !== path, + ); + const keptIndex = current ? entries.indexOf(current) : -1; + return { + entries, + index: + keptIndex >= 0 ? keptIndex : Math.min(stack.index, entries.length - 1), + }; + }); +} + +export function canGoBack( + history = historyStore.get(), + workspacePath = workspaceStore.get().workspacePath, +) { + if (history.isNavigating) return false; + return stackFor(history, workspacePath).index > 0; +} + +export function canGoForward( + history = historyStore.get(), + workspacePath = workspaceStore.get().workspacePath, +) { + if (history.isNavigating) return false; + const { index, entries } = stackFor(history, workspacePath); + return index >= 0 && index < entries.length - 1; +} diff --git a/apps/desktop/src/store/hooks.ts b/apps/desktop/src/store/hooks.ts new file mode 100644 index 00000000..80ab84a7 --- /dev/null +++ b/apps/desktop/src/store/hooks.ts @@ -0,0 +1,18 @@ +import { useStoreValue } from "@simplestack/store/react"; +import { canGoBack, canGoForward } from "./history"; +import { historyStore, workspacePathStore } from "./state"; + +// Back/forward enablement depends on two stores: the history stacks and the +// workspace that picks the active stack. Boolean selectors re-render callers +// only when enablement actually flips. +export function useHistoryNav() { + const workspacePath = useStoreValue(workspacePathStore); + return { + canGoBack: useStoreValue(historyStore, (history) => + canGoBack(history, workspacePath), + ), + canGoForward: useStoreValue(historyStore, (history) => + canGoForward(history, workspacePath), + ), + }; +} diff --git a/apps/desktop/src/store/state.ts b/apps/desktop/src/store/state.ts index fb01dced..18243fa7 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_HISTORY = 50; + +export type HistoryStack = { + entries: string[]; + index: number; +}; + +export type HistoryState = { + 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 historyStore = 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/Sidebar.tsx b/packages/ui/src/components/Sidebar.tsx index d749827f..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; @@ -1559,6 +1589,19 @@ export function SidebarFrame({ setSidebarWidth(nextWidth); }, [widthStorageKey]); + // Publish width on the document root so the full-window toolbar (a sibling, + // not a descendant) can match the sidebar seam. Hoisting the variable onto a + // shared ancestor would mean lifting width state (resize + persistence) out + // of this component into a provider both app shells wire up. One sidebar + // mounts per window and cleanup removes the property, so the root is safe. + useEffect(() => { + const root = document.documentElement; + root.style.setProperty("--sidebar-width", `${sidebarWidth}px`); + return () => { + root.style.removeProperty("--sidebar-width"); + }; + }, [sidebarWidth]); + function setWidth(nextWidth: number) { const clampedWidth = clampSidebarWidth(nextWidth); sidebarWidthRef.current = clampedWidth; diff --git a/packages/ui/src/components/Toolbar.tsx b/packages/ui/src/components/Toolbar.tsx index 9a535a63..e33590ca 100644 --- a/packages/ui/src/components/Toolbar.tsx +++ b/packages/ui/src/components/Toolbar.tsx @@ -17,15 +17,36 @@ const END_INSET = isMac() ? "0px" : "calc(100vw - env(titlebar-area-width, calc(100vw - 138px)))"; const ACTIONS_BASIS = "114px"; +const DEFAULT_SIDEBAR_WIDTH = "220px"; const NO_DRAG_STYLE = { WebkitAppRegion: "no-drag", } as CSSProperties; -function ToolbarActions({ children }: { children?: React.ReactNode }) { +// Clusters shrink from ACTIONS_BASIS by default; passing `width` pins them to +// an exact size instead (used to match the sidebar seam). +function ToolbarCluster({ + children, + align = "start", + width, + platformInset = true, +}: { + children?: React.ReactNode; + align?: "start" | "end"; + width?: string; + platformInset?: boolean; +}) { return (
{children}
@@ -106,36 +127,44 @@ export function Toolbar({ return (
- -
- {onToggleSidebar && ( - - )} - {leftSlot} -
-
+ + {onToggleSidebar && ( + + )} + {leftSlot ? ( + sidebarOpen ? ( +
{leftSlot}
+ ) : ( + leftSlot + ) + ) : null} +
{editingTitle ? ( void commitTitleEdit()} @@ -162,14 +191,9 @@ export function Toolbar({ )}
- -
- {rightSlot} -
-
+ + {rightSlot} +
); } 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");