Skip to content
Open
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
18 changes: 18 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ function AppContent() {
reloadCurrentNote,
currentNote,
syncNotesFolder,
goBack,
goForward,
} = useNotes();
const { interfaceZoom, setInterfaceZoom, reloadSettings } = useTheme();
const interfaceZoomRef = useRef(interfaceZoom);
Expand Down Expand Up @@ -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]");
Expand Down Expand Up @@ -441,6 +457,8 @@ function AppContent() {
focusMode,
view,
setInterfaceZoom,
goBack,
goForward,
]);

const handleClosePalette = useCallback(() => {
Expand Down
26 changes: 26 additions & 0 deletions src/components/editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ import {
MarkdownIcon,
MarkdownOffIcon,
FolderPlusIcon,
ArrowLeftIcon,
ArrowRightIcon,
} from "../icons";

function formatDateTime(timestamp: number): string {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2183,6 +2189,26 @@ export function Editor({
<PanelLeftIcon className="w-4.5 h-4.5 stroke-[1.5]" />
</IconButton>
)}
{goBack && (
<IconButton
onClick={goBack}
title={`Navigate back (${mod}${isMac ? "" : "+"}[)`}
className="shrink-0"
disabled={!canGoBack}
>
<ArrowLeftIcon className="w-4.5 h-4.5 stroke-[1.5]" />
</IconButton>
)}
{goForward && (
<IconButton
onClick={goForward}
title={`Navigate forward (${mod}${isMac ? "" : "+"}])`}
className="shrink-0"
disabled={!canGoForward}
>
<ArrowRightIcon className="w-4.5 h-4.5 stroke-[1.5]" />
</IconButton>
)}
<span className="text-xs text-text-muted mb-px truncate">
{formatDateTime(currentNote.modified)}
</span>
Expand Down
125 changes: 122 additions & 3 deletions src/context/NotesContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ interface NotesDataContextValue {
isSearching: boolean;
hasExternalChanges: boolean;
reloadVersion: number;
canGoBack: boolean;
canGoForward: boolean;
}

// Actions context: stable references, rarely causes re-renders
Expand All @@ -51,11 +53,37 @@ interface NotesActionsContextValue {
renameFolder: (oldPath: string, newName: string) => Promise<void>;
moveNote: (id: string, targetFolder: string) => Promise<void>;
moveFolder: (path: string, targetParent: string) => Promise<void>;
goBack: () => Promise<void>;
goForward: () => Promise<void>;
}

const NotesDataContext = createContext<NotesDataContextValue | null>(null);
const NotesActionsContext = createContext<NotesActionsContextValue | null>(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<NoteMetadata[]>([]);
const [selectedNoteId, setSelectedNoteId] = useState<string | null>(null);
Expand Down Expand Up @@ -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<string | null>(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;
Expand All @@ -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(
Expand All @@ -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) => {
const ok = await loadNoteById(id);
if (ok) setNav((prev) => navPush(prev, id));
},
[loadNoteById],
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 {
Expand Down Expand Up @@ -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([]);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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");
Expand All @@ -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);
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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");
Expand Down Expand Up @@ -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");
Expand All @@ -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) {
Expand All @@ -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();
Expand Down Expand Up @@ -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<NotesDataContextValue>(
() => ({
notes,
Expand All @@ -694,6 +805,8 @@ export function NotesProvider({ children }: { children: ReactNode }) {
isSearching,
hasExternalChanges,
reloadVersion,
canGoBack,
canGoForward,
}),
[
notes,
Expand All @@ -707,6 +820,8 @@ export function NotesProvider({ children }: { children: ReactNode }) {
isSearching,
hasExternalChanges,
reloadVersion,
canGoBack,
canGoForward,
]
);

Expand All @@ -733,6 +848,8 @@ export function NotesProvider({ children }: { children: ReactNode }) {
renameFolder: renameFolderAction,
moveNote: moveNoteAction,
moveFolder: moveFolderAction,
goBack,
goForward,
}),
[
selectNote,
Expand All @@ -755,6 +872,8 @@ export function NotesProvider({ children }: { children: ReactNode }) {
renameFolderAction,
moveNoteAction,
moveFolderAction,
goBack,
goForward,
]
);

Expand Down
2 changes: 2 additions & 0 deletions src/lib/shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export const shortcutCategories: ShortcutCategory[] = [
{ keys: [mod, "="], description: "Zoom in" },
{ keys: [mod, "-"], description: "Zoom out" },
{ keys: [mod, "0"], description: "Reset zoom" },
{ keys: [mod, "["], description: "Navigate back" },
{ keys: [mod, "]"], description: "Navigate forward" },
],
},
{
Expand Down