Remember Markdown TOC state while tabs are open (#6476)

* Keep markdown TOC open per document

* Clean up Markdown visibility state for replaced previews
This commit is contained in:
andrewroxby 2026-07-03 18:23:28 -07:00 committed by GitHub
parent 748bced2e6
commit f8591d6991
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 269 additions and 47 deletions

View File

@ -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 (
<EditorPanelShell
@ -338,7 +341,7 @@ function EditorPanelInner({
activeViewStateId={activeViewStateId}
model={model}
copiedPathVisible={copiedPathToast?.fileId === activeFile.id}
showMarkdownTableOfContents={showMarkdownTableOfContents}
showMarkdownTableOfContents={isMarkdownTableOfContentsVisible}
canShowMarkdownFrontmatterToggle={canShowMarkdownFrontmatterToggle}
markdownFrontmatterVisible={isMarkdownFrontmatterVisible}
sideBySide={sideBySide}
@ -357,13 +360,15 @@ function EditorPanelInner({
onOpenContainingFolder={handleOpenContainingFolder}
onToggleSideBySide={() => 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}

View File

@ -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()

View File

@ -401,7 +401,12 @@ export type EditorSlice = {
markdownFrontmatterVisible: Record<string, boolean>
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<string, boolean>
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<OpenFile, 'id' | 'markdownPreviewSourceFileId'>,
nextFileId: string
): Pick<EditorSlice, 'editorDrafts' | 'editorCursorLine' | 'markdownViewMode' | 'editorViewMode'> {
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<string, boolean>,
keysToRemove: readonly string[]
): Record<string, boolean> {
let next: Record<string, boolean> | 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<string, Tab>): Tab | null {
return group.activeTabId ? (tabsById.get(group.activeTabId) ?? null) : null
}
@ -1331,7 +1387,27 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (s
markdownViewMode: nextMarkdownViewMode,
editorViewMode: nextEditorViewMode,
markdownFrontmatterVisible: nextMarkdownFrontmatterVisible,
markdownTableOfContentsVisible: nextMarkdownTableOfContentsVisible,
recentlyClosedEditorTabsByWorktree: nextRecentlyClosed,
...previewTabBarUpdate,
...activeResult
@ -1962,25 +2041,22 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (s
markdownViewMode: newMarkdownViewMode,
editorViewMode: newEditorViewMode,
markdownFrontmatterVisible: newMarkdownFrontmatterVisible,
markdownTableOfContentsVisible: newMarkdownTableOfContentsVisible,
tabBarOrderByWorktree: nextTabBarOrderByWorktree,
pendingEditorReveal: null,
recentlyClosedEditorTabsByWorktree: nextRecentlyClosed
@ -2208,6 +2285,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
markdownViewMode: {},
editorViewMode: {},
markdownFrontmatterVisible: {},
markdownTableOfContentsVisible: {},
pendingEditorReveal: null
}
}
@ -2228,6 +2306,11 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (s
markdownViewMode: newMarkdownViewMode,
editorViewMode: newEditorViewMode,
markdownFrontmatterVisible: newMarkdownFrontmatterVisible,
markdownTableOfContentsVisible: newMarkdownTableOfContentsVisible,
activeFileIdByWorktree: newActiveFileIdByWorktree,
activeTabTypeByWorktree: newActiveTabTypeByWorktree,
tabBarOrderByWorktree: nextTabBarOrderByWorktree,
@ -2489,7 +2573,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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 },