From a57007164f1e027628ed8a0950fbb63107efbe77 Mon Sep 17 00:00:00 2001 From: Dale Lane Date: Sun, 28 Jun 2026 11:32:22 +0100 Subject: [PATCH 1/3] feat: add browser-like back/forward navigation history Tracks note navigation in a history stack so you can move back and forward between previously visited notes, mimicing standard browser behaviour. I've chosen Cmd+[ and Cmd+] as keyboard shortcuts for back/forward because that's what Safari uses. I considered using Cmd+left and Cmd+right to match Chrome, but that clashed with editor shortcuts to jump to the start of the line. I've added toolbar back/forward buttons with arrow icons, next to the sidebar toggle, and enable/disable them if the stack has no pages left. I've tried to make sure the stack keeps up with any changes (e.g. when notes or folders are renamed or deleted) so the history doesn't point at non-existent pages. Returning to the default "What's on your mind?" page will clear the history entirely as it is essentially a reset. All of that did complicate the commit a lot more than I'd originally hoped/expected, sorry about that! To copy web browser behaviour, if you click on a link that discards any forward history. I'm ignoring any external URL clicks as that just got confusing to include. Signed-off-by: Dale Lane --- src/App.tsx | 18 +++++ src/components/editor/Editor.tsx | 26 +++++++ src/context/NotesContext.tsx | 125 ++++++++++++++++++++++++++++++- src/lib/shortcuts.ts | 3 + 4 files changed, 169 insertions(+), 3 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 6bbf132b..16314df8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -60,6 +60,8 @@ function AppContent() { reloadCurrentNote, currentNote, syncNotesFolder, + goBack, + goForward, } = useNotes(); const { interfaceZoom, setInterfaceZoom, reloadSettings } = useTheme(); const interfaceZoomRef = useRef(interfaceZoom); @@ -360,6 +362,20 @@ function AppContent() { return; } + // Cmd+[ - Navigate back + if ((e.metaKey || e.ctrlKey) && e.key === "[") { + e.preventDefault(); + goBack(); + return; + } + + // Cmd+] - Navigate forward + if ((e.metaKey || e.ctrlKey) && e.key === "]") { + e.preventDefault(); + goForward(); + return; + } + // Arrow keys for note navigation // Skip if folder tree view is handling its own navigation const isInFolderTree = !!(e.target as HTMLElement).closest("[data-folder-tree]"); @@ -441,6 +457,8 @@ function AppContent() { focusMode, view, setInterfaceZoom, + goBack, + goForward, ]); const handleClosePalette = useCallback(() => { diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index dcf07161..95c6956d 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -107,6 +107,8 @@ import { MarkdownIcon, MarkdownOffIcon, FolderPlusIcon, + ArrowLeftIcon, + ArrowRightIcon, } from "../icons"; function formatDateTime(timestamp: number): string { @@ -546,6 +548,10 @@ export function Editor({ const pinNote = notesCtx?.pinNote; const unpinNote = notesCtx?.unpinNote; const notes = notesCtx?.notes; + const canGoBack = notesCtx?.canGoBack ?? false; + const canGoForward = notesCtx?.canGoForward ?? false; + const goBack = notesCtx?.goBack; + const goForward = notesCtx?.goForward; const { textDirection } = useTheme(); const [isSaving, setIsSaving] = useState(false); // Force re-render when selection changes to update toolbar active states @@ -2183,6 +2189,26 @@ export function Editor({ )} + {goBack && ( + + + + )} + {goForward && ( + + + + )} {formatDateTime(currentNote.modified)} diff --git a/src/context/NotesContext.tsx b/src/context/NotesContext.tsx index 7ad2ea11..00e12433 100644 --- a/src/context/NotesContext.tsx +++ b/src/context/NotesContext.tsx @@ -27,6 +27,8 @@ interface NotesDataContextValue { isSearching: boolean; hasExternalChanges: boolean; reloadVersion: number; + canGoBack: boolean; + canGoForward: boolean; } // Actions context: stable references, rarely causes re-renders @@ -51,11 +53,37 @@ interface NotesActionsContextValue { renameFolder: (oldPath: string, newName: string) => Promise; moveNote: (id: string, targetFolder: string) => Promise; moveFolder: (path: string, targetParent: string) => Promise; + goBack: () => Promise; + goForward: () => Promise; } const NotesDataContext = createContext(null); const NotesActionsContext = createContext(null); +type NavState = { history: string[]; index: number }; + +function navPush(prev: NavState, id: string): NavState { + if (prev.history[prev.index] === id) return prev; + const newHistory = [...prev.history.slice(0, prev.index + 1), id]; + return { history: newHistory, index: newHistory.length - 1 }; +} + +function navRemove(prev: NavState, shouldRemove: (id: string) => boolean): NavState { + if (!prev.history.some(shouldRemove)) return prev; + if (prev.history[prev.index] != null && shouldRemove(prev.history[prev.index])) { + return { history: [], index: -1 }; + } + const newHistory = prev.history.filter((h) => !shouldRemove(h)); + if (newHistory.length === 0) return { history: [], index: -1 }; + const keptBeforeOrAt = prev.history + .slice(0, prev.index + 1) + .filter((h) => !shouldRemove(h)).length; + return { + history: newHistory, + index: Math.max(0, Math.min(keptBeforeOrAt - 1, newHistory.length - 1)), + }; +} + export function NotesProvider({ children }: { children: ReactNode }) { const [notes, setNotes] = useState([]); const [selectedNoteId, setSelectedNoteId] = useState(null); @@ -86,6 +114,13 @@ export function NotesProvider({ children }: { children: ReactNode }) { const searchRequestIdRef = useRef(0); // Tracks the ID of a newly created note so Editor can focus its title. const pendingNewNoteIdRef = useRef(null); + // Navigation history: stack of note IDs with a current position index + const [nav, setNav] = useState<{ history: string[]; index: number }>({ + history: [], + index: -1, + }); + const navRef = useRef(nav); + navRef.current = nav; const refreshNotes = useCallback(async () => { if (!notesFolder) return; @@ -108,16 +143,15 @@ export function NotesProvider({ children }: { children: ReactNode }) { }, 300); }, [refreshNotes]); - const selectNote = useCallback(async (id: string) => { + // Internal: load and display a note without touching navigation history + const loadNoteById = useCallback(async (id: string) => { const requestId = ++selectRequestIdRef.current; try { if (pendingNewNoteIdRef.current !== id) { pendingNewNoteIdRef.current = null; } - // Set selected ID immediately for responsive UI setSelectedNoteId(id); setHasExternalChanges(false); - // Expand parent folders so the note is visible in the tree const lastSlash = id.lastIndexOf("/"); if (lastSlash > 0) { window.dispatchEvent( @@ -129,12 +163,37 @@ export function NotesProvider({ children }: { children: ReactNode }) { const note = await notesService.readNote(id); if (requestId !== selectRequestIdRef.current) return; setCurrentNote(note); + return true; } catch (err) { if (requestId !== selectRequestIdRef.current) return; setError(err instanceof Error ? err.message : "Failed to load note"); } }, []); + const selectNote = useCallback( + async (id: string) => { + setNav((prev) => navPush(prev, id)); + await loadNoteById(id); + }, + [loadNoteById], + ); + + const goBack = useCallback(async () => { + const { history, index } = navRef.current; + if (index <= 0) return; + const newIndex = index - 1; + const ok = await loadNoteById(history[newIndex]); + if (ok) setNav((prev) => ({ ...prev, index: newIndex })); + }, [loadNoteById]); + + const goForward = useCallback(async () => { + const { history, index } = navRef.current; + if (index >= history.length - 1) return; + const newIndex = index + 1; + const ok = await loadNoteById(history[newIndex]); + if (ok) setNav((prev) => ({ ...prev, index: newIndex })); + }, [loadNoteById]); + const reloadCurrentNote = useCallback(async () => { if (!selectedNoteIdRef.current) return; try { @@ -165,6 +224,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { await refreshNotes(); setCurrentNote(note); setSelectedNoteId(note.id); + setNav((prev) => navPush(prev, note.id)); // Clear search when creating a new note setSearchQuery(""); setSearchResults([]); @@ -215,6 +275,14 @@ export function NotesProvider({ children }: { children: ReactNode }) { }; await notesService.updateSettings(updatedSettings); } + + // Update navigation history to reflect the new ID + setNav((prev) => ({ + ...prev, + history: prev.history.map((h) => + h === savingNoteId ? updated.id : h, + ), + })); } // Clear external changes flag - if it was set by our own save, we want to ignore it @@ -275,6 +343,10 @@ export function NotesProvider({ children }: { children: ReactNode }) { } return prevId; }); + + // Remove deleted note from navigation history + setNav((prev) => navRemove(prev, (h) => h === id)); + await refreshNotes(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to delete note"); @@ -293,6 +365,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { await refreshNotes(); setCurrentNote(newNote); setSelectedNoteId(newNote.id); + setNav((prev) => navPush(prev, newNote.id)); setTimeout(() => { recentlySavedRef.current.delete(newNote.id); }, 1000); @@ -353,6 +426,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { await refreshNotes(); setCurrentNote(note); setSelectedNoteId(note.id); + setNav((prev) => navPush(prev, note.id)); setSearchQuery(""); setSearchResults([]); setTimeout(() => { @@ -394,6 +468,11 @@ export function NotesProvider({ children }: { children: ReactNode }) { } return prevId; }); + + // Remove notes inside the deleted folder from navigation history + const deletedPrefix = path + "/"; + setNav((prev) => navRemove(prev, (h) => h.startsWith(deletedPrefix))); + await refreshNotes(); } catch (err) { setError( @@ -432,6 +511,16 @@ export function NotesProvider({ children }: { children: ReactNode }) { return prevId; }); + // Update navigation history IDs for notes inside the renamed folder + setNav((prev) => ({ + ...prev, + history: prev.history.map((h) => + h.startsWith(oldPrefix) + ? newPrefix + h.substring(oldPrefix.length) + : h, + ), + })); + await refreshNotes(); } catch (err) { setError( @@ -458,6 +547,13 @@ export function NotesProvider({ children }: { children: ReactNode }) { } return prevId; }); + + // Update navigation history to reflect the new ID + setNav((prev) => ({ + ...prev, + history: prev.history.map((h) => (h === id ? newId : h)), + })); + await refreshNotes(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to move note"); @@ -495,6 +591,16 @@ export function NotesProvider({ children }: { children: ReactNode }) { return prevId; }); + // Update navigation history IDs for notes inside the moved folder + setNav((prev) => ({ + ...prev, + history: prev.history.map((h) => + h.startsWith(oldPrefix) + ? newPrefix + h.substring(oldPrefix.length) + : h, + ), + })); + await refreshNotes(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to move folder"); @@ -507,6 +613,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { try { await notesService.setNotesFolder(path); setNotesFolderState(path); + setNav({ history: [], index: -1 }); // Start file watcher after setting folder await notesService.startFileWatcher(); } catch (err) { @@ -523,6 +630,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { setNotesFolderState(path); setSelectedNoteId(null); setCurrentNote(null); + setNav({ history: [], index: -1 }); const notesList = await notesService.listNotes(); setNotes(notesList); await notesService.startFileWatcher(); @@ -681,6 +789,9 @@ export function NotesProvider({ children }: { children: ReactNode }) { }, [notesFolder, refreshNotes]); // Memoize data context value to prevent unnecessary re-renders + const canGoBack = nav.index > 0; + const canGoForward = nav.index < nav.history.length - 1; + const dataValue = useMemo( () => ({ notes, @@ -694,6 +805,8 @@ export function NotesProvider({ children }: { children: ReactNode }) { isSearching, hasExternalChanges, reloadVersion, + canGoBack, + canGoForward, }), [ notes, @@ -707,6 +820,8 @@ export function NotesProvider({ children }: { children: ReactNode }) { isSearching, hasExternalChanges, reloadVersion, + canGoBack, + canGoForward, ] ); @@ -733,6 +848,8 @@ export function NotesProvider({ children }: { children: ReactNode }) { renameFolder: renameFolderAction, moveNote: moveNoteAction, moveFolder: moveFolderAction, + goBack, + goForward, }), [ selectNote, @@ -755,6 +872,8 @@ export function NotesProvider({ children }: { children: ReactNode }) { renameFolderAction, moveNoteAction, moveFolderAction, + goBack, + goForward, ] ); diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index a58c0ce9..ff7e4c0b 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -23,6 +23,9 @@ export const shortcutCategories: ShortcutCategory[] = [ { keys: [mod, "="], description: "Zoom in" }, { keys: [mod, "-"], description: "Zoom out" }, { keys: [mod, "0"], description: "Reset zoom" }, + { keys: [mod, "G"], description: "Go to note" }, + { keys: [mod, "["], description: "Navigate back" }, + { keys: [mod, "]"], description: "Navigate forward" }, ], }, { From 51b68aabda89ebb36b2dfdd7113ef95a3303fbac Mon Sep 17 00:00:00 2001 From: Dale Lane Date: Sun, 28 Jun 2026 13:42:38 +0100 Subject: [PATCH 2/3] fix: remove orphaned shortcut identified in code review This was a git commit mixup - I've got a go-to-note feature in a separate branch, and hadn't spotted that I left the shortcut in this branch. Signed-off-by: Dale Lane --- src/lib/shortcuts.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index ff7e4c0b..c7ff5d27 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -23,7 +23,6 @@ export const shortcutCategories: ShortcutCategory[] = [ { keys: [mod, "="], description: "Zoom in" }, { keys: [mod, "-"], description: "Zoom out" }, { keys: [mod, "0"], description: "Reset zoom" }, - { keys: [mod, "G"], description: "Go to note" }, { keys: [mod, "["], description: "Navigate back" }, { keys: [mod, "]"], description: "Navigate forward" }, ], From afe34b20d882a784c75fd5ce9a11260db14e4c04 Mon Sep 17 00:00:00 2001 From: Dale Lane Date: Sun, 28 Jun 2026 13:46:37 +0100 Subject: [PATCH 3/3] fix: Apply suggestion from @coderabbitai[bot] Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/context/NotesContext.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/context/NotesContext.tsx b/src/context/NotesContext.tsx index 00e12433..22fba13a 100644 --- a/src/context/NotesContext.tsx +++ b/src/context/NotesContext.tsx @@ -172,8 +172,8 @@ export function NotesProvider({ children }: { children: ReactNode }) { const selectNote = useCallback( async (id: string) => { - setNav((prev) => navPush(prev, id)); - await loadNoteById(id); + const ok = await loadNoteById(id); + if (ok) setNav((prev) => navPush(prev, id)); }, [loadNoteById], );