fix: preserve yml views in raw editor

This commit is contained in:
lucaronin
2026-04-17 23:32:36 +02:00
parent 954ee42f0b
commit 34de384738
6 changed files with 231 additions and 2 deletions

View File

@@ -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,

View File

@@ -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?.() },

View File

@@ -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'

View File

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

View File

@@ -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
}

View File

@@ -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<void> {
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<void> {
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<void> {
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<string> {
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)
})
})