fix: preserve yml views in raw editor
This commit is contained in:
13
src/App.tsx
13
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,
|
||||
|
||||
@@ -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?.() },
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
184
tests/smoke/raw-yml-view-save.spec.ts
Normal file
184
tests/smoke/raw-yml-view-save.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user