* feat: hide markdown front matter by default with per-file toggle (#4468) Front matter in the markdown preview was always visible above the rendered body, which clutters reading for files where the metadata is incidental. Default the card to hidden and let users opt in per file via a small switch in the section header. * Add a per-file markdownFrontmatterVisible flag in EditorSlice, mirroring the editorViewMode pattern (absent entry = hidden; storing only explicit true values keeps the record minimal). * Clean the entry up on closeFile, preview-tab replacement, and worktree removal; reset on runtime switch. * Render the existing front-matter card unchanged when the toggle is on, hide only the <pre> body when it is off, and keep the section header + SettingsSwitch visible so the affordance stays discoverable. * Add 4 regression cases in editor.test.ts mirroring the editorViewMode tests. * fix: persist markdown front matter visibility Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
c6293c8050
commit
13f062d093
|
|
@ -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 ? <FrontMatterBanner raw={fm.raw} /> : null}
|
||||
headerSlot={
|
||||
fm && showMarkdownFrontmatter ? <FrontMatterBanner raw={fm.raw} /> : null
|
||||
}
|
||||
/>
|
||||
</RichMarkdownErrorBoundary>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<EditorPanelShell
|
||||
|
|
@ -313,6 +336,8 @@ function EditorPanelInner({
|
|||
model={model}
|
||||
copiedPathVisible={copiedPathToast?.fileId === activeFile.id}
|
||||
showMarkdownTableOfContents={showMarkdownTableOfContents}
|
||||
canShowMarkdownFrontmatterToggle={canShowMarkdownFrontmatterToggle}
|
||||
markdownFrontmatterVisible={isMarkdownFrontmatterVisible}
|
||||
sideBySide={sideBySide}
|
||||
openFiles={openFiles}
|
||||
fileContents={fileContents}
|
||||
|
|
@ -330,6 +355,12 @@ function EditorPanelInner({
|
|||
onToggleSideBySide={() => setSideBySide((prev) => !prev)}
|
||||
onEditorToggleChange={handleEditorToggleChange}
|
||||
onToggleMarkdownTableOfContents={() => setShowMarkdownTableOfContents((shown) => !shown)}
|
||||
onToggleMarkdownFrontmatter={() =>
|
||||
setMarkdownFrontmatterVisible(
|
||||
markdownFrontmatterSourceFileId,
|
||||
!isMarkdownFrontmatterVisible
|
||||
)
|
||||
}
|
||||
onExportMarkdownToPdf={() => void exportActiveMarkdownToPdf()}
|
||||
onContentChange={handleContentChange}
|
||||
onContentChangeForFile={handleContentChangeForFile}
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{hasViewModeToggle && isMarkdown && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
|
||||
aria-label="More actions"
|
||||
title="More actions"
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={4}>
|
||||
<DropdownMenuItem
|
||||
// Why: the item is disabled (not hidden) only in source/Monaco
|
||||
// mode, which has no document DOM to export. We intentionally
|
||||
// don't poll the DOM (canExportActiveMarkdown) at render time:
|
||||
// the Radix content renders in a Portal and the lookup can
|
||||
// race with the active surface's paint, producing a stuck
|
||||
// disabled state. exportActiveMarkdownToPdf is a safe no-op
|
||||
// when no subtree is found.
|
||||
disabled={mdViewMode === 'source'}
|
||||
onSelect={onExportMarkdownToPdf}
|
||||
>
|
||||
Export as PDF
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<EditorPanelMarkdownActionsMenu
|
||||
isMarkdown={isMarkdown}
|
||||
hasViewModeToggle={hasViewModeToggle}
|
||||
mdViewMode={mdViewMode}
|
||||
canShowMarkdownFrontmatterToggle={canShowMarkdownFrontmatterToggle}
|
||||
markdownFrontmatterVisible={markdownFrontmatterVisible}
|
||||
onToggleMarkdownFrontmatter={onToggleMarkdownFrontmatter}
|
||||
onExportMarkdownToPdf={onExportMarkdownToPdf}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
|
||||
aria-label="More actions"
|
||||
title="More actions"
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={4}>
|
||||
{canShowMarkdownFrontmatterToggle ? (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => {
|
||||
event.preventDefault()
|
||||
onToggleMarkdownFrontmatter()
|
||||
}}
|
||||
>
|
||||
{markdownFrontmatterVisible ? 'Hide front matter' : 'Show front matter'}
|
||||
</DropdownMenuItem>
|
||||
{hasViewModeToggle ? <DropdownMenuSeparator /> : null}
|
||||
</>
|
||||
) : null}
|
||||
{hasViewModeToggle ? (
|
||||
<DropdownMenuItem
|
||||
// Why: source/Monaco mode has no document DOM. Avoid polling the
|
||||
// portal-mounted menu; exportActiveMarkdownToPdf is a safe no-op
|
||||
// when no rendered markdown subtree is found.
|
||||
disabled={mdViewMode === 'source'}
|
||||
onSelect={onExportMarkdownToPdf}
|
||||
>
|
||||
Export as PDF
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
|
@ -19,6 +19,8 @@ type EditorPanelShellProps = {
|
|||
model: EditorPanelRenderModel
|
||||
copiedPathVisible: boolean
|
||||
showMarkdownTableOfContents: boolean
|
||||
canShowMarkdownFrontmatterToggle: boolean
|
||||
markdownFrontmatterVisible: boolean
|
||||
sideBySide: boolean
|
||||
openFiles: OpenFile[]
|
||||
fileContents: Record<string, FileContent>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null)
|
||||
const [reviewNotesCopied, setReviewNotesCopied] = useState(false)
|
||||
const [copiedReviewNoteId, setCopiedReviewNoteId] = useState<string | null>(null)
|
||||
|
|
@ -1737,10 +1745,10 @@ export default function MarkdownPreview({
|
|||
</div>
|
||||
) : null}
|
||||
<div ref={bodyRef} className="markdown-body">
|
||||
{/* 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 ? (
|
||||
<div className="mb-4 rounded border border-border/60 bg-muted/40 px-3 py-2">
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Front Matter
|
||||
|
|
@ -1749,7 +1757,7 @@ export default function MarkdownPreview({
|
|||
{frontMatterInner}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
<Markdown
|
||||
components={components}
|
||||
// Why: react-markdown filters file:// after rehype-sanitize; preview
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ function createSnapshot(browserUrlHistory: BrowserHistoryEntry[]): WorkspaceSess
|
|||
activeTabIdByWorktree: {},
|
||||
openFiles: [],
|
||||
editorDrafts: {},
|
||||
markdownFrontmatterVisible: {},
|
||||
activeFileIdByWorktree: {},
|
||||
activeTabTypeByWorktree: {},
|
||||
browserTabsByWorktree: {},
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ function createSnapshot(
|
|||
activeTabIdByWorktree: {},
|
||||
openFiles: [],
|
||||
editorDrafts: {},
|
||||
markdownFrontmatterVisible: {},
|
||||
activeFileIdByWorktree: {},
|
||||
activeTabTypeByWorktree: {},
|
||||
browserTabsByWorktree: {},
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ function createSnapshot(
|
|||
activeTabIdByWorktree: {},
|
||||
openFiles: [],
|
||||
editorDrafts: {},
|
||||
markdownFrontmatterVisible: {},
|
||||
activeFileIdByWorktree: {},
|
||||
activeTabTypeByWorktree: {},
|
||||
browserTabsByWorktree: {},
|
||||
|
|
|
|||
|
|
@ -23,8 +23,10 @@ function createSnapshot(
|
|||
},
|
||||
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',
|
||||
|
|
@ -36,6 +38,7 @@ function createSnapshot(
|
|||
originalContent: ''
|
||||
},
|
||||
{
|
||||
id: '/tmp/demo.diff',
|
||||
filePath: '/tmp/demo.diff',
|
||||
relativePath: 'demo.diff',
|
||||
worktreeId: 'wt-1',
|
||||
|
|
@ -90,7 +93,12 @@ describe('buildWorkspaceSessionPatch', () => {
|
|||
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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ describe('SESSION_RELEVANT_FIELDS', () => {
|
|||
activeTabIdByWorktree: true,
|
||||
openFiles: true,
|
||||
editorDrafts: true,
|
||||
markdownFrontmatterVisible: true,
|
||||
activeFileIdByWorktree: true,
|
||||
activeTabTypeByWorktree: true,
|
||||
browserTabsByWorktree: true,
|
||||
|
|
|
|||
|
|
@ -21,8 +21,10 @@ function createSnapshot(overrides: Partial<AppState> = {}): 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> = {}): 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(
|
||||
|
|
|
|||
|
|
@ -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<string, string>,
|
||||
markdownFrontmatterVisible: Record<string, boolean>,
|
||||
activeFileIdByWorktree: Record<string, string | null>,
|
||||
activeTabTypeByWorktree: Record<string, WorkspaceVisibleTabType>
|
||||
): Pick<
|
||||
WorkspaceSessionState,
|
||||
'openFilesByWorktree' | 'activeFileIdByWorktree' | 'activeTabTypeByWorktree'
|
||||
| 'openFilesByWorktree'
|
||||
| 'activeFileIdByWorktree'
|
||||
| 'activeTabTypeByWorktree'
|
||||
| 'markdownFrontmatterVisible'
|
||||
> {
|
||||
const editFiles = openFiles.filter((f) => f.mode === 'edit')
|
||||
const byWorktree: Record<string, PersistedOpenFile[]> = {}
|
||||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -287,6 +287,12 @@ export type EditorSlice = {
|
|||
editorViewMode: Record<string, EditorViewMode>
|
||||
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<string, boolean>
|
||||
setMarkdownFrontmatterVisible: (fileId: string, visible: boolean) => void
|
||||
|
||||
// Right sidebar
|
||||
rightSidebarOpen: boolean
|
||||
rightSidebarWidth: number
|
||||
|
|
@ -1215,6 +1221,26 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (s
|
|||
editorCursorLine: nextEditorCursorLine,
|
||||
markdownViewMode: nextMarkdownViewMode,
|
||||
editorViewMode: nextEditorViewMode,
|
||||
markdownFrontmatterVisible: nextMarkdownFrontmatterVisible,
|
||||
recentlyClosedEditorTabsByWorktree: nextRecentlyClosed,
|
||||
...previewTabBarUpdate,
|
||||
...activeResult
|
||||
|
|
@ -1692,6 +1740,25 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (s
|
|||
activeTabTypeByWorktree: newActiveTabTypeByWorktree,
|
||||
markdownViewMode: newMarkdownViewMode,
|
||||
editorViewMode: newEditorViewMode,
|
||||
markdownFrontmatterVisible: newMarkdownFrontmatterVisible,
|
||||
tabBarOrderByWorktree: nextTabBarOrderByWorktree,
|
||||
pendingEditorReveal: null,
|
||||
recentlyClosedEditorTabsByWorktree: nextRecentlyClosed
|
||||
|
|
@ -1902,6 +1970,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
activeTabType: 'terminal',
|
||||
markdownViewMode: {},
|
||||
editorViewMode: {},
|
||||
markdownFrontmatterVisible: {},
|
||||
pendingEditorReveal: null
|
||||
}
|
||||
}
|
||||
|
|
@ -1917,6 +1986,11 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<string, boolean>()
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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({})
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ function runtimeScopedStateReset(): Partial<AppState> {
|
|||
editorDrafts: {},
|
||||
markdownViewMode: {},
|
||||
editorViewMode: {},
|
||||
markdownFrontmatterVisible: {},
|
||||
editorCursorLine: {},
|
||||
gitIgnoredPathsByWorktree: {},
|
||||
activeFileId: null,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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<AppState>)
|
||||
|
||||
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<AppState>)
|
||||
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -568,6 +568,9 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial<Ap
|
|||
for (const file of s.openFiles) {
|
||||
if (worktreeIdSet.has(file.worktreeId)) {
|
||||
removedFileIds.add(file.id)
|
||||
if (file.markdownPreviewSourceFileId) {
|
||||
removedFileIds.add(file.markdownPreviewSourceFileId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -673,6 +676,7 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial<Ap
|
|||
// Per-file editor state for removed files
|
||||
editorDrafts: omitByFileId(s.editorDrafts),
|
||||
markdownViewMode: omitByFileId(s.markdownViewMode),
|
||||
markdownFrontmatterVisible: omitByFileId(s.markdownFrontmatterVisible),
|
||||
// Top-level actives
|
||||
openFiles: nextOpenFiles,
|
||||
everActivatedWorktreeIds: nextEverActivatedWorktreeIds,
|
||||
|
|
@ -1300,19 +1304,31 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
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<string>()
|
||||
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<AppState, [], [], WorktreeSlice>
|
|||
editorDrafts: nextEditorDrafts,
|
||||
markdownViewMode: nextMarkdownViewMode,
|
||||
editorViewMode: nextEditorViewMode,
|
||||
markdownFrontmatterVisible: nextMarkdownFrontmatterVisible,
|
||||
showDotfilesByWorktree: nextShowDotfilesByWorktree,
|
||||
expandedDirs: nextExpandedDirs,
|
||||
gitStatusByWorktree: nextGitStatusByWorktree,
|
||||
|
|
|
|||
|
|
@ -431,6 +431,7 @@ export function getDefaultWorkspaceSession(): WorkspaceSessionState {
|
|||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
openFilesByWorktree: {},
|
||||
markdownFrontmatterVisible: {},
|
||||
browserTabsByWorktree: {},
|
||||
browserPagesByWorkspace: {},
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
|
|
|
|||
|
|
@ -684,6 +684,8 @@ export type WorkspaceSessionState = {
|
|||
openFilesByWorktree?: Record<string, PersistedOpenFile[]>
|
||||
/** Per-worktree active editor file ID (filePath) at shutdown. */
|
||||
activeFileIdByWorktree?: Record<string, string | null>
|
||||
/** Per-file markdown preview front-matter visibility. Absent entry means hidden. */
|
||||
markdownFrontmatterVisible?: Record<string, boolean>
|
||||
/** Persisted browser workspaces, keyed by worktree ID. */
|
||||
browserTabsByWorktree?: Record<string, BrowserWorkspace[]>
|
||||
/** Persisted browser pages, keyed by workspace ID. */
|
||||
|
|
|
|||
|
|
@ -218,6 +218,7 @@ export const workspaceSessionStateSchema: z.ZodType<WorkspaceSessionState> = 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(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue