From 34de3847386008b7367c49ae7e0449d18fa1ccbb Mon Sep 17 00:00:00 2001 From: lucaronin Date: Fri, 17 Apr 2026 23:32:36 +0200 Subject: [PATCH] fix: preserve yml views in raw editor --- src/App.tsx | 13 +- src/hooks/commands/viewCommands.ts | 2 +- src/hooks/useAppSave.test.ts | 27 ++++ src/hooks/useCommandRegistry.test.ts | 6 + src/hooks/useNoteRename.ts | 1 + tests/smoke/raw-yml-view-save.spec.ts | 184 ++++++++++++++++++++++++++ 6 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 tests/smoke/raw-yml-view-save.spec.ts diff --git a/src/App.tsx b/src/App.tsx index f51374f8..fb095b7b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -899,6 +899,17 @@ function App() { ) ?? null }, [notes.activeTabPath, vault.modifiedFiles]) + const activeCommandEntry = useMemo(() => { + if (!notes.activeTabPath) return null + return notes.tabs.find((tab) => tab.entry.path === notes.activeTabPath)?.entry + ?? vault.entries.find((entry) => entry.path === notes.activeTabPath) + ?? null + }, [notes.activeTabPath, notes.tabs, vault.entries]) + + const canToggleRichEditor = !!activeCommandEntry + && activeCommandEntry.filename.toLowerCase().endsWith('.md') + && !activeDeletedFile + const commands = useAppCommands({ activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef, entries: vault.entries, @@ -922,7 +933,7 @@ function App() { onSetViewMode: handleSetViewMode, onToggleInspector: handleToggleInspector, onToggleDiff: () => diffToggleRef.current(), - onToggleRawEditor: activeDeletedFile ? undefined : () => rawToggleRef.current(), + onToggleRawEditor: canToggleRichEditor ? () => rawToggleRef.current() : undefined, onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset, zoomLevel: zoom.zoomLevel, onSelect: handleSetSelection, diff --git a/src/hooks/commands/viewCommands.ts b/src/hooks/commands/viewCommands.ts index 57124fe7..73a363de 100644 --- a/src/hooks/commands/viewCommands.ts +++ b/src/hooks/commands/viewCommands.ts @@ -32,7 +32,7 @@ export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] { { id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') }, { id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', shortcut: '⌘⇧I', keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector }, { id: 'toggle-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() }, - { id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote, execute: () => onToggleRawEditor?.() }, + { id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote && !!onToggleRawEditor, execute: () => onToggleRawEditor?.() }, { id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: '⇧⌘L', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() }, { id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector }, { id: 'customize-note-list-columns', label: noteListColumnsLabel, group: 'View', keywords: ['all notes', 'inbox', 'columns', 'chips', 'properties', 'note list'], enabled: !!(canCustomizeNoteListColumns && onCustomizeNoteListColumns), execute: () => onCustomizeNoteListColumns?.() }, diff --git a/src/hooks/useAppSave.test.ts b/src/hooks/useAppSave.test.ts index ceaf967d..f2f8f348 100644 --- a/src/hooks/useAppSave.test.ts +++ b/src/hooks/useAppSave.test.ts @@ -461,6 +461,33 @@ describe('useAppSave', () => { expect(getTabs()[0].content).toBe('# Fresh Title\n\nBody that keeps changing while rename is pending') }) + it('does not run markdown title-sync renames for non-markdown text files', async () => { + vi.mocked(isTauri).mockReturnValue(true) + + const viewPath = '/vault/views/active-projects.yml' + const viewContent = 'name: Active Projects\nicon: rocket\ncolor: blue\n' + const viewEntry = { + ...makeEntry(viewPath, 'Active Projects', 'active-projects.yml'), + fileKind: 'text' as const, + } + + const { result } = renderSave({ + tabs: [{ entry: viewEntry, content: viewContent }], + activeTabPath: viewPath, + unsavedPaths: new Set([viewPath]), + }) + + await act(async () => { + await result.current.handleSave() + }) + + expect(vi.mocked(invoke)).toHaveBeenCalledWith('save_note_content', { + path: viewPath, + content: viewContent, + }) + expect(deps.handleRenameNote).not.toHaveBeenCalled() + }) + it('remaps a buffered auto-save to the renamed path when untitled rename lands mid-idle window', async () => { const initialContent = '# Fresh Title\n\nInitial body' const bufferedContent = '# Fresh Title\n\nBody typed right before rename' diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts index 22948e43..02b428f8 100644 --- a/src/hooks/useCommandRegistry.test.ts +++ b/src/hooks/useCommandRegistry.test.ts @@ -214,6 +214,12 @@ describe('useCommandRegistry', () => { expect(findCommand(result.current, 'archive-note')?.shortcut).toBeUndefined() }) + it('disables Toggle Raw Editor when the active file cannot switch to rich mode', () => { + const config = makeConfig({ onToggleRawEditor: undefined }) + const { result } = renderHook(() => useCommandRegistry(config)) + expect(findCommand(result.current, 'toggle-raw-editor')?.enabled).toBe(false) + }) + it('omits Inbox navigation when the explicit workflow is disabled', () => { const config = makeConfig({ showInbox: false }) const { result } = renderHook(() => useCommandRegistry(config)) diff --git a/src/hooks/useNoteRename.ts b/src/hooks/useNoteRename.ts index 5406cf79..1ba36da4 100644 --- a/src/hooks/useNoteRename.ts +++ b/src/hooks/useNoteRename.ts @@ -35,6 +35,7 @@ interface ReloadTabsAfterRenameRequest { /** Check if a note's filename doesn't match the slug of its current title. */ export function needsRenameOnSave(title: string, filename: string): boolean { + if (!filename.toLowerCase().endsWith('.md')) return false return `${slugify(title)}.md` !== filename } diff --git a/tests/smoke/raw-yml-view-save.spec.ts b/tests/smoke/raw-yml-view-save.spec.ts new file mode 100644 index 00000000..d420c755 --- /dev/null +++ b/tests/smoke/raw-yml-view-save.spec.ts @@ -0,0 +1,184 @@ +import { test, expect, type Page } from '@playwright/test' +import fs from 'fs' +import os from 'os' +import path from 'path' +import { APP_COMMAND_IDS } from '../../src/hooks/appCommandCatalog' +import { + createFixtureVaultCopy, + openFixtureVaultDesktopHarness, + removeFixtureVaultCopy, +} from '../helpers/fixtureVault' +import { triggerShortcutCommand } from './testBridge' + +const VIEW_RELATIVE_PATH = path.join('views', 'active-projects.yml') +const DUPLICATE_RELATIVE_PATH = path.join('views', 'active-projects.md') +const VIEW_TITLE = 'Active Projects' +const VIEW_CONTENT = `name: Active Projects +icon: rocket +color: blue +sort: "modified:desc" +filters: + all: + - field: type + op: equals + value: Project +` + +let tempVaultDir: string + +function seedViewFile(vaultPath: string): void { + const viewPath = path.join(vaultPath, VIEW_RELATIVE_PATH) + fs.mkdirSync(path.dirname(viewPath), { recursive: true }) + fs.writeFileSync(viewPath, VIEW_CONTENT) +} + +function buildViewEntry(vaultPath: string) { + const viewPath = path.join(vaultPath, VIEW_RELATIVE_PATH) + return { + path: viewPath, + filename: path.basename(viewPath), + title: VIEW_TITLE, + isA: null, + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + archived: false, + modifiedAt: Date.now(), + createdAt: null, + fileSize: Buffer.byteLength(VIEW_CONTENT), + snippet: '', + wordCount: 0, + relationships: {}, + icon: null, + color: null, + order: null, + sidebarLabel: null, + template: null, + sort: null, + view: null, + visible: null, + organized: false, + favorite: false, + favoriteIndex: null, + listPropertiesDisplay: [], + outgoingLinks: [], + properties: {}, + hasH1: false, + fileKind: 'text', + } +} + +async function injectViewEntryIntoVaultList(page: Page, vaultPath: string): Promise { + const viewEntry = buildViewEntry(vaultPath) + await page.route('**/api/vault/list*', async (route) => { + const response = await route.fetch() + const entries = await response.json() + if (!Array.isArray(entries)) { + await route.fulfill({ response }) + return + } + + const hasViewEntry = entries.some((entry) => entry?.path === viewEntry.path) + const nextEntries = hasViewEntry ? entries : [...entries, viewEntry] + await route.fulfill({ response, json: nextEntries }) + }) +} + +async function openQuickOpenEntry(page: Page, title: string): Promise { + await triggerShortcutCommand(page, APP_COMMAND_IDS.fileQuickOpen) + const input = page.locator('input[placeholder="Search notes..."]') + await expect(input).toBeVisible({ timeout: 5_000 }) + await input.fill(title) + await page.keyboard.press('Enter') +} + +async function appendRawEditorLine(page: Page, line: string): Promise { + await page.evaluate((nextLine) => { + const host = document.querySelector('.cm-content') + if (!host) { + throw new Error('CodeMirror content element is missing') + } + + type CodeMirrorHost = Element & { + cmTile?: { + view?: { + state: { doc: { toString(): string; length: number } } + dispatch(transaction: { changes: { from: number; to: number; insert: string } }): void + } + } + } + + const view = (host as CodeMirrorHost).cmTile?.view + if (!view) { + throw new Error('CodeMirror view is missing') + } + + const doc = view.state.doc.toString() + view.dispatch({ + changes: { + from: doc.length, + to: doc.length, + insert: `\n${nextLine}\n`, + }, + }) + }, line) +} + +async function readRawEditorContent(page: Page): Promise { + return page.evaluate(() => { + const host = document.querySelector('.cm-content') + if (!host) return '' + + type CodeMirrorHost = Element & { + cmTile?: { + view?: { + state: { doc: { toString(): string } } + } + } + } + + return (host as CodeMirrorHost).cmTile?.view?.state.doc.toString() ?? host.textContent ?? '' + }) +} + +test.describe('raw .yml view save regression', () => { + test.beforeEach(() => { + tempVaultDir = createFixtureVaultCopy() + seedViewFile(tempVaultDir) + }) + + test.afterEach(() => { + removeFixtureVaultCopy(tempVaultDir) + }) + + test('saving a raw-edited .yml view preserves the .yml file and blocks the raw toggle shortcut @smoke', async ({ page }) => { + const viewPath = path.join(tempVaultDir, VIEW_RELATIVE_PATH) + const duplicatePath = path.join(tempVaultDir, DUPLICATE_RELATIVE_PATH) + const marker = `# raw-yml-save-${Date.now()}-${os.userInfo().username}` + + await injectViewEntryIntoVaultList(page, tempVaultDir) + await openFixtureVaultDesktopHarness(page, tempVaultDir) + await openQuickOpenEntry(page, VIEW_TITLE) + + await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 }) + await expect(page.locator('.bn-editor')).toHaveCount(0) + + await triggerShortcutCommand(page, APP_COMMAND_IDS.editToggleRawEditor) + await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 }) + await expect(page.locator('.bn-editor')).toHaveCount(0) + + await appendRawEditorLine(page, marker) + await triggerShortcutCommand(page, APP_COMMAND_IDS.fileSave) + + await expect.poll(() => fs.readFileSync(viewPath, 'utf-8')).toContain(marker) + await expect.poll(() => fs.existsSync(duplicatePath)).toBe(false) + + await openQuickOpenEntry(page, 'Alpha Project') + await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 }) + + await openQuickOpenEntry(page, VIEW_TITLE) + await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 }) + await expect.poll(async () => readRawEditorContent(page)).toContain(marker) + }) +})