diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index 45def20fc..bad4adb0b 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -108,6 +108,7 @@ export function EditorContent({ isChangesMode, sideBySide, showMarkdownTableOfContents = false, + showMarkdownFrontmatter = false, onCloseMarkdownTableOfContents = noopCloseMarkdownTableOfContents, markdownAnnotationsEnabled = true, pendingEditorReveal, @@ -134,6 +135,7 @@ export function EditorContent({ isChangesMode: boolean sideBySide: boolean showMarkdownTableOfContents?: boolean + showMarkdownFrontmatter?: boolean onCloseMarkdownTableOfContents?: () => void markdownAnnotationsEnabled?: boolean pendingEditorReveal: PendingEditorReveal | null @@ -397,7 +399,9 @@ export function EditorContent({ // (inside the editor shell) so formatting controls remain at // the top of the pane — the banner is read-only context, not // a header above the toolbar. - headerSlot={fm ? : null} + headerSlot={ + fm && showMarkdownFrontmatter ? : null + } /> diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index 90ac0ba8c..0990cc3b7 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -18,6 +18,7 @@ import { useEditorCmdSaveRequest } from './useEditorCmdSaveRequest' import { useEditorPanelContentState } from './useEditorPanelContentState' import { useMarkdownPreviewShortcut } from './useMarkdownPreviewShortcut' import { useUntitledFileRename } from './useUntitledFileRename' +import { extractFrontMatter } from './markdown-frontmatter' function EditorPanelInner({ activeFileId: activeFileIdProp, @@ -43,6 +44,8 @@ function EditorPanelInner({ const setEditorViewMode = useAppStore((s) => s.setEditorViewMode) const openFile = useAppStore((s) => s.openFile) const openMarkdownPreview = useAppStore((s) => s.openMarkdownPreview) + const markdownFrontmatterVisible = useAppStore((s) => s.markdownFrontmatterVisible) + const setMarkdownFrontmatterVisible = useAppStore((s) => s.setMarkdownFrontmatterVisible) const closeFile = useAppStore((s) => s.closeFile) const clearUntitled = useAppStore((s) => s.clearUntitled) const editorDrafts = useAppStore((s) => s.editorDrafts) @@ -304,6 +307,26 @@ function EditorPanelInner({ )?.activeRuntimeEnvironmentId?.trim() || (renameDialogFile ? getConnectionId(renameDialogFile.worktreeId) : null) ) + const markdownFrontmatterSourceFileId = + 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 + } else if (activeFile.mode === 'edit') { + activeMarkdownContent = + editorDrafts[activeFile.id] ?? fileContents[activeFile.id]?.content ?? null + } + const canShowMarkdownFrontmatterToggle = Boolean( + model.isMarkdown && + (activeFile.mode === 'markdown-preview' || model.mdViewMode !== 'source') && + activeMarkdownContent && + extractFrontMatter(activeMarkdownContent) + ) + const isMarkdownFrontmatterVisible = + markdownFrontmatterVisible[markdownFrontmatterSourceFileId] ?? false return ( setSideBySide((prev) => !prev)} onEditorToggleChange={handleEditorToggleChange} onToggleMarkdownTableOfContents={() => setShowMarkdownTableOfContents((shown) => !shown)} + onToggleMarkdownFrontmatter={() => + setMarkdownFrontmatterVisible( + markdownFrontmatterSourceFileId, + !isMarkdownFrontmatterVisible + ) + } onExportMarkdownToPdf={() => void exportActiveMarkdownToPdf()} onContentChange={handleContentChange} onContentChangeForFile={handleContentChangeForFile} diff --git a/src/renderer/src/components/editor/EditorPanelHeader.tsx b/src/renderer/src/components/editor/EditorPanelHeader.tsx index 1df03df78..f0ce094c1 100644 --- a/src/renderer/src/components/editor/EditorPanelHeader.tsx +++ b/src/renderer/src/components/editor/EditorPanelHeader.tsx @@ -1,15 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { - Columns2, - Copy, - Eye, - ExternalLink, - FileText, - ListTree, - MoreHorizontal, - Pencil, - Rows2 -} from 'lucide-react' +import { Columns2, Copy, Eye, ExternalLink, FileText, ListTree, Pencil, Rows2 } from 'lucide-react' import { useAppStore } from '@/store' import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor' import { @@ -33,6 +23,7 @@ import type { EditorHeaderOpenFileState } from './editor-header' import { getEditorHeaderCopyState } from './editor-header' import { DiffNotesSendMenu } from './DiffNotesSendMenu' import { useEditorHeaderFileRename } from './editor-header-file-rename' +import { EditorPanelMarkdownActionsMenu } from './EditorPanelMarkdownActionsMenu' const isMac = navigator.userAgent.includes('Mac') const isLinux = navigator.userAgent.includes('Linux') @@ -62,6 +53,8 @@ type EditorPanelHeaderProps = { canShowMarkdownTableOfContents: boolean isMarkdownTableOfContentsDisabled: boolean showMarkdownTableOfContents: boolean + canShowMarkdownFrontmatterToggle: boolean + markdownFrontmatterVisible: boolean sideBySide: boolean openFileState: EditorHeaderOpenFileState onCopyPath: () => void @@ -72,6 +65,7 @@ type EditorPanelHeaderProps = { onToggleSideBySide: () => void onEditorToggleChange: (next: EditorToggleValue) => void onToggleMarkdownTableOfContents: () => void + onToggleMarkdownFrontmatter: () => void onExportMarkdownToPdf: () => void } @@ -93,6 +87,8 @@ export function EditorPanelHeader({ canShowMarkdownTableOfContents, isMarkdownTableOfContentsDisabled, showMarkdownTableOfContents, + canShowMarkdownFrontmatterToggle, + markdownFrontmatterVisible, sideBySide, openFileState, onCopyPath, @@ -103,6 +99,7 @@ export function EditorPanelHeader({ onToggleSideBySide, onEditorToggleChange, onToggleMarkdownTableOfContents, + onToggleMarkdownFrontmatter, onExportMarkdownToPdf }: EditorPanelHeaderProps): React.JSX.Element { const [pathMenuOpen, setPathMenuOpen] = useState(false) @@ -363,35 +360,15 @@ export function EditorPanelHeader({ )} - {hasViewModeToggle && isMarkdown && ( - - - - - - - Export as PDF - - - - )} + ) } diff --git a/src/renderer/src/components/editor/EditorPanelMarkdownActionsMenu.tsx b/src/renderer/src/components/editor/EditorPanelMarkdownActionsMenu.tsx new file mode 100644 index 000000000..4818aca18 --- /dev/null +++ b/src/renderer/src/components/editor/EditorPanelMarkdownActionsMenu.tsx @@ -0,0 +1,75 @@ +import type React from 'react' +import { MoreHorizontal } from 'lucide-react' +import type { MarkdownViewMode } from '@/store/slices/editor' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' + +type EditorPanelMarkdownActionsMenuProps = { + isMarkdown: boolean + hasViewModeToggle: boolean + mdViewMode: MarkdownViewMode + canShowMarkdownFrontmatterToggle: boolean + markdownFrontmatterVisible: boolean + onToggleMarkdownFrontmatter: () => void + onExportMarkdownToPdf: () => void +} + +export function EditorPanelMarkdownActionsMenu({ + isMarkdown, + hasViewModeToggle, + mdViewMode, + canShowMarkdownFrontmatterToggle, + markdownFrontmatterVisible, + onToggleMarkdownFrontmatter, + onExportMarkdownToPdf +}: EditorPanelMarkdownActionsMenuProps): React.JSX.Element | null { + if (!isMarkdown || (!hasViewModeToggle && !canShowMarkdownFrontmatterToggle)) { + return null + } + + return ( + + + + + + {canShowMarkdownFrontmatterToggle ? ( + <> + { + event.preventDefault() + onToggleMarkdownFrontmatter() + }} + > + {markdownFrontmatterVisible ? 'Hide front matter' : 'Show front matter'} + + {hasViewModeToggle ? : null} + + ) : null} + {hasViewModeToggle ? ( + + Export as PDF + + ) : null} + + + ) +} diff --git a/src/renderer/src/components/editor/EditorPanelShell.tsx b/src/renderer/src/components/editor/EditorPanelShell.tsx index f1089dcb5..58516d6f9 100644 --- a/src/renderer/src/components/editor/EditorPanelShell.tsx +++ b/src/renderer/src/components/editor/EditorPanelShell.tsx @@ -19,6 +19,8 @@ type EditorPanelShellProps = { model: EditorPanelRenderModel copiedPathVisible: boolean showMarkdownTableOfContents: boolean + canShowMarkdownFrontmatterToggle: boolean + markdownFrontmatterVisible: boolean sideBySide: boolean openFiles: OpenFile[] fileContents: Record @@ -36,6 +38,7 @@ type EditorPanelShellProps = { onToggleSideBySide: () => void onEditorToggleChange: (next: EditorToggleValue) => void onToggleMarkdownTableOfContents: () => void + onToggleMarkdownFrontmatter: () => void onExportMarkdownToPdf: () => void onContentChange: (content: string) => void onContentChangeForFile: (file: OpenFile, content: string) => void @@ -56,6 +59,8 @@ export function EditorPanelShell({ model, copiedPathVisible, showMarkdownTableOfContents, + canShowMarkdownFrontmatterToggle, + markdownFrontmatterVisible, sideBySide, openFiles, fileContents, @@ -73,6 +78,7 @@ export function EditorPanelShell({ onToggleSideBySide, onEditorToggleChange, onToggleMarkdownTableOfContents, + onToggleMarkdownFrontmatter, onExportMarkdownToPdf, onContentChange, onContentChangeForFile, @@ -106,6 +112,8 @@ export function EditorPanelShell({ canShowMarkdownTableOfContents={model.canShowMarkdownTableOfContents} isMarkdownTableOfContentsDisabled={model.isMarkdownTableOfContentsDisabled} showMarkdownTableOfContents={showMarkdownTableOfContents} + canShowMarkdownFrontmatterToggle={canShowMarkdownFrontmatterToggle} + markdownFrontmatterVisible={markdownFrontmatterVisible} sideBySide={sideBySide} openFileState={model.openFileState} onCopyPath={onCopyPath} @@ -116,6 +124,7 @@ export function EditorPanelShell({ onToggleSideBySide={onToggleSideBySide} onEditorToggleChange={onEditorToggleChange} onToggleMarkdownTableOfContents={onToggleMarkdownTableOfContents} + onToggleMarkdownFrontmatter={onToggleMarkdownFrontmatter} onExportMarkdownToPdf={onExportMarkdownToPdf} /> )} @@ -144,6 +153,7 @@ export function EditorPanelShell({ handleSaveForFile={onSaveForFile} reloadFileContent={onReloadFileContent} showMarkdownTableOfContents={showMarkdownTableOfContents} + showMarkdownFrontmatter={markdownFrontmatterVisible} onCloseMarkdownTableOfContents={onCloseMarkdownTableOfContents} markdownAnnotationsEnabled={markdownAnnotationsEnabled} /> diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index d2c9b24e2..caef7bc28 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -466,6 +466,7 @@ export default function MarkdownPreview({ const activateMarkdownLink = useAppStore((s) => s.activateMarkdownLink) const openMarkdownPreview = useAppStore((s) => s.openMarkdownPreview) const setMarkdownViewMode = useAppStore((s) => s.setMarkdownViewMode) + const frontmatterVisibleByFile = useAppStore((s) => s.markdownFrontmatterVisible) const setPendingEditorReveal = useAppStore((s) => s.setPendingEditorReveal) const addDiffComment = useAppStore((s) => s.addDiffComment) const deleteDiffComment = useAppStore((s) => s.deleteDiffComment) @@ -559,6 +560,13 @@ export default function MarkdownPreview({ .replace(/\r?\n(?:---|\+\+\+)\r?\n?$/, '') .trim() }, [frontMatter]) + // Why: front matter is hidden by default (#4468) and controlled from the + // markdown preview actions menu, keeping metadata out of the reading surface + // unless the user explicitly asks for it. + const toggleableSourceFileId: string | null = sourceFileId ?? null + const frontmatterVisible = toggleableSourceFileId + ? (frontmatterVisibleByFile[toggleableSourceFileId] ?? false) + : true const [activeAnnotationBlockKey, setActiveAnnotationBlockKey] = useState(null) const [reviewNotesCopied, setReviewNotesCopied] = useState(false) const [copiedReviewNoteId, setCopiedReviewNoteId] = useState(null) @@ -1737,10 +1745,10 @@ export default function MarkdownPreview({ ) : null}
- {/* Why: remarkFrontmatter silently strips front-matter from rendered - output. We extract it ourselves and render it as a styled code block so - the user can see the metadata in preview mode. */} - {frontMatter && ( + {/* Why: remarkFrontmatter strips front matter from normal markdown + output. When the user opts in from the preview actions menu, render the + raw metadata as a compact read-only block above the document body. */} + {frontMatter && frontmatterVisible ? (
Front Matter @@ -1749,7 +1757,7 @@ export default function MarkdownPreview({ {frontMatterInner}
- )} + ) : null} { const patch = buildWorkspaceSessionPatch(createSnapshot(), ['openFiles']) expect(Object.keys(patch).sort()).toEqual( - ['activeFileIdByWorktree', 'activeTabTypeByWorktree', 'openFilesByWorktree'].sort() + [ + 'activeFileIdByWorktree', + 'activeTabTypeByWorktree', + 'markdownFrontmatterVisible', + 'openFilesByWorktree' + ].sort() ) expect(patch.openFilesByWorktree).toEqual({ 'wt-1': [ @@ -125,7 +133,12 @@ describe('buildWorkspaceSessionPatch', () => { ) expect(Object.keys(patch).sort()).toEqual( - ['activeFileIdByWorktree', 'activeTabTypeByWorktree', 'openFilesByWorktree'].sort() + [ + 'activeFileIdByWorktree', + 'activeTabTypeByWorktree', + 'markdownFrontmatterVisible', + 'openFilesByWorktree' + ].sort() ) expect(patch.openFilesByWorktree?.['wt-1'][0]).toEqual( expect.objectContaining({ @@ -135,6 +148,28 @@ describe('buildWorkspaceSessionPatch', () => { ) }) + it('derives editor session keys when markdown front-matter visibility changes', () => { + const patch = buildWorkspaceSessionPatch( + createSnapshot({ + markdownFrontmatterVisible: { + '/tmp/demo.ts': true, + '/tmp/demo.diff': true + } + }), + ['markdownFrontmatterVisible'] + ) + + expect(Object.keys(patch).sort()).toEqual( + [ + 'activeFileIdByWorktree', + 'activeTabTypeByWorktree', + 'markdownFrontmatterVisible', + 'openFilesByWorktree' + ].sort() + ) + expect(patch.markdownFrontmatterVisible).toEqual({ '/tmp/demo.ts': true }) + }) + it('sanitizes terminal tabs and prunes local buffers when tab topology changes', () => { const localWorktreeId = 'repo-1::/local/worktree' const patch = buildWorkspaceSessionPatch( diff --git a/src/renderer/src/lib/workspace-session-patch.ts b/src/renderer/src/lib/workspace-session-patch.ts index 667a92d64..19f0edad7 100644 --- a/src/renderer/src/lib/workspace-session-patch.ts +++ b/src/renderer/src/lib/workspace-session-patch.ts @@ -79,6 +79,7 @@ export function buildWorkspaceSessionPatch( hasAnyChangedField(changed, [ 'openFiles', 'editorDrafts', + 'markdownFrontmatterVisible', 'activeFileIdByWorktree', 'activeTabTypeByWorktree' ] as const) @@ -88,6 +89,7 @@ export function buildWorkspaceSessionPatch( buildEditorSessionData( snapshot.openFiles, snapshot.editorDrafts, + snapshot.markdownFrontmatterVisible, snapshot.activeFileIdByWorktree, snapshot.activeTabTypeByWorktree ) diff --git a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts index c15b09c91..df9794e4d 100644 --- a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts +++ b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts @@ -14,6 +14,7 @@ describe('SESSION_RELEVANT_FIELDS', () => { activeTabIdByWorktree: true, openFiles: true, editorDrafts: true, + markdownFrontmatterVisible: true, activeFileIdByWorktree: true, activeTabTypeByWorktree: true, browserTabsByWorktree: true, diff --git a/src/renderer/src/lib/workspace-session.test.ts b/src/renderer/src/lib/workspace-session.test.ts index 03710b498..50218f3d6 100644 --- a/src/renderer/src/lib/workspace-session.test.ts +++ b/src/renderer/src/lib/workspace-session.test.ts @@ -21,8 +21,10 @@ function createSnapshot(overrides: Partial = {}): AppState { }, activeTabIdByWorktree: { 'wt-1': 'tab-1', 'wt-2': 'tab-2' }, editorDrafts: {}, + markdownFrontmatterVisible: {}, openFiles: [ { + id: '/tmp/demo.ts', filePath: '/tmp/demo.ts', relativePath: 'demo.ts', worktreeId: 'wt-1', @@ -34,6 +36,7 @@ function createSnapshot(overrides: Partial = {}): AppState { originalContent: '' }, { + id: '/tmp/demo.diff', filePath: '/tmp/demo.diff', relativePath: 'demo.diff', worktreeId: 'wt-1', @@ -173,6 +176,20 @@ describe('buildWorkspaceSessionPayload', () => { expect(payload.browserTabsByWorktree?.['wt-1'][0].loading).toBe(false) }) + it('persists front-matter visibility only for restored editor files', () => { + const payload = buildWorkspaceSessionPayload( + createSnapshot({ + markdownFrontmatterVisible: { + '/tmp/demo.ts': true, + '/tmp/demo.diff': true, + '/tmp/closed.md': true + } + }) + ) + + expect(payload.markdownFrontmatterVisible).toEqual({ '/tmp/demo.ts': true }) + }) + it('drops local terminal scrollback buffers from session payloads', () => { const localWorktreeId = 'repo-1::/local/worktree' const payload = buildWorkspaceSessionPayload( diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index c03a02461..4ac7dbe11 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -38,6 +38,7 @@ export type WorkspaceSessionSnapshot = Pick< | 'activeTabIdByWorktree' | 'openFiles' | 'editorDrafts' + | 'markdownFrontmatterVisible' | 'activeFileIdByWorktree' | 'activeTabTypeByWorktree' | 'browserTabsByWorktree' @@ -72,6 +73,7 @@ export const SESSION_RELEVANT_FIELDS = [ 'activeTabIdByWorktree', 'openFiles', 'editorDrafts', + 'markdownFrontmatterVisible', 'activeFileIdByWorktree', 'activeTabTypeByWorktree', 'browserTabsByWorktree', @@ -102,11 +104,15 @@ void _exhaustive export function buildEditorSessionData( openFiles: OpenFile[], editorDrafts: Record, + markdownFrontmatterVisible: Record, activeFileIdByWorktree: Record, activeTabTypeByWorktree: Record ): Pick< WorkspaceSessionState, - 'openFilesByWorktree' | 'activeFileIdByWorktree' | 'activeTabTypeByWorktree' + | 'openFilesByWorktree' + | 'activeFileIdByWorktree' + | 'activeTabTypeByWorktree' + | 'markdownFrontmatterVisible' > { const editFiles = openFiles.filter((f) => f.mode === 'edit') const byWorktree: Record = {} @@ -160,11 +166,18 @@ export function buildEditorSessionData( string, WorkspaceVisibleTabType > + const allEditFileIds = new Set(Object.values(editFileIdsByWorktree).flatMap((ids) => [...ids])) + const persistedMarkdownFrontmatterVisible = Object.fromEntries( + Object.keys(markdownFrontmatterVisible ?? {}) + .filter((fileId) => allEditFileIds.has(fileId)) + .map((fileId) => [fileId, true]) + ) return { openFilesByWorktree: byWorktree, activeFileIdByWorktree: persistedActiveFileIdByWorktree, - activeTabTypeByWorktree: persistedActiveTabTypeByWorktree + activeTabTypeByWorktree: persistedActiveTabTypeByWorktree, + markdownFrontmatterVisible: persistedMarkdownFrontmatterVisible } } @@ -335,6 +348,7 @@ export function buildWorkspaceSessionPayload( ...buildEditorSessionData( snapshot.openFiles, snapshot.editorDrafts, + snapshot.markdownFrontmatterVisible, snapshot.activeFileIdByWorktree, snapshot.activeTabTypeByWorktree ), diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index d44d9e3b2..da594a2b2 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -895,6 +895,129 @@ describe('createEditorSlice editor view mode', () => { }) }) +describe('createEditorSlice markdown frontmatter visibility (#4468)', () => { + it('stores visible=true as an explicit entry keyed by fileId', () => { + const store = createEditorStore() + + store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', true) + + expect(store.getState().markdownFrontmatterVisible).toEqual({ '/repo/notes.md': true }) + }) + + it('deletes the entry when visibility resets to hidden', () => { + const store = createEditorStore() + store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', true) + + store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', false) + + expect(store.getState().markdownFrontmatterVisible).toEqual({}) + }) + + it('is a no-op when hiding a file that was never shown', () => { + const store = createEditorStore() + const before = store.getState().markdownFrontmatterVisible + + store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', false) + + expect(store.getState().markdownFrontmatterVisible).toBe(before) + }) + + it('drops the visibility flag when the file is closed', () => { + const store = createEditorStore() + store.getState().openFile({ + filePath: '/repo/notes.md', + relativePath: 'notes.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit' + }) + store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', true) + + store.getState().closeFile('/repo/notes.md') + + expect(store.getState().markdownFrontmatterVisible).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().setMarkdownFrontmatterVisible('/repo/notes.md', true) + + store.getState().closeFile('/repo/notes.md') + + expect(store.getState().markdownFrontmatterVisible).toEqual({ '/repo/notes.md': true }) + + store.getState().closeFile('markdown-preview::/repo/notes.md') + + expect(store.getState().markdownFrontmatterVisible).toEqual({}) + }) + + it('keeps the visibility flag when replacing an edit preview referenced by a markdown preview', () => { + const store = createEditorStore() + store.getState().openFile( + { + filePath: '/repo/notes.md', + relativePath: 'notes.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit' + }, + { preview: true } + ) + store.getState().openMarkdownPreview( + { + filePath: '/repo/notes.md', + relativePath: 'notes.md', + worktreeId: 'wt-1', + language: 'markdown' + }, + { sourceFileId: '/repo/notes.md' } + ) + store.getState().setMarkdownFrontmatterVisible('/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().markdownFrontmatterVisible).toEqual({ '/repo/notes.md': true }) + }) + + it('drops the visibility flag when all files are closed', () => { + const store = createEditorStore() + store.getState().openFile({ + filePath: '/repo/notes.md', + relativePath: 'notes.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit' + }) + store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', true) + + store.getState().closeAllFiles() + + expect(store.getState().markdownFrontmatterVisible).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 86c5d81fb..a14e8b223 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -287,6 +287,12 @@ export type EditorSlice = { editorViewMode: Record setEditorViewMode: (fileId: string, mode: EditorViewMode) => void + // Per-file opt-in to render front matter in the markdown preview (#4468). + // Default is hidden; absent entry means hidden. Storing only the explicit + // true values keeps the record minimal and the default implicit. + markdownFrontmatterVisible: Record + setMarkdownFrontmatterVisible: (fileId: string, visible: boolean) => void + // Right sidebar rightSidebarOpen: boolean rightSidebarWidth: number @@ -1215,6 +1221,26 @@ export const createEditorSlice: StateCreator = (s return { editorViewMode: { ...s.editorViewMode, [fileId]: mode } } }), + // Markdown preview front-matter visibility (#4468). Default is hidden; the + // preview only renders the front-matter card when the user opts in per file. + markdownFrontmatterVisible: {}, + setMarkdownFrontmatterVisible: (fileId, visible) => + set((s) => { + // Why: default is hidden. Writing `false` explicitly when no entry exists + // would grow the record unnecessarily; delete instead so the shape stays + // minimal and hydration round-trips cleanly — same trade-off as + // setEditorViewMode above. + if (!visible) { + if (!(fileId in s.markdownFrontmatterVisible)) { + return s + } + const next = { ...s.markdownFrontmatterVisible } + delete next[fileId] + return { markdownFrontmatterVisible: next } + } + return { markdownFrontmatterVisible: { ...s.markdownFrontmatterVisible, [fileId]: true } } + }), + // Right sidebar rightSidebarOpen: false, rightSidebarWidth: 280, @@ -1425,6 +1451,27 @@ export const createEditorSlice: StateCreator = (s ([fileId]) => fileId !== replacedPreview.id ) ) + const frontmatterVisibilityKeys = new Set([replacedPreview.id]) + if (replacedPreview.markdownPreviewSourceFileId) { + frontmatterVisibilityKeys.add(replacedPreview.markdownPreviewSourceFileId) + } + const frontmatterKeysToRemove = [...frontmatterVisibilityKeys].filter( + (key) => + key in s.markdownFrontmatterVisible && + !s.openFiles.some( + (file, index) => + index !== existingPreviewIdx && + (file.id === key || file.markdownPreviewSourceFileId === key) + ) + ) + const nextMarkdownFrontmatterVisible = + replacedPreview.id === id || frontmatterKeysToRemove.length === 0 + ? s.markdownFrontmatterVisible + : Object.fromEntries( + Object.entries(s.markdownFrontmatterVisible).filter( + ([fileId]) => !frontmatterKeysToRemove.includes(fileId) + ) + ) // Why: editorCursorLine entries accumulate per file; clean up the // evicted preview's entry so it does not leak across tab replacements. const nextEditorCursorLine = @@ -1474,6 +1521,7 @@ export const createEditorSlice: StateCreator = (s editorCursorLine: nextEditorCursorLine, markdownViewMode: nextMarkdownViewMode, editorViewMode: nextEditorViewMode, + markdownFrontmatterVisible: nextMarkdownFrontmatterVisible, recentlyClosedEditorTabsByWorktree: nextRecentlyClosed, ...previewTabBarUpdate, ...activeResult @@ -1692,6 +1740,25 @@ export const createEditorSlice: StateCreator = (s delete newMarkdownViewMode[fileId] const newEditorViewMode = { ...s.editorViewMode } delete newEditorViewMode[fileId] + const frontmatterVisibilityKeys = new Set([fileId]) + if (closedFile?.markdownPreviewSourceFileId) { + frontmatterVisibilityKeys.add(closedFile.markdownPreviewSourceFileId) + } + const keysToRemove = [...frontmatterVisibilityKeys].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 + })() + : s.markdownFrontmatterVisible // 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. @@ -1817,6 +1884,7 @@ export const createEditorSlice: StateCreator = (s activeTabTypeByWorktree: newActiveTabTypeByWorktree, markdownViewMode: newMarkdownViewMode, editorViewMode: newEditorViewMode, + markdownFrontmatterVisible: newMarkdownFrontmatterVisible, tabBarOrderByWorktree: nextTabBarOrderByWorktree, pendingEditorReveal: null, recentlyClosedEditorTabsByWorktree: nextRecentlyClosed @@ -1902,6 +1970,7 @@ export const createEditorSlice: StateCreator = (s activeTabType: 'terminal', markdownViewMode: {}, editorViewMode: {}, + markdownFrontmatterVisible: {}, pendingEditorReveal: null } } @@ -1917,6 +1986,11 @@ export const createEditorSlice: StateCreator = (s const newEditorViewMode = Object.fromEntries( Object.entries(s.editorViewMode).filter(([fileId]) => remainingFileIds.has(fileId)) ) + const newMarkdownFrontmatterVisible = Object.fromEntries( + Object.entries(s.markdownFrontmatterVisible).filter(([fileId]) => + remainingFileIds.has(fileId) + ) + ) const newEditorCursorLine = Object.fromEntries( Object.entries(s.editorCursorLine).filter(([fileId]) => remainingFileIds.has(fileId)) ) @@ -1982,6 +2056,7 @@ export const createEditorSlice: StateCreator = (s activeTabType: browserTabsForWorktree.length > 0 ? 'browser' : 'terminal', markdownViewMode: newMarkdownViewMode, editorViewMode: newEditorViewMode, + markdownFrontmatterVisible: newMarkdownFrontmatterVisible, activeFileIdByWorktree: newActiveFileIdByWorktree, activeTabTypeByWorktree: newActiveTabTypeByWorktree, tabBarOrderByWorktree: nextTabBarOrderByWorktree, @@ -3440,6 +3515,7 @@ export const createEditorSlice: StateCreator = (s const openFilesByWorktree = session.openFilesByWorktree ?? {} const persistedActiveFileIdByWorktree = session.activeFileIdByWorktree ?? {} const persistedActiveTabTypeByWorktree = session.activeTabTypeByWorktree ?? {} + const persistedMarkdownFrontmatterVisible = session.markdownFrontmatterVisible ?? {} // Why: worktrees may have been deleted between sessions. Filter out // files for worktrees that no longer exist, mirroring the validation @@ -3576,10 +3652,30 @@ export const createEditorSlice: StateCreator = (s // browser or terminal instead of showing an empty editor surface. const nextActiveTabType = nextActiveFileId || activeTabType !== 'editor' ? activeTabType : 'terminal' + const openFileIds = new Set(openFiles.map((file) => file.id)) + const visibleFrontmatterEntries = new Map() + for (const [persistedFileId, visible] of Object.entries( + persistedMarkdownFrontmatterVisible + )) { + if (!visible) { + continue + } + if (openFileIds.has(persistedFileId)) { + visibleFrontmatterEntries.set(persistedFileId, true) + } + for (const migrations of Object.values(editorFileIdMigrationsByWorktree)) { + const migratedFileId = migrations.get(persistedFileId) + if (migratedFileId && openFileIds.has(migratedFileId)) { + visibleFrontmatterEntries.set(migratedFileId, true) + } + } + } + const markdownFrontmatterVisible = Object.fromEntries(visibleFrontmatterEntries) return { openFiles, editorDrafts, + markdownFrontmatterVisible, activeFileId: nextActiveFileId, activeFileIdByWorktree: filteredActiveFileIdByWorktree, activeTabType: nextActiveTabType, diff --git a/src/renderer/src/store/slices/settings.test.ts b/src/renderer/src/store/slices/settings.test.ts index 41040c4c8..c2261859b 100644 --- a/src/renderer/src/store/slices/settings.test.ts +++ b/src/renderer/src/store/slices/settings.test.ts @@ -213,6 +213,7 @@ describe('createSettingsSlice runtime switching', () => { editorDrafts: { '/env-1/repo/stale.md': 'stale' }, markdownViewMode: { '/env-1/repo/stale.md': 'rich' }, editorViewMode: { '/env-1/repo/stale.md': 'changes' }, + markdownFrontmatterVisible: { '/env-1/repo/stale.md': true }, editorCursorLine: { '/env-1/repo/stale.md': 4 }, showDotfilesByWorktree: { 'repo-env-1::/env-1/repo': false }, gitIgnoredPathsByWorktree: { 'repo-env-1::/env-1/repo': ['dist/'] }, @@ -271,6 +272,7 @@ describe('createSettingsSlice runtime switching', () => { expect(store.getState().editorDrafts).toEqual({}) expect(store.getState().markdownViewMode).toEqual({}) expect(store.getState().editorViewMode).toEqual({}) + expect(store.getState().markdownFrontmatterVisible).toEqual({}) expect(store.getState().editorCursorLine).toEqual({}) expect(store.getState().showDotfilesByWorktree).toEqual({}) expect(store.getState().gitIgnoredPathsByWorktree).toEqual({}) diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index b54544fca..3fbd429ca 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -98,6 +98,7 @@ function runtimeScopedStateReset(): Partial { editorDrafts: {}, markdownViewMode: {}, editorViewMode: {}, + markdownFrontmatterVisible: {}, editorCursorLine: {}, gitIgnoredPathsByWorktree: {}, activeFileId: null, diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index b4ffc2434..753b756c7 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -1796,7 +1796,8 @@ describe('hydrateEditorSession', () => { ] }, activeFileIdByWorktree: { [wt]: '/path/wt1/src/index.ts' }, - activeTabTypeByWorktree: { [wt]: 'editor' } + activeTabTypeByWorktree: { [wt]: 'editor' }, + markdownFrontmatterVisible: { '/path/wt1/README.md': true } }) const s = store.getState() @@ -1805,6 +1806,7 @@ describe('hydrateEditorSession', () => { expect(s.openFiles[0].mode).toBe('edit') expect(s.openFiles[0].isDirty).toBe(false) expect(s.openFiles[1].isPreview).toBe(true) + expect(s.markdownFrontmatterVisible).toEqual({ '/path/wt1/README.md': true }) expect(s.activeFileId).toBe('/path/wt1/src/index.ts') expect(s.activeTabType).toBe('editor') }) @@ -1851,9 +1853,44 @@ describe('hydrateEditorSession', () => { }) ]) expect(s.editorDrafts).toEqual({ [fileId]: '' }) + expect(s.markdownFrontmatterVisible).toEqual({}) expect(s.activeFileIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toBe(fileId) }) + it('migrates hydrated front-matter visibility to owner-qualified editor file ids', () => { + const store = createTestStore() + const filePath = '/orca/userData/floating-workspace/note.md' + const fileId = ownedEditorFileId(filePath, FLOATING_TERMINAL_WORKTREE_ID, null) + + store.setState({ activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID }) + + store.getState().hydrateEditorSession({ + activeRepoId: null, + activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + openFilesByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { + filePath, + relativePath: 'note.md', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + language: 'markdown', + runtimeEnvironmentId: null + } + ] + }, + activeFileIdByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: filePath + }, + activeTabTypeByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: 'editor' }, + markdownFrontmatterVisible: { [filePath]: true } + }) + + expect(store.getState().markdownFrontmatterVisible).toEqual({ [fileId]: true }) + }) + it('falls back to the floating workspace file id when duplicate paths are owner-qualified', () => { const store = createTestStore() const wt = 'repo1::/path/wt1' diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index bf1ee533a..39e3ec1e4 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -1573,6 +1573,64 @@ describe('removeWorktree state cleanup', () => { expect(store.getState().editorViewMode).toEqual({ 'file-2': 'changes' }) }) + it('cleans up markdownFrontmatterVisible for files in the removed worktree', async () => { + const store = createTestStore() + const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) + + store.setState({ + worktreesByRepo: { repo1: [wt] }, + openFiles: [ + { + id: 'file-1', + worktreeId: 'repo1::/path/wt1', + filePath: '/path/wt1/readme.md', + relativePath: 'readme.md', + language: 'markdown', + isDirty: false, + isPreview: false, + mode: 'edit' as const + } + ], + markdownFrontmatterVisible: { + 'file-1': true, + 'file-2': true + } + } as unknown as Partial) + + await store.getState().removeWorktree('repo1::/path/wt1') + + expect(store.getState().markdownFrontmatterVisible).toEqual({ 'file-2': true }) + }) + + it('cleans up markdownFrontmatterVisible for preview-only source files in the removed worktree', async () => { + const store = createTestStore() + const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) + + store.setState({ + worktreesByRepo: { repo1: [wt] }, + openFiles: [ + { + id: 'markdown-preview::file-1', + worktreeId: 'repo1::/path/wt1', + filePath: '/path/wt1/readme.md', + relativePath: 'readme.md', + language: 'markdown', + isDirty: false, + markdownPreviewSourceFileId: 'file-1', + mode: 'markdown-preview' as const + } + ], + markdownFrontmatterVisible: { + 'file-1': true, + 'file-2': true + } + } as unknown as Partial) + + await store.getState().removeWorktree('repo1::/path/wt1') + + expect(store.getState().markdownFrontmatterVisible).toEqual({ 'file-2': true }) + }) + it('records the sidebar scroll anchor in the same tick it removes the worktree', async () => { const store = createTestStore() const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) @@ -2985,11 +3043,13 @@ describe('purgeWorktreeTerminalState direct (design §4.4)', () => { relativePath: 'a.ts', language: 'typescript', isDirty: false, + markdownPreviewSourceFileId: 'source-1', isPreview: false, mode: 'edit' as const } ], editorDrafts: { 'file-1': 'draft', 'file-99': 'other' }, + markdownFrontmatterVisible: { 'source-1': true, 'file-99': true }, gitIgnoredPathsByWorktree: { 'repoA::/a/wt1': ['dist/'], 'repoA::/a/wt2': ['coverage/'] @@ -3022,6 +3082,7 @@ describe('purgeWorktreeTerminalState direct (design §4.4)', () => { expect(s.runtimePaneTitlesByTabId).toEqual({ 'tab-3': 'bash' }) expect(s.openFiles).toEqual([]) expect(s.editorDrafts).toEqual({ 'file-99': 'other' }) + expect(s.markdownFrontmatterVisible).toEqual({ 'file-99': true }) expect(s.gitIgnoredPathsByWorktree).toEqual({ 'repoA::/a/wt2': ['coverage/'] }) expect(s.rightSidebarTabByWorktree).toEqual({ 'repoA::/a/wt2': 'checks' }) expect(s.activeWorktreeId).toBeNull() diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 0af740d4c..e91d34fc6 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -568,6 +568,9 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial delete nextGitBranchCompareRequestKeyByWorktree[worktreeId] // Why: clean up per-file editor state for files belonging to the removed // worktree so stale drafts and view modes never accumulate in memory. - const removedFileIds = new Set( - s.openFiles.filter((f) => f.worktreeId === worktreeId).map((f) => f.id) - ) + const removedFileIds = new Set() + for (const file of s.openFiles) { + if (file.worktreeId !== worktreeId) { + continue + } + removedFileIds.add(file.id) + if (file.markdownPreviewSourceFileId) { + removedFileIds.add(file.markdownPreviewSourceFileId) + } + } const nextEditorDrafts = removedFileIds.size > 0 ? { ...s.editorDrafts } : s.editorDrafts const nextMarkdownViewMode = removedFileIds.size > 0 ? { ...s.markdownViewMode } : s.markdownViewMode const nextEditorViewMode = removedFileIds.size > 0 ? { ...s.editorViewMode } : s.editorViewMode + const nextMarkdownFrontmatterVisible = + removedFileIds.size > 0 + ? { ...s.markdownFrontmatterVisible } + : s.markdownFrontmatterVisible if (removedFileIds.size > 0) { for (const fileId of removedFileIds) { delete nextEditorDrafts[fileId] delete nextMarkdownViewMode[fileId] delete nextEditorViewMode[fileId] + delete nextMarkdownFrontmatterVisible[fileId] } } const nextExpandedDirs = { ...s.expandedDirs } @@ -1379,6 +1395,7 @@ export const createWorktreeSlice: StateCreator editorDrafts: nextEditorDrafts, markdownViewMode: nextMarkdownViewMode, editorViewMode: nextEditorViewMode, + markdownFrontmatterVisible: nextMarkdownFrontmatterVisible, showDotfilesByWorktree: nextShowDotfilesByWorktree, expandedDirs: nextExpandedDirs, gitStatusByWorktree: nextGitStatusByWorktree, diff --git a/src/shared/constants.ts b/src/shared/constants.ts index c1124ee0d..ff917e246 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -431,6 +431,7 @@ export function getDefaultWorkspaceSession(): WorkspaceSessionState { tabsByWorktree: {}, terminalLayoutsByTabId: {}, openFilesByWorktree: {}, + markdownFrontmatterVisible: {}, browserTabsByWorktree: {}, browserPagesByWorkspace: {}, activeBrowserTabIdByWorktree: {}, diff --git a/src/shared/types.ts b/src/shared/types.ts index 12013616c..b7db93a61 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -684,6 +684,8 @@ export type WorkspaceSessionState = { openFilesByWorktree?: Record /** Per-worktree active editor file ID (filePath) at shutdown. */ activeFileIdByWorktree?: Record + /** Per-file markdown preview front-matter visibility. Absent entry means hidden. */ + markdownFrontmatterVisible?: Record /** Persisted browser workspaces, keyed by worktree ID. */ browserTabsByWorktree?: Record /** Persisted browser pages, keyed by workspace ID. */ diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 5ab61590b..c8b1f42c1 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -218,6 +218,7 @@ export const workspaceSessionStateSchema: z.ZodType = z.o activeWorktreeIdsOnShutdown: z.array(z.string()).optional(), openFilesByWorktree: z.record(z.string(), z.array(persistedOpenFileSchema)).optional(), activeFileIdByWorktree: z.record(z.string(), z.string().nullable()).optional(), + markdownFrontmatterVisible: z.record(z.string(), z.boolean()).optional(), browserTabsByWorktree: z.record(z.string(), z.array(browserWorkspaceSchema)).optional(), browserPagesByWorkspace: z.record(z.string(), z.array(browserPageSchema)).optional(), activeBrowserTabIdByWorktree: z.record(z.string(), z.string().nullable()).optional(),