From f8591d699174ebee617e6deae5ae0603c31f89d7 Mon Sep 17 00:00:00 2001 From: andrewroxby <130508986+andrewroxby@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:23:28 -0700 Subject: [PATCH] Remember Markdown TOC state while tabs are open (#6476) * Keep markdown TOC open per document * Clean up Markdown visibility state for replaced previews --- .../src/components/editor/EditorPanel.tsx | 29 ++-- src/renderer/src/store/slices/editor.test.ts | 131 +++++++++++++++ src/renderer/src/store/slices/editor.ts | 156 ++++++++++++++---- 3 files changed, 269 insertions(+), 47 deletions(-) diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index eff721e6c..8af1758a1 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -45,6 +45,8 @@ function EditorPanelInner({ const openMarkdownPreview = useAppStore((s) => s.openMarkdownPreview) const markdownFrontmatterVisible = useAppStore((s) => s.markdownFrontmatterVisible) const setMarkdownFrontmatterVisible = useAppStore((s) => s.setMarkdownFrontmatterVisible) + const markdownTableOfContentsVisible = useAppStore((s) => s.markdownTableOfContentsVisible) + const setMarkdownTableOfContentsVisible = useAppStore((s) => s.setMarkdownTableOfContentsVisible) const closeFile = useAppStore((s) => s.closeFile) const clearUntitled = useAppStore((s) => s.clearUntitled) const editorDrafts = useAppStore((s) => s.editorDrafts) @@ -75,7 +77,6 @@ function EditorPanelInner({ }, [clearCopiedPathToastResetTimer] ) - const [showMarkdownTableOfContents, setShowMarkdownTableOfContents] = useState(false) const [sideBySide, setSideBySide] = useState(settings?.diffDefaultView === 'side-by-side') const [prevDiffView, setPrevDiffView] = useState(settings?.diffDefaultView) @@ -310,14 +311,14 @@ function EditorPanelInner({ )?.activeRuntimeEnvironmentId?.trim() || (renameDialogFile ? getConnectionId(renameDialogFile.worktreeId) : null) ) - const markdownFrontmatterSourceFileId = + const markdownDocumentStateFileId = activeFile.mode === 'markdown-preview' ? (activeFile.markdownPreviewSourceFileId ?? activeFile.filePath) : activeFile.id let activeMarkdownContent: string | null = null if (activeFile.mode === 'markdown-preview') { activeMarkdownContent = - editorDrafts[markdownFrontmatterSourceFileId] ?? fileContents[activeFile.id]?.content ?? null + editorDrafts[markdownDocumentStateFileId] ?? fileContents[activeFile.id]?.content ?? null } else if (activeFile.mode === 'edit') { activeMarkdownContent = editorDrafts[activeFile.id] ?? fileContents[activeFile.id]?.content ?? null @@ -329,7 +330,9 @@ function EditorPanelInner({ extractFrontMatter(activeMarkdownContent) ) const isMarkdownFrontmatterVisible = - markdownFrontmatterVisible[markdownFrontmatterSourceFileId] ?? false + markdownFrontmatterVisible[markdownDocumentStateFileId] ?? false + const isMarkdownTableOfContentsVisible = + markdownTableOfContentsVisible[markdownDocumentStateFileId] ?? false return ( setSideBySide((prev) => !prev)} onEditorToggleChange={handleEditorToggleChange} - onToggleMarkdownTableOfContents={() => setShowMarkdownTableOfContents((shown) => !shown)} - onToggleMarkdownFrontmatter={() => - setMarkdownFrontmatterVisible( - markdownFrontmatterSourceFileId, - !isMarkdownFrontmatterVisible + onToggleMarkdownTableOfContents={() => + setMarkdownTableOfContentsVisible( + markdownDocumentStateFileId, + !isMarkdownTableOfContentsVisible ) } + onToggleMarkdownFrontmatter={() => + setMarkdownFrontmatterVisible(markdownDocumentStateFileId, !isMarkdownFrontmatterVisible) + } onExportMarkdownToPdf={() => void exportActiveMarkdownToPdf({ fileId: activeFile.id, root: panelRef.current }) } @@ -373,7 +378,9 @@ function EditorPanelInner({ onSave={handleSave} onSaveForFile={handleSaveForFile} onReloadFileContent={reloadFileContent} - onCloseMarkdownTableOfContents={() => setShowMarkdownTableOfContents(false)} + onCloseMarkdownTableOfContents={() => + setMarkdownTableOfContentsVisible(markdownDocumentStateFileId, false) + } onCloseRenameDialog={closeRenameDialog} onRenameConfirm={handleRenameConfirm} markdownAnnotationsEnabled={markdownAnnotationsEnabled} diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index 0b570c41f..4656c7658 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -1459,6 +1459,64 @@ describe('createEditorSlice markdown view state', () => { }) ]) }) + + it('drops markdown visibility for a preview replaced by a diff', () => { + const store = createEditorStore() + + store.getState().openFile( + { + filePath: '/repo/docs/README.md', + relativePath: 'docs/README.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit' + }, + { preview: true } + ) + store.getState().setMarkdownFrontmatterVisible('/repo/docs/README.md', true) + store.getState().setMarkdownTableOfContentsVisible('/repo/docs/README.md', true) + + store.getState().openDiff('wt-1', '/repo/docs/guide.md', 'docs/guide.md', 'markdown', false, { + preview: true + }) + + expect(store.getState().markdownFrontmatterVisible).toEqual({}) + expect(store.getState().markdownTableOfContentsVisible).toEqual({}) + }) + + it('keeps markdown visibility when another preview still references a replaced source', () => { + const store = createEditorStore() + + store.getState().openFile( + { + filePath: '/repo/docs/README.md', + relativePath: 'docs/README.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit' + }, + { preview: true } + ) + store.getState().openMarkdownPreview({ + filePath: '/repo/docs/README.md', + relativePath: 'docs/README.md', + worktreeId: 'wt-1', + language: 'markdown' + }) + store.getState().setMarkdownFrontmatterVisible('/repo/docs/README.md', true) + store.getState().setMarkdownTableOfContentsVisible('/repo/docs/README.md', true) + + store.getState().openDiff('wt-1', '/repo/docs/guide.md', 'docs/guide.md', 'markdown', false, { + preview: true + }) + + expect(store.getState().markdownFrontmatterVisible).toEqual({ + '/repo/docs/README.md': true + }) + expect(store.getState().markdownTableOfContentsVisible).toEqual({ + '/repo/docs/README.md': true + }) + }) }) describe('createEditorSlice editor view mode', () => { @@ -1628,6 +1686,79 @@ describe('createEditorSlice markdown frontmatter visibility (#4468)', () => { }) }) +describe('createEditorSlice markdown table of contents visibility', () => { + it('stores visible=true as an explicit entry keyed by fileId', () => { + const store = createEditorStore() + + store.getState().setMarkdownTableOfContentsVisible('/repo/notes.md', true) + + expect(store.getState().markdownTableOfContentsVisible).toEqual({ '/repo/notes.md': true }) + }) + + it('deletes the entry when visibility resets to hidden', () => { + const store = createEditorStore() + store.getState().setMarkdownTableOfContentsVisible('/repo/notes.md', true) + + store.getState().setMarkdownTableOfContentsVisible('/repo/notes.md', false) + + expect(store.getState().markdownTableOfContentsVisible).toEqual({}) + }) + + it('drops the visibility flag when replacing a preview tab', () => { + const store = createEditorStore() + store.getState().openFile( + { + filePath: '/repo/notes.md', + relativePath: 'notes.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit' + }, + { preview: true } + ) + store.getState().setMarkdownTableOfContentsVisible('/repo/notes.md', true) + + store.getState().openFile( + { + filePath: '/repo/guide.md', + relativePath: 'guide.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit' + }, + { preview: true } + ) + + expect(store.getState().markdownTableOfContentsVisible).toEqual({}) + }) + + it('keeps the visibility flag while a preview tab still references the source file', () => { + const store = createEditorStore() + store.getState().openFile({ + filePath: '/repo/notes.md', + relativePath: 'notes.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit' + }) + store.getState().openMarkdownPreview({ + filePath: '/repo/notes.md', + relativePath: 'notes.md', + worktreeId: 'wt-1', + language: 'markdown' + }) + store.getState().setMarkdownTableOfContentsVisible('/repo/notes.md', true) + + store.getState().closeFile('/repo/notes.md') + + expect(store.getState().markdownTableOfContentsVisible).toEqual({ '/repo/notes.md': true }) + + store.getState().closeFile('markdown-preview::/repo/notes.md') + + expect(store.getState().markdownTableOfContentsVisible).toEqual({}) + }) +}) + describe('createEditorSlice openMarkdownPreview', () => { it('opens markdown preview as a separate read-only tab', () => { const store = createEditorStore() diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 54013b30a..5ce2b4ef8 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -401,7 +401,12 @@ export type EditorSlice = { markdownFrontmatterVisible: Record setMarkdownFrontmatterVisible: (fileId: string, visible: boolean) => void - // Markdown table of contents + // Per-file opt-in to keep the markdown table of contents open. Default is + // hidden; absent entry means hidden. + markdownTableOfContentsVisible: Record + setMarkdownTableOfContentsVisible: (fileId: string, visible: boolean) => void + + // Markdown table of contents panel sizing markdownTocPanelWidth: number setMarkdownTocPanelWidth: (width: number) => void @@ -792,35 +797,86 @@ function getReplaceablePreviewFileId( function removeEditorStateForReplacedPreview( state: Pick< EditorSlice, - 'editorDrafts' | 'editorCursorLine' | 'markdownViewMode' | 'editorViewMode' + | 'editorDrafts' + | 'editorCursorLine' + | 'markdownViewMode' + | 'editorViewMode' + | 'markdownFrontmatterVisible' + | 'markdownTableOfContentsVisible' + | 'openFiles' >, - replacedFileId: string, + replacedFile: Pick, nextFileId: string -): Pick { - if (replacedFileId === nextFileId) { +): Pick< + EditorSlice, + | 'editorDrafts' + | 'editorCursorLine' + | 'markdownViewMode' + | 'editorViewMode' + | 'markdownFrontmatterVisible' + | 'markdownTableOfContentsVisible' +> { + const visibilityKeys = [ + replacedFile.id, + ...(replacedFile.markdownPreviewSourceFileId ? [replacedFile.markdownPreviewSourceFileId] : []) + ].filter( + (key) => + key !== nextFileId && + !state.openFiles.some( + (file) => + file.id !== replacedFile.id && + (file.id === key || file.markdownPreviewSourceFileId === key) + ) + ) + if (replacedFile.id === nextFileId) { return { editorDrafts: state.editorDrafts, editorCursorLine: state.editorCursorLine, markdownViewMode: state.markdownViewMode, - editorViewMode: state.editorViewMode + editorViewMode: state.editorViewMode, + markdownFrontmatterVisible: state.markdownFrontmatterVisible, + markdownTableOfContentsVisible: state.markdownTableOfContentsVisible } } return { editorDrafts: Object.fromEntries( - Object.entries(state.editorDrafts).filter(([fileId]) => fileId !== replacedFileId) + Object.entries(state.editorDrafts).filter(([fileId]) => fileId !== replacedFile.id) ), editorCursorLine: Object.fromEntries( - Object.entries(state.editorCursorLine).filter(([fileId]) => fileId !== replacedFileId) + Object.entries(state.editorCursorLine).filter(([fileId]) => fileId !== replacedFile.id) ), markdownViewMode: Object.fromEntries( - Object.entries(state.markdownViewMode).filter(([fileId]) => fileId !== replacedFileId) + Object.entries(state.markdownViewMode).filter(([fileId]) => fileId !== replacedFile.id) ), editorViewMode: Object.fromEntries( - Object.entries(state.editorViewMode).filter(([fileId]) => fileId !== replacedFileId) + Object.entries(state.editorViewMode).filter(([fileId]) => fileId !== replacedFile.id) + ), + markdownFrontmatterVisible: removeMarkdownVisibilityKeys( + state.markdownFrontmatterVisible, + visibilityKeys + ), + markdownTableOfContentsVisible: removeMarkdownVisibilityKeys( + state.markdownTableOfContentsVisible, + visibilityKeys ) } } +function removeMarkdownVisibilityKeys( + visibility: Record, + keysToRemove: readonly string[] +): Record { + let next: Record | null = null + for (const key of keysToRemove) { + if (!(key in visibility)) { + continue + } + next ??= { ...visibility } + delete next[key] + } + return next ?? visibility +} + function getGroupActiveTab(group: TabGroup, tabsById: Map): Tab | null { return group.activeTabId ? (tabsById.get(group.activeTabId) ?? null) : null } @@ -1331,7 +1387,27 @@ export const createEditorSlice: StateCreator = (s return { markdownFrontmatterVisible: { ...s.markdownFrontmatterVisible, [fileId]: true } } }), - // Markdown table of contents + // Markdown table of contents visibility + markdownTableOfContentsVisible: {}, + setMarkdownTableOfContentsVisible: (fileId, visible) => + set((s) => { + if (!visible) { + if (!(fileId in s.markdownTableOfContentsVisible)) { + return s + } + const next = { ...s.markdownTableOfContentsVisible } + delete next[fileId] + return { markdownTableOfContentsVisible: next } + } + return { + markdownTableOfContentsVisible: { + ...s.markdownTableOfContentsVisible, + [fileId]: true + } + } + }), + + // Markdown table of contents panel sizing markdownTocPanelWidth: 240, setMarkdownTocPanelWidth: (width) => set((s) => ({ @@ -1645,13 +1721,12 @@ export const createEditorSlice: StateCreator = (s ([fileId]) => fileId !== replacedPreview.id ) ) - const frontmatterVisibilityKeys = new Set([replacedPreview.id]) + const markdownVisibilityKeys = new Set([replacedPreview.id]) if (replacedPreview.markdownPreviewSourceFileId) { - frontmatterVisibilityKeys.add(replacedPreview.markdownPreviewSourceFileId) + markdownVisibilityKeys.add(replacedPreview.markdownPreviewSourceFileId) } - const frontmatterKeysToRemove = [...frontmatterVisibilityKeys].filter( + const visibilityKeysToRemove = [...markdownVisibilityKeys].filter( (key) => - key in s.markdownFrontmatterVisible && !s.openFiles.some( (file, index) => index !== existingPreviewIdx && @@ -1659,12 +1734,15 @@ export const createEditorSlice: StateCreator = (s ) ) const nextMarkdownFrontmatterVisible = - replacedPreview.id === id || frontmatterKeysToRemove.length === 0 + replacedPreview.id === id || visibilityKeysToRemove.length === 0 ? s.markdownFrontmatterVisible - : Object.fromEntries( - Object.entries(s.markdownFrontmatterVisible).filter( - ([fileId]) => !frontmatterKeysToRemove.includes(fileId) - ) + : removeMarkdownVisibilityKeys(s.markdownFrontmatterVisible, visibilityKeysToRemove) + const nextMarkdownTableOfContentsVisible = + replacedPreview.id === id || visibilityKeysToRemove.length === 0 + ? s.markdownTableOfContentsVisible + : removeMarkdownVisibilityKeys( + s.markdownTableOfContentsVisible, + visibilityKeysToRemove ) // Why: editorCursorLine entries accumulate per file; clean up the // evicted preview's entry so it does not leak across tab replacements. @@ -1721,6 +1799,7 @@ export const createEditorSlice: StateCreator = (s markdownViewMode: nextMarkdownViewMode, editorViewMode: nextEditorViewMode, markdownFrontmatterVisible: nextMarkdownFrontmatterVisible, + markdownTableOfContentsVisible: nextMarkdownTableOfContentsVisible, recentlyClosedEditorTabsByWorktree: nextRecentlyClosed, ...previewTabBarUpdate, ...activeResult @@ -1962,25 +2041,22 @@ export const createEditorSlice: StateCreator = (s delete newMarkdownViewMode[fileId] const newEditorViewMode = { ...s.editorViewMode } delete newEditorViewMode[fileId] - const frontmatterVisibilityKeys = new Set([fileId]) + const markdownVisibilityKeys = new Set([fileId]) if (closedFile?.markdownPreviewSourceFileId) { - frontmatterVisibilityKeys.add(closedFile.markdownPreviewSourceFileId) + markdownVisibilityKeys.add(closedFile.markdownPreviewSourceFileId) } - const keysToRemove = [...frontmatterVisibilityKeys].filter( + const visibilityKeysToRemove = [...markdownVisibilityKeys].filter( (key) => - key in s.markdownFrontmatterVisible && !newFiles.some((file) => file.id === key || file.markdownPreviewSourceFileId === key) ) const newMarkdownFrontmatterVisible = - keysToRemove.length > 0 - ? (() => { - const next = { ...s.markdownFrontmatterVisible } - for (const key of keysToRemove) { - delete next[key] - } - return next - })() + visibilityKeysToRemove.length > 0 + ? removeMarkdownVisibilityKeys(s.markdownFrontmatterVisible, visibilityKeysToRemove) : s.markdownFrontmatterVisible + const newMarkdownTableOfContentsVisible = + visibilityKeysToRemove.length > 0 + ? removeMarkdownVisibilityKeys(s.markdownTableOfContentsVisible, visibilityKeysToRemove) + : s.markdownTableOfContentsVisible // Why: editorCursorLine entries are keyed by fileId and accumulate on // every cursor move. Without cleanup they grow without bound across a // long session as files are opened and closed. @@ -2112,6 +2188,7 @@ export const createEditorSlice: StateCreator = (s markdownViewMode: newMarkdownViewMode, editorViewMode: newEditorViewMode, markdownFrontmatterVisible: newMarkdownFrontmatterVisible, + markdownTableOfContentsVisible: newMarkdownTableOfContentsVisible, tabBarOrderByWorktree: nextTabBarOrderByWorktree, pendingEditorReveal: null, recentlyClosedEditorTabsByWorktree: nextRecentlyClosed @@ -2208,6 +2285,7 @@ export const createEditorSlice: StateCreator = (s markdownViewMode: {}, editorViewMode: {}, markdownFrontmatterVisible: {}, + markdownTableOfContentsVisible: {}, pendingEditorReveal: null } } @@ -2228,6 +2306,11 @@ export const createEditorSlice: StateCreator = (s remainingFileIds.has(fileId) ) ) + const newMarkdownTableOfContentsVisible = Object.fromEntries( + Object.entries(s.markdownTableOfContentsVisible).filter(([fileId]) => + remainingFileIds.has(fileId) + ) + ) const newEditorCursorLine = Object.fromEntries( Object.entries(s.editorCursorLine).filter(([fileId]) => remainingFileIds.has(fileId)) ) @@ -2295,6 +2378,7 @@ export const createEditorSlice: StateCreator = (s markdownViewMode: newMarkdownViewMode, editorViewMode: newEditorViewMode, markdownFrontmatterVisible: newMarkdownFrontmatterVisible, + markdownTableOfContentsVisible: newMarkdownTableOfContentsVisible, activeFileIdByWorktree: newActiveFileIdByWorktree, activeTabTypeByWorktree: newActiveTabTypeByWorktree, tabBarOrderByWorktree: nextTabBarOrderByWorktree, @@ -2489,7 +2573,7 @@ export const createEditorSlice: StateCreator = (s openFiles: s.openFiles.map((file, index) => index === replaceablePreviewIndex ? newFile : file ), - ...removeEditorStateForReplacedPreview(s, s.openFiles[replaceablePreviewIndex].id, id), + ...removeEditorStateForReplacedPreview(s, s.openFiles[replaceablePreviewIndex], id), activeFileId: id, activeTabType: 'editor', activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id }, @@ -2580,7 +2664,7 @@ export const createEditorSlice: StateCreator = (s openFiles: s.openFiles.map((file, index) => index === replaceablePreviewIndex ? newFile : file ), - ...removeEditorStateForReplacedPreview(s, s.openFiles[replaceablePreviewIndex].id, id), + ...removeEditorStateForReplacedPreview(s, s.openFiles[replaceablePreviewIndex], id), activeFileId: id, activeTabType: 'editor', activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id }, @@ -2671,7 +2755,7 @@ export const createEditorSlice: StateCreator = (s openFiles: s.openFiles.map((file, index) => index === replaceablePreviewIndex ? newFile : file ), - ...removeEditorStateForReplacedPreview(s, s.openFiles[replaceablePreviewIndex].id, id), + ...removeEditorStateForReplacedPreview(s, s.openFiles[replaceablePreviewIndex], id), activeFileId: id, activeTabType: 'editor', activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id }, @@ -2872,7 +2956,7 @@ export const createEditorSlice: StateCreator = (s openFiles: s.openFiles.map((file, index) => index === replaceablePreviewIndex ? newFile : file ), - ...removeEditorStateForReplacedPreview(s, s.openFiles[replaceablePreviewIndex].id, id), + ...removeEditorStateForReplacedPreview(s, s.openFiles[replaceablePreviewIndex], id), activeFileId: id, activeTabType: 'editor', activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },