diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 225274c..719218c 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -523,6 +523,21 @@ export function App() { }); }, [refreshTree, dispatch, tomeActive]); + // Mouse back/forward button support (button 3 = back, button 4 = forward). + useEffect(() => { + const handler = (e: MouseEvent) => { + if (e.button === 3) { + e.preventDefault(); + dispatch({ type: "navBack" }); + } else if (e.button === 4) { + e.preventDefault(); + dispatch({ type: "navForward" }); + } + }; + window.addEventListener("mouseup", handler); + return () => window.removeEventListener("mouseup", handler); + }, [dispatch]); + // Discard provisional notes whose tab was closed without modification. useEffect(() => { const openPaths = new Set(state.panes.flatMap((pane) => pane.tabs.map((tab) => tab.path))); @@ -608,6 +623,20 @@ export function App() { run: () => createCalendar(), }, { id: "new-grid", title: "New grid", category: "Create", run: () => createGrid() }, + { + id: "navigate-back", + title: "Navigate back", + category: "Go", + defaultHotkey: "Alt+ArrowLeft", + run: () => dispatch({ type: "navBack" }), + }, + { + id: "navigate-forward", + title: "Navigate forward", + category: "Go", + defaultHotkey: "Alt+ArrowRight", + run: () => dispatch({ type: "navForward" }), + }, { id: "split-pane", title: "Split editor pane", @@ -892,6 +921,10 @@ export function App() { tomeActive={tomeActive} onCloseTome={closeTome} onOpenTome={openTome} + canNavBack={state.navIndex > 0} + canNavForward={state.navIndex < state.navHistory.length - 1} + onNavBack={() => dispatch({ type: "navBack" })} + onNavForward={() => dispatch({ type: "navForward" })} />
void; onOpenTome?: () => void; + canNavBack?: boolean; + canNavForward?: boolean; + onNavBack?: () => void; + onNavForward?: () => void; } function TopBarMenu({ @@ -81,6 +85,10 @@ export function Ribbon({ tomeActive = true, onCloseTome, onOpenTome, + canNavBack = false, + canNavForward = false, + onNavBack, + onNavForward, }: RibbonProps) { const electronApi = window.electronAPI; const isDesktop = Boolean(electronApi); @@ -169,6 +177,26 @@ export function Ribbon({
+
+ + +
{ expect(state.panes[1].tabs.map((t) => t.path)).toEqual(["a.md"]); // original keeps non-active tab }); }); + +describe("navigation stack", () => { + it("openFile pushes to navHistory", () => { + let state = workspaceReducer(initial(), { type: "openFile", path: "a.md", title: "a" }); + expect(state.navHistory).toHaveLength(1); + expect(state.navHistory[0]).toEqual({ path: "a.md", title: "a" }); + expect(state.navIndex).toBe(0); + + state = workspaceReducer(state, { type: "openFile", path: "b.md", title: "b" }); + expect(state.navHistory).toHaveLength(2); + expect(state.navIndex).toBe(1); + }); + + it("opening same path does not duplicate history entry", () => { + let state = workspaceReducer(initial(), { type: "openFile", path: "a.md", title: "a" }); + state = workspaceReducer(state, { type: "openFile", path: "a.md", title: "a" }); + expect(state.navHistory).toHaveLength(1); + expect(state.navIndex).toBe(0); + }); + + it("navBack navigates to the previous entry", () => { + let state = workspaceReducer(initial(), { type: "openFile", path: "a.md", title: "a" }); + state = workspaceReducer(state, { type: "openFile", path: "b.md", title: "b" }); + expect(state.panes[0].tabs.find((t) => t.id === state.panes[0].activeTabId)?.path).toBe("b.md"); + + state = workspaceReducer(state, { type: "navBack" }); + expect(state.navIndex).toBe(0); + const activeTab = state.panes[0].tabs.find((t) => t.id === state.panes[0].activeTabId); + expect(activeTab?.path).toBe("a.md"); + }); + + it("navForward navigates forward after going back", () => { + let state = workspaceReducer(initial(), { type: "openFile", path: "a.md", title: "a" }); + state = workspaceReducer(state, { type: "openFile", path: "b.md", title: "b" }); + state = workspaceReducer(state, { type: "navBack" }); + state = workspaceReducer(state, { type: "navForward" }); + expect(state.navIndex).toBe(1); + const activeTab = state.panes[0].tabs.find((t) => t.id === state.panes[0].activeTabId); + expect(activeTab?.path).toBe("b.md"); + }); + + it("navBack at start is a no-op", () => { + const state = workspaceReducer(initial(), { type: "openFile", path: "a.md", title: "a" }); + const next = workspaceReducer(state, { type: "navBack" }); + expect(next.navIndex).toBe(0); + expect(next).toBe(state); + }); + + it("navForward at end is a no-op", () => { + const state = workspaceReducer(initial(), { type: "openFile", path: "a.md", title: "a" }); + const next = workspaceReducer(state, { type: "navForward" }); + expect(next.navIndex).toBe(0); + expect(next).toBe(state); + }); + + it("opening a file after going back truncates forward history", () => { + let state = workspaceReducer(initial(), { type: "openFile", path: "a.md", title: "a" }); + state = workspaceReducer(state, { type: "openFile", path: "b.md", title: "b" }); + state = workspaceReducer(state, { type: "navBack" }); + // Now open a new file — this should drop "b.md" from history. + state = workspaceReducer(state, { type: "openFile", path: "c.md", title: "c" }); + expect(state.navHistory.map((e) => e.path)).toEqual(["a.md", "c.md"]); + expect(state.navIndex).toBe(1); + }); +}); diff --git a/apps/web/src/state/reducer.ts b/apps/web/src/state/reducer.ts index 324c27d..b6b1bc5 100644 --- a/apps/web/src/state/reducer.ts +++ b/apps/web/src/state/reducer.ts @@ -1,4 +1,7 @@ -import type { Pane, Tab, WorkspaceAction, WorkspaceState } from "./types"; +import type { NavEntry, Pane, Tab, WorkspaceAction, WorkspaceState } from "./types"; + +/** Maximum number of entries kept in the navigation history. */ +const NAV_MAX = 100; function generateId(prefix: string): string { const random = globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2); @@ -16,6 +19,52 @@ function updatePane( }; } +/** + * Push `entry` to the navigation history of `state`, truncating any forward + * entries, and return the updated nav fields. Returns undefined when the entry + * is identical to the current position (no-op). + */ +function pushNav( + state: WorkspaceState, + entry: NavEntry, +): Pick | undefined { + const current = state.navHistory[state.navIndex]; + if (current && current.path === entry.path) { + // Already at this location — update title in place if it changed. + if (current.title === entry.title) return undefined; + const navHistory = [...state.navHistory]; + navHistory[state.navIndex] = entry; + return { navHistory, navIndex: state.navIndex }; + } + // Truncate any forward entries and append the new one. + const base = state.navHistory.slice(0, state.navIndex + 1); + const navHistory = [...base, entry].slice(-NAV_MAX); + return { navHistory, navIndex: navHistory.length - 1 }; +} + +/** + * Resolve the pane state when navigating to `entry` without pushing history. + * Activates an existing tab for the path, or opens a new tab. + */ +function applyNavEntry(state: WorkspaceState, entry: NavEntry): WorkspaceState { + // Find the pane that currently has this path open. + for (const pane of state.panes) { + const tab = pane.tabs.find((t) => t.path === entry.path); + if (tab) { + const next = updatePane(state, pane.id, (p) => ({ ...p, activeTabId: tab.id })); + return { ...next, activePaneId: pane.id }; + } + } + // Not open anywhere — open it as a new tab in the active pane. + const pane = state.panes.find((p) => p.id === state.activePaneId) ?? state.panes[0]; + const tab: Tab = { id: generateId("tab"), path: entry.path, title: entry.title }; + return updatePane(state, pane.id, (p) => ({ + ...p, + tabs: [...p.tabs, tab], + activeTabId: tab.id, + })); +} + export function workspaceReducer(state: WorkspaceState, action: WorkspaceAction): WorkspaceState { switch (action.type) { case "setTree": @@ -81,15 +130,36 @@ export function workspaceReducer(state: WorkspaceState, action: WorkspaceAction) case "openFile": { const pane = state.panes.find((p) => p.id === state.activePaneId) ?? state.panes[0]; const existing = pane.tabs.find((tab) => tab.path === action.path); + const navUpdate = pushNav(state, { path: action.path, title: action.title }); if (existing) { - return updatePane(state, pane.id, (p) => ({ ...p, activeTabId: existing.id })); + const next = updatePane(state, pane.id, (p) => ({ ...p, activeTabId: existing.id })); + return navUpdate ? { ...next, ...navUpdate } : next; } const tab: Tab = { id: generateId("tab"), path: action.path, title: action.title }; - return updatePane(state, pane.id, (p) => ({ + const next = updatePane(state, pane.id, (p) => ({ ...p, tabs: [...p.tabs, tab], activeTabId: tab.id, })); + return navUpdate ? { ...next, ...navUpdate } : next; + } + + case "navBack": { + if (state.navIndex <= 0) return state; + const navIndex = state.navIndex - 1; + const entry = state.navHistory[navIndex]; + if (!entry) return state; + const next = applyNavEntry(state, entry); + return { ...next, navIndex }; + } + + case "navForward": { + if (state.navIndex >= state.navHistory.length - 1) return state; + const navIndex = state.navIndex + 1; + const entry = state.navHistory[navIndex]; + if (!entry) return state; + const next = applyNavEntry(state, entry); + return { ...next, navIndex }; } case "activateTab": diff --git a/apps/web/src/state/types.ts b/apps/web/src/state/types.ts index e9cd0b4..fa14078 100644 --- a/apps/web/src/state/types.ts +++ b/apps/web/src/state/types.ts @@ -18,17 +18,29 @@ export interface Pane { activeTabId?: string; } +/** A single entry in the navigation history (path + display title). */ +export interface NavEntry { + path: string; + title: string; +} + export interface WorkspaceState { tree: FileEntry[]; panes: Pane[]; activePaneId: string; theme: ThemeMode; status: string; + /** Ordered list of visited locations. */ + navHistory: NavEntry[]; + /** Index of the currently active entry in navHistory (−1 when empty). */ + navIndex: number; } export type WorkspaceAction = | { type: "setTree"; tree: FileEntry[] } | { type: "openFile"; path: string; title: string } + | { type: "navBack" } + | { type: "navForward" } | { type: "closeTab"; paneId: string; tabId: string } | { type: "closeOtherTabs"; paneId: string; tabId: string } | { type: "closeTabsToRight"; paneId: string; tabId: string } @@ -70,5 +82,7 @@ export function createInitialState(theme: ThemeMode): WorkspaceState { activePaneId: paneId, theme, status: "Ready", + navHistory: [], + navIndex: -1, }; } diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index c62c557..a881165 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -327,10 +327,34 @@ button { .ribbon-search { display: flex; + align-items: center; + gap: 4px; justify-content: center; min-width: 0; } +.ribbon-nav { + display: flex; + gap: 2px; + flex-shrink: 0; +} + +.ribbon-nav-btn { + width: 24px; + height: 24px; + padding: 0; + font-size: 14px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; +} + +.ribbon-nav-btn:disabled { + opacity: 0.35; + cursor: default; +} + .ribbon-search-input { width: min(420px, 100%); height: 26px; diff --git a/package-lock.json b/package-lock.json index 12cd6a4..bd92106 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4713,6 +4713,7 @@ "version": "13.0.3", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "hasInstallScript": true, "license": "MIT", "dependencies": { "node-addon-api": "^8.0.0"