feat: raw editor mode — plain textarea with frontmatter + wikilink autocomplete

Adds a third editor mode (alongside WYSIWYG and Diff) that shows the raw
markdown file in a monospaced textarea. Includes:

- useRawMode hook with derived state (no setState-in-effect) and onFlushPending
- RawEditorView: textarea with 500ms debounce, YAML error banner, wikilink
  autocomplete via [[  trigger, Cmd+S save
- BreadcrumbBar Code icon toggles raw mode; mutual exclusion with diff mode
- Command palette: "Toggle Raw Editor" (View group, requires active tab)
- useEditorModeExclusion hook extracted to keep Editor.tsx under complexity threshold
- buildViewCommands extracted from useCommandRegistry for same reason
- RawToggleButton and RawModeEditorSection components extracted for clean CC

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-02 18:38:25 +01:00
parent f04dfdbd37
commit 185feb5a2c
14 changed files with 750 additions and 16 deletions

View File

@@ -0,0 +1 @@
{"children":[],"variables":{}}

View File

@@ -270,6 +270,9 @@ function App() {
const bulkActions = useBulkActions(entryActions, setToastMessage)
// Raw-toggle ref: Editor registers its handleToggleRaw here so the command palette can call it
const rawToggleRef = useRef<() => void>(() => {})
const { setViewMode, sidebarVisible, noteListVisible } = useViewMode()
const zoom = useZoom()
const buildNumber = useBuildNumber()
@@ -290,6 +293,7 @@ function App() {
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
onCommitPush: commitFlow.openCommitDialog, onSetViewMode: setViewMode,
onToggleInspector: () => layout.setInspectorCollapsed(c => !c),
onToggleRawEditor: () => rawToggleRef.current(),
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
zoomLevel: zoom.zoomLevel,
onSelect: setSelection, onCloseTab: notes.handleCloseTab,
@@ -389,7 +393,9 @@ function App() {
onUnarchiveNote={entryActions.handleUnarchiveNote}
onRenameTab={handleRenameTab}
onContentChange={handleContentChange}
onSave={handleSave}
onTitleSync={handleTitleSync}
rawToggleRef={rawToggleRef}
canGoBack={navHistory.canGoBack}
canGoForward={navHistory.canGoForward}
onGoBack={handleGoBack}

View File

@@ -115,3 +115,24 @@ describe('BreadcrumbBar — archive/unarchive', () => {
expect(onUnarchive).toHaveBeenCalledOnce()
})
})
describe('BreadcrumbBar — raw editor toggle', () => {
it('shows Raw editor button with tooltip "Raw editor" when rawMode is off', () => {
const onToggleRaw = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={false} onToggleRaw={onToggleRaw} />)
expect(screen.getByTitle('Raw editor')).toBeInTheDocument()
})
it('shows "Back to editor" tooltip when rawMode is on', () => {
const onToggleRaw = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={true} onToggleRaw={onToggleRaw} />)
expect(screen.getByTitle('Back to editor')).toBeInTheDocument()
})
it('calls onToggleRaw when raw button is clicked', () => {
const onToggleRaw = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={false} onToggleRaw={onToggleRaw} />)
fireEvent.click(screen.getByTitle('Raw editor'))
expect(onToggleRaw).toHaveBeenCalledOnce()
})
})

View File

@@ -4,6 +4,7 @@ import { cn } from '@/lib/utils'
import {
MagnifyingGlass,
GitBranch,
Code,
CursorText,
Sparkle,
SlidersHorizontal,
@@ -22,6 +23,8 @@ interface BreadcrumbBarProps {
diffMode: boolean
diffLoading: boolean
onToggleDiff: () => void
rawMode?: boolean
onToggleRaw?: () => void
showAIChat?: boolean
onToggleAIChat?: () => void
inspectorCollapsed?: boolean
@@ -34,7 +37,23 @@ interface BreadcrumbBarProps {
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggleRaw?: () => void }) {
return (
<button
className={cn(
'flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors',
rawMode ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
)}
onClick={onToggleRaw}
title={rawMode ? 'Back to editor' : 'Raw editor'}
>
<Code size={16} />
</button>
)
}
function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onToggleDiff,
rawMode, onToggleRaw,
showAIChat, onToggleAIChat, inspectorCollapsed, onToggleInspector,
onTrash, onRestore, onArchive, onUnarchive,
}: Omit<BreadcrumbBarProps, 'wordCount' | 'noteStatus'>) {
@@ -68,6 +87,7 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
<GitBranch size={16} />
</button>
)}
<RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />
<button
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground"
style={DISABLED_ICON_STYLE}

View File

@@ -9,6 +9,7 @@ import type { FrontmatterValue } from './Inspector'
import { ResizeHandle } from './ResizeHandle'
import { TabBar } from './TabBar'
import { useDiffMode } from '../hooks/useDiffMode'
import { useRawMode } from '../hooks/useRawMode'
import { useEditorFocus } from '../hooks/useEditorFocus'
import { EditorRightPanel } from './EditorRightPanel'
import { EditorContent } from './EditorContent'
@@ -53,6 +54,7 @@ interface EditorProps {
onUnarchiveNote?: (path: string) => void
onRenameTab?: (path: string, newTitle: string) => void
onContentChange?: (path: string, content: string) => void
onSave?: () => void
/** Called when H1→title sync updates the title (debounced). */
onTitleSync?: (path: string, newTitle: string) => void
canGoBack?: boolean
@@ -61,6 +63,34 @@ interface EditorProps {
onGoForward?: () => void
leftPanelsCollapsed?: boolean
isDarkTheme?: boolean
/** Mutable ref that Editor registers its raw-mode toggle into, for command palette access. */
rawToggleRef?: React.MutableRefObject<() => void>
}
function useEditorModeExclusion({
diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef,
}: {
diffMode: boolean
rawMode: boolean
handleToggleDiff: () => void | Promise<void>
handleToggleRaw: () => void
rawToggleRef?: React.MutableRefObject<() => void>
}) {
const handleToggleDiffExclusive = useCallback(async () => {
if (!diffMode && rawMode) handleToggleRaw()
await handleToggleDiff()
}, [diffMode, rawMode, handleToggleDiff, handleToggleRaw])
const handleToggleRawExclusive = useCallback(() => {
if (!rawMode && diffMode) handleToggleDiff()
handleToggleRaw()
}, [rawMode, diffMode, handleToggleDiff, handleToggleRaw])
useEffect(() => {
if (rawToggleRef) rawToggleRef.current = handleToggleRawExclusive
}, [rawToggleRef, handleToggleRawExclusive])
return { handleToggleDiffExclusive, handleToggleRawExclusive }
}
function EditorEmptyState() {
@@ -81,9 +111,10 @@ export const Editor = memo(function Editor({
showAIChat, onToggleAIChat,
vaultPath,
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
onRenameTab, onContentChange, onTitleSync,
onRenameTab, onContentChange, onSave, onTitleSync,
canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed,
isDarkTheme,
rawToggleRef,
}: EditorProps) {
const vaultPathRef = useRef(vaultPath)
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
@@ -116,6 +147,13 @@ export const Editor = memo(function Editor({
const { diffMode, diffContent, diffLoading, handleToggleDiff, handleViewCommitDiff } = useDiffMode({
activeTabPath, onLoadDiff, onLoadDiffAtCommit,
})
const { rawMode, handleToggleRaw } = useRawMode({ activeTabPath })
const { handleToggleDiffExclusive, handleToggleRawExclusive } = useEditorModeExclusion({
diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef,
})
const isLoadingNewTab = activeTabPath !== null && !activeTab
const activeStatus = activeTab ? getNoteStatus?.(activeTab.entry.path) ?? 'clean' : 'clean'
const showDiffToggle = !!(activeTab && (diffMode || activeStatus === 'modified'))
@@ -149,7 +187,11 @@ export const Editor = memo(function Editor({
diffMode={diffMode}
diffContent={diffContent}
diffLoading={diffLoading}
onToggleDiff={handleToggleDiff}
onToggleDiff={handleToggleDiffExclusive}
rawMode={rawMode}
onToggleRaw={handleToggleRawExclusive}
onRawContentChange={onContentChange}
onSave={onSave}
activeStatus={activeStatus}
showDiffToggle={showDiffToggle}
showAIChat={showAIChat}

View File

@@ -2,6 +2,7 @@ import type { VaultEntry, NoteStatus } from '../types'
import type { useCreateBlockNote } from '@blocknote/react'
import { DiffView } from './DiffView'
import { BreadcrumbBar } from './BreadcrumbBar'
import { RawEditorView } from './RawEditorView'
import { countWords } from '../utils/wikilinks'
import { SingleEditorView } from './SingleEditorView'
@@ -19,6 +20,10 @@ interface EditorContentProps {
diffContent: string | null
diffLoading: boolean
onToggleDiff: () => void
rawMode: boolean
onToggleRaw: () => void
onRawContentChange?: (path: string, content: string) => void
onSave?: () => void
activeStatus: NoteStatus
showDiffToggle: boolean
showAIChat?: boolean
@@ -63,6 +68,28 @@ function DiffModeView({ diffContent, onToggleDiff }: { diffContent: string | nul
)
}
function RawModeEditorSection({
rawMode, activeTab, entries, onContentChange, onSave,
}: {
rawMode: boolean
activeTab: Tab | null
entries: VaultEntry[]
onContentChange?: (path: string, content: string) => void
onSave?: () => void
}) {
if (!rawMode || !activeTab) return null
return (
<RawEditorView
key={activeTab.entry.path}
content={activeTab.content}
path={activeTab.entry.path}
entries={entries}
onContentChange={onContentChange ?? (() => {})}
onSave={onSave ?? (() => {})}
/>
)
}
/** Bind an optional callback to a path, returning undefined if callback is absent */
function bindPath(cb: ((path: string) => void) | undefined, path: string) {
return cb ? () => cb(path) : undefined
@@ -70,7 +97,7 @@ function bindPath(cb: ((path: string) => void) | undefined, path: string) {
function ActiveTabBreadcrumb({ activeTab, props }: {
activeTab: Tab
props: Omit<EditorContentProps, 'activeTab' | 'isLoadingNewTab' | 'entries' | 'editor' | 'onNavigateWikilink' | 'onEditorChange'>
props: Omit<EditorContentProps, 'activeTab' | 'isLoadingNewTab' | 'entries' | 'editor' | 'onNavigateWikilink' | 'onEditorChange' | 'onRawContentChange' | 'onSave'>
}) {
const wordCount = countWords(activeTab.content)
const path = activeTab.entry.path
@@ -83,6 +110,8 @@ function ActiveTabBreadcrumb({ activeTab, props }: {
diffMode={props.diffMode}
diffLoading={props.diffLoading}
onToggleDiff={props.onToggleDiff}
rawMode={props.rawMode}
onToggleRaw={props.onToggleRaw}
showAIChat={props.showAIChat}
onToggleAIChat={props.onToggleAIChat}
inspectorCollapsed={props.inspectorCollapsed}
@@ -98,19 +127,28 @@ function ActiveTabBreadcrumb({ activeTab, props }: {
export function EditorContent({
activeTab, isLoadingNewTab, entries, editor,
diffMode, diffContent, onToggleDiff,
rawMode, onToggleRaw, onRawContentChange, onSave,
onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme,
...breadcrumbProps
}: EditorContentProps) {
const showEditor = !diffMode && !rawMode
return (
<div className="flex flex-1 flex-col min-w-0 min-h-0">
{activeTab && <ActiveTabBreadcrumb activeTab={activeTab} props={{ diffMode, diffContent, onToggleDiff, ...breadcrumbProps }} />}
{activeTab && (
<ActiveTabBreadcrumb
activeTab={activeTab}
props={{ diffMode, diffContent, onToggleDiff, rawMode, onToggleRaw, ...breadcrumbProps }}
/>
)}
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
{!diffMode && activeTab && (
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} />
{showEditor && activeTab && (
<div style={{ display: 'flex', flex: 1, flexDirection: 'column', minHeight: 0 }}>
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} />
</div>
)}
{isLoadingNewTab && !diffMode && <EditorLoadingSkeleton />}
{isLoadingNewTab && showEditor && <EditorLoadingSkeleton />}
</div>
)
}

View File

@@ -0,0 +1,143 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react'
import { RawEditorView } from './RawEditorView'
import { extractWikilinkQuery, detectYamlError } from '../utils/rawEditorUtils'
// Minimal VaultEntry factory
function entry(title: string, path = `/vault/note/${title}.md`) {
return {
path, filename: `${title}.md`, title, isA: 'Note',
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
cadence: null, archived: false, trashed: false, trashedAt: null,
modifiedAt: null, createdAt: null, fileSize: 0, snippet: '', wordCount: 0,
relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, outgoingLinks: [],
}
}
const defaultProps = {
content: '---\ntitle: My Note\n---\n\n# My Note\n\nSome content.',
path: '/vault/note/my-note.md',
entries: [entry('Project Alpha'), entry('Meeting Notes')],
onContentChange: vi.fn(),
onSave: vi.fn(),
}
describe('extractWikilinkQuery', () => {
it('returns null when no [[ trigger', () => {
expect(extractWikilinkQuery('hello world', 5)).toBeNull()
})
it('returns empty string immediately after [[', () => {
const text = 'see [['
expect(extractWikilinkQuery(text, text.length)).toBe('')
})
it('returns query after [[', () => {
const text = 'see [[Proj'
expect(extractWikilinkQuery(text, text.length)).toBe('Proj')
})
it('returns null when ]] closes the link', () => {
const text = '[[Proj]]'
expect(extractWikilinkQuery(text, text.length)).toBeNull()
})
it('returns null when newline is in query', () => {
const text = '[[Proj\ncontinued'
expect(extractWikilinkQuery(text, text.length)).toBeNull()
})
it('handles cursor before end of text', () => {
// cursor at 6 = after "[[Proj" (before the space)
const text = '[[Proj after'
expect(extractWikilinkQuery(text, 6)).toBe('Proj')
})
})
describe('detectYamlError', () => {
it('returns null for content without frontmatter', () => {
expect(detectYamlError('# Title\n\nSome content.')).toBeNull()
})
it('returns null for valid frontmatter', () => {
expect(detectYamlError('---\ntitle: My Note\n---\n\n# Title')).toBeNull()
})
it('returns error for unclosed frontmatter', () => {
const error = detectYamlError('---\ntitle: My Note\n\n# Title')
expect(error).toContain('Unclosed frontmatter')
})
it('returns error for tab indentation in frontmatter', () => {
const error = detectYamlError('---\n\ttitle: My Note\n---\n')
expect(error).toContain('tab indentation')
})
it('returns null for content not starting with ---', () => {
expect(detectYamlError('Not frontmatter')).toBeNull()
})
})
describe('RawEditorView', () => {
it('renders textarea with the provided content', () => {
render(<RawEditorView {...defaultProps} />)
const textarea = screen.getByTestId('raw-editor-textarea')
expect(textarea).toBeInTheDocument()
expect((textarea as HTMLTextAreaElement).value).toBe(defaultProps.content)
})
it('calls onContentChange when user types (debounced)', async () => {
vi.useFakeTimers()
const onContentChange = vi.fn()
render(<RawEditorView {...defaultProps} onContentChange={onContentChange} />)
const textarea = screen.getByTestId('raw-editor-textarea')
fireEvent.change(textarea, { target: { value: '---\ntitle: Changed\n---\n\n# Changed' } })
// Should not be called immediately
expect(onContentChange).not.toHaveBeenCalled()
// After debounce
await act(async () => { vi.advanceTimersByTime(600) })
expect(onContentChange).toHaveBeenCalledWith(
defaultProps.path,
'---\ntitle: Changed\n---\n\n# Changed'
)
vi.useRealTimers()
})
it('shows YAML error banner for unclosed frontmatter', () => {
render(<RawEditorView {...defaultProps} content="---\ntitle: Bad\n\n# Title" />)
expect(screen.getByTestId('raw-editor-yaml-error')).toBeInTheDocument()
expect(screen.getByTestId('raw-editor-yaml-error')).toHaveTextContent('Unclosed frontmatter')
})
it('does not show YAML error for valid content', () => {
render(<RawEditorView {...defaultProps} />)
expect(screen.queryByTestId('raw-editor-yaml-error')).not.toBeInTheDocument()
})
it('calls onSave when Cmd+S is pressed', () => {
const onSave = vi.fn()
render(<RawEditorView {...defaultProps} onSave={onSave} />)
const textarea = screen.getByTestId('raw-editor-textarea')
fireEvent.keyDown(textarea, { key: 's', metaKey: true })
expect(onSave).toHaveBeenCalledOnce()
})
it('calls onSave when Ctrl+S is pressed', () => {
const onSave = vi.fn()
render(<RawEditorView {...defaultProps} onSave={onSave} />)
const textarea = screen.getByTestId('raw-editor-textarea')
fireEvent.keyDown(textarea, { key: 's', ctrlKey: true })
expect(onSave).toHaveBeenCalledOnce()
})
it('has monospaced font family applied', () => {
render(<RawEditorView {...defaultProps} />)
const textarea = screen.getByTestId('raw-editor-textarea') as HTMLTextAreaElement
expect(textarea.style.fontFamily).toContain('monospace')
})
})

View File

@@ -0,0 +1,276 @@
import { useRef, useState, useCallback, useEffect, useMemo } from 'react'
import { preFilterWikilinks, deduplicateByPath, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
import { attachClickHandlers, enrichSuggestionItems } from '../utils/suggestionEnrichment'
import { buildTypeEntryMap } from '../utils/typeColors'
import { NoteSearchList } from './NoteSearchList'
import { extractWikilinkQuery, detectYamlError } from '../utils/rawEditorUtils'
import type { WikilinkSuggestionItem } from './WikilinkSuggestionMenu'
import type { VaultEntry } from '../types'
/** Get approximate pixel coordinates of the cursor in a textarea. */
function getCaretCoordinates(
textarea: HTMLTextAreaElement,
position: number,
): { top: number; left: number } {
const mirror = document.createElement('div')
const style = getComputedStyle(textarea)
const props = [
'boxSizing', 'width', 'borderTopWidth', 'borderRightWidth',
'borderBottomWidth', 'borderLeftWidth',
'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
'fontStyle', 'fontVariant', 'fontWeight', 'fontSize', 'lineHeight',
'fontFamily', 'textTransform', 'letterSpacing', 'wordSpacing',
] as const
for (const prop of props) {
(mirror.style as unknown as Record<string, string>)[prop] = style[prop] as string
}
mirror.style.position = 'absolute'
mirror.style.visibility = 'hidden'
mirror.style.top = '0'
mirror.style.left = '-9999px'
mirror.style.whiteSpace = 'pre-wrap'
mirror.style.wordWrap = 'break-word'
mirror.style.overflow = 'hidden'
mirror.textContent = textarea.value.slice(0, position)
const caret = document.createElement('span')
caret.textContent = '\u200B'
mirror.appendChild(caret)
document.body.appendChild(mirror)
const caretRect = caret.getBoundingClientRect()
const mirrorRect = mirror.getBoundingClientRect()
document.body.removeChild(mirror)
const textareaRect = textarea.getBoundingClientRect()
return {
top: caretRect.top - mirrorRect.top + textareaRect.top - textarea.scrollTop,
left: caretRect.left - mirrorRect.left + textareaRect.left,
}
}
interface AutocompleteState {
caretTop: number
caretLeft: number
selectedIndex: number
items: WikilinkSuggestionItem[]
}
interface RawEditorViewProps {
content: string
path: string
entries: VaultEntry[]
onContentChange: (path: string, content: string) => void
onSave: () => void
}
const FONT_FAMILY = '"Berkeley Mono", "JetBrains Mono", "Fira Mono", ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
const DEBOUNCE_MS = 500
const DROPDOWN_MAX_HEIGHT = 200
export function RawEditorView({ content, path, entries, onContentChange, onSave }: RawEditorViewProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pathRef = useRef(path)
const onContentChangeRef = useRef(onContentChange)
const onSaveRef = useRef(onSave)
useEffect(() => { pathRef.current = path }, [path])
useEffect(() => { onContentChangeRef.current = onContentChange }, [onContentChange])
useEffect(() => { onSaveRef.current = onSave }, [onSave])
const [value, setValue] = useState(content)
const [autocomplete, setAutocomplete] = useState<AutocompleteState | null>(null)
const [yamlError, setYamlError] = useState<string | null>(() => detectYamlError(content))
// NOTE: tab-switch reset is handled via key={path} in the parent (EditorContent)
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const baseItems = useMemo(
() => deduplicateByPath(entries.map(entry => ({
title: entry.title,
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
group: entry.isA || 'Note',
entryTitle: entry.title,
path: entry.path,
}))),
[entries],
)
/** Insert [[entryTitle]] at the current [[ trigger position */
const insertWikilink = useCallback((entryTitle: string) => {
const textarea = textareaRef.current
if (!textarea) return
const cursor = textarea.selectionStart
const text = textarea.value
const before = text.slice(0, cursor)
const triggerIdx = before.lastIndexOf('[[')
if (triggerIdx === -1) return
const after = text.slice(cursor)
const newText = `${text.slice(0, triggerIdx)}[[${entryTitle}]]${after}`
const newCursor = triggerIdx + entryTitle.length + 4
setValue(newText)
setAutocomplete(null)
// Flush immediately — autocomplete inserts should not be debounced
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = null
onContentChangeRef.current(pathRef.current, newText)
requestAnimationFrame(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart = newCursor
textareaRef.current.selectionEnd = newCursor
textareaRef.current.focus()
}
})
}, [])
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value
const cursor = e.target.selectionStart ?? 0
setValue(newValue)
setYamlError(detectYamlError(newValue))
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
onContentChangeRef.current(pathRef.current, newValue)
}, DEBOUNCE_MS)
const query = extractWikilinkQuery(newValue, cursor)
if (query === null || query.length < MIN_QUERY_LENGTH) {
setAutocomplete(null)
return
}
const textarea = e.target
const coords = getCaretCoordinates(textarea, cursor)
const candidates = preFilterWikilinks(baseItems, query)
const withHandlers = attachClickHandlers(candidates, insertWikilink)
const items = enrichSuggestionItems(withHandlers, query, typeEntryMap)
setAutocomplete({ caretTop: coords.top, caretLeft: coords.left, selectedIndex: 0, items })
}, [baseItems, typeEntryMap, insertWikilink])
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Save shortcut
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault()
if (debounceRef.current) {
clearTimeout(debounceRef.current)
debounceRef.current = null
onContentChangeRef.current(pathRef.current, textareaRef.current?.value ?? '')
}
onSaveRef.current()
return
}
if (!autocomplete) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setAutocomplete(prev => prev
? { ...prev, selectedIndex: Math.min(prev.selectedIndex + 1, prev.items.length - 1) }
: null)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setAutocomplete(prev => prev
? { ...prev, selectedIndex: Math.max(prev.selectedIndex - 1, 0) }
: null)
} else if (e.key === 'Enter') {
e.preventDefault()
const item = autocomplete.items[autocomplete.selectedIndex]
if (item) insertWikilink(item.entryTitle ?? item.title)
} else if (e.key === 'Escape') {
e.preventDefault()
setAutocomplete(null)
}
}, [autocomplete, insertWikilink])
const closeAutocomplete = useCallback(() => setAutocomplete(null), [])
// Flush pending debounce on unmount (e.g. switching tabs while in raw mode)
useEffect(() => {
const pendingPath = pathRef
const pendingChange = onContentChangeRef
const pendingDebounce = debounceRef
const pendingTextarea = textareaRef
return () => {
if (pendingDebounce.current) {
clearTimeout(pendingDebounce.current)
pendingChange.current(pendingPath.current, pendingTextarea.current?.value ?? '')
}
}
}, [])
const dropdownBelow = autocomplete
? autocomplete.caretTop + 20 + DROPDOWN_MAX_HEIGHT <= window.innerHeight
: true
const dropdownTop = autocomplete
? (dropdownBelow ? autocomplete.caretTop + 20 : autocomplete.caretTop - DROPDOWN_MAX_HEIGHT - 4)
: 0
const dropdownLeft = autocomplete
? Math.min(autocomplete.caretLeft, window.innerWidth - 260)
: 0
return (
<div className="flex flex-1 flex-col min-h-0 relative" style={{ background: 'var(--background)' }}>
{yamlError && (
<div
className="flex items-center gap-2 px-4 py-2 text-xs border-b shrink-0"
style={{ background: '#fef3c7', borderColor: '#d97706', color: '#92400e' }}
role="alert"
data-testid="raw-editor-yaml-error"
>
<span style={{ fontWeight: 600 }}>YAML error:</span>
<span>{yamlError}</span>
</div>
)}
<textarea
ref={textareaRef}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onClick={closeAutocomplete}
className="flex-1 resize-none border-none outline-none p-8"
style={{
fontFamily: FONT_FAMILY,
fontSize: 13,
lineHeight: 1.6,
background: 'var(--background)',
color: 'var(--foreground)',
tabSize: 2,
minHeight: 0,
}}
spellCheck={false}
aria-label="Raw editor"
data-testid="raw-editor-textarea"
/>
{autocomplete && autocomplete.items.length > 0 && (
<div
className="fixed z-50 min-w-64 max-w-xs rounded-md border shadow-lg overflow-auto"
style={{
top: dropdownTop,
left: dropdownLeft,
maxHeight: DROPDOWN_MAX_HEIGHT,
background: 'var(--popover)',
borderColor: 'var(--border)',
}}
data-testid="raw-editor-wikilink-dropdown"
>
<NoteSearchList
items={autocomplete.items}
selectedIndex={autocomplete.selectedIndex}
getItemKey={(item, i) => `${item.title}-${item.path ?? i}`}
onItemClick={(item) => insertWikilink(item.entryTitle ?? item.title)}
onItemHover={(i) => setAutocomplete(prev => prev ? { ...prev, selectedIndex: i } : null)}
/>
</div>
)}
</div>
)
}

View File

@@ -33,6 +33,7 @@ interface AppCommandsConfig {
onCommitPush: () => void
onSetViewMode: (mode: ViewMode) => void
onToggleInspector: () => void
onToggleRawEditor?: () => void
onZoomIn: () => void
onZoomOut: () => void
onZoomReset: () => void
@@ -129,6 +130,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onCommitPush: config.onCommitPush,
onSetViewMode: config.onSetViewMode,
onToggleInspector: config.onToggleInspector,
onToggleRawEditor: config.onToggleRawEditor,
onZoomIn: config.onZoomIn,
onZoomOut: config.onZoomOut,
onZoomReset: config.onZoomReset,

View File

@@ -156,6 +156,36 @@ describe('useCommandRegistry', () => {
expect(onTrashNote).toHaveBeenCalledWith('/vault/note/test.md')
})
it('has toggle-raw-editor command in View group', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
const cmd = result.current.find(c => c.id === 'toggle-raw-editor')
expect(cmd).toBeDefined()
expect(cmd!.group).toBe('View')
})
it('disables toggle-raw-editor when no note is open', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ activeTabPath: null })))
const cmd = result.current.find(c => c.id === 'toggle-raw-editor')
expect(cmd!.enabled).toBe(false)
})
it('enables toggle-raw-editor when a note is open', () => {
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md' })),
)
const cmd = result.current.find(c => c.id === 'toggle-raw-editor')
expect(cmd!.enabled).toBe(true)
})
it('calls onToggleRawEditor when toggle-raw-editor executes', () => {
const onToggleRawEditor = vi.fn()
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', onToggleRawEditor })),
)
result.current.find(c => c.id === 'toggle-raw-editor')!.execute()
expect(onToggleRawEditor).toHaveBeenCalledOnce()
})
it('disables commit when no modified files', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ modifiedCount: 0 })))
expect(result.current.find(c => c.id === 'commit-push')!.enabled).toBe(false)

View File

@@ -32,6 +32,7 @@ interface CommandRegistryConfig {
onCommitPush: () => void
onSetViewMode: (mode: ViewMode) => void
onToggleInspector: () => void
onToggleRawEditor?: () => void
onToggleAIChat?: () => void
onZoomIn: () => void
onZoomOut: () => void
@@ -102,6 +103,30 @@ export function buildTypeCommands(
})
}
export function buildViewCommands(
hasActiveNote: boolean,
onSetViewMode: (mode: ViewMode) => void,
onToggleInspector: () => void,
onToggleRawEditor: (() => void) | undefined,
onToggleAIChat: (() => void) | undefined,
zoomLevel: number,
onZoomIn: () => void,
onZoomOut: () => void,
onZoomReset: () => void,
): CommandAction[] {
return [
{ id: 'view-editor', label: 'Editor Only', group: 'View', shortcut: '⌘1', keywords: ['layout', 'focus'], enabled: true, execute: () => onSetViewMode('editor-only') },
{ id: 'view-editor-list', label: 'Editor + Note List', group: 'View', shortcut: '⌘2', keywords: ['layout'], enabled: true, execute: () => onSetViewMode('editor-list') },
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
{ id: 'toggle-inspector', label: 'Toggle Inspector', group: 'View', keywords: ['properties', 'panel', 'right'], enabled: true, execute: onToggleInspector },
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote, execute: () => onToggleRawEditor?.() },
{ id: 'toggle-ai-chat', label: 'Toggle AI Chat', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
{ id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: '⌘0', keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset },
]
}
export function buildThemeCommands(
themes: ThemeFile[] | undefined,
activeThemeId: string | null | undefined,
@@ -130,7 +155,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
activeTabPath, entries, modifiedCount,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onSetViewMode, onToggleInspector, onToggleAIChat, onOpenVault,
onCommitPush, onSetViewMode, onToggleInspector, onToggleRawEditor, onToggleAIChat, onOpenVault,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onOpenDailyNote, onCloseTab,
onGoBack, onGoForward, canGoBack, canGoForward,
@@ -181,14 +206,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
{ id: 'view-changes', label: 'View Pending Changes', group: 'Git', keywords: ['modified', 'diff'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
// View
{ id: 'view-editor', label: 'Editor Only', group: 'View', shortcut: '⌘1', keywords: ['layout', 'focus'], enabled: true, execute: () => onSetViewMode('editor-only') },
{ id: 'view-editor-list', label: 'Editor + Note List', group: 'View', shortcut: '⌘2', keywords: ['layout'], enabled: true, execute: () => onSetViewMode('editor-list') },
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
{ id: 'toggle-inspector', label: 'Toggle Inspector', group: 'View', keywords: ['properties', 'panel', 'right'], enabled: true, execute: onToggleInspector },
{ id: 'toggle-ai-chat', label: 'Toggle AI Chat', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
{ id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: '⌘0', keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset },
...buildViewCommands(hasActiveNote, onSetViewMode, onToggleInspector, onToggleRawEditor, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset),
// Appearance
...buildThemeCommands(themes, activeThemeId, onSwitchTheme, onCreateTheme),
@@ -206,7 +224,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
hasActiveNote, activeTabPath, isArchived, isTrashed, modifiedCount,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onSetViewMode, onToggleInspector, onToggleAIChat, onOpenVault,
onCommitPush, onSetViewMode, onToggleInspector, onToggleRawEditor, onToggleAIChat, onOpenVault,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onOpenDailyNote, onCloseTab,
onGoBack, onGoForward, canGoBack, canGoForward,

View File

@@ -0,0 +1,87 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useRawMode } from './useRawMode'
describe('useRawMode', () => {
let onFlushPending: ReturnType<typeof vi.fn>
beforeEach(() => {
onFlushPending = vi.fn().mockResolvedValue(true)
})
function renderRawHook(activeTabPath: string | null = '/note.md') {
return renderHook(
({ path }) => useRawMode({ activeTabPath: path, onFlushPending }),
{ initialProps: { path: activeTabPath } },
)
}
it('starts with raw mode off', () => {
const { result } = renderRawHook()
expect(result.current.rawMode).toBe(false)
})
it('toggles raw mode on', async () => {
const { result } = renderRawHook()
await act(async () => { await result.current.handleToggleRaw() })
expect(result.current.rawMode).toBe(true)
})
it('flushes pending edits when activating raw mode', async () => {
const { result } = renderRawHook()
await act(async () => { await result.current.handleToggleRaw() })
expect(onFlushPending).toHaveBeenCalledOnce()
})
it('does not flush pending edits when deactivating raw mode', async () => {
const { result } = renderRawHook()
await act(async () => { await result.current.handleToggleRaw() })
onFlushPending.mockClear()
await act(async () => { await result.current.handleToggleRaw() })
expect(onFlushPending).not.toHaveBeenCalled()
})
it('toggles raw mode off when already on', async () => {
const { result } = renderRawHook()
await act(async () => { await result.current.handleToggleRaw() })
expect(result.current.rawMode).toBe(true)
await act(async () => { await result.current.handleToggleRaw() })
expect(result.current.rawMode).toBe(false)
})
it('resets raw mode when activeTabPath changes', async () => {
const { result, rerender } = renderRawHook('/note-a.md')
await act(async () => { await result.current.handleToggleRaw() })
expect(result.current.rawMode).toBe(true)
rerender({ path: '/note-b.md' })
expect(result.current.rawMode).toBe(false)
})
it('works without onFlushPending callback', async () => {
const { result } = renderHook(() => useRawMode({ activeTabPath: '/note.md' }))
await act(async () => { await result.current.handleToggleRaw() })
expect(result.current.rawMode).toBe(true)
})
it('does not activate raw mode when activeTabPath is null', async () => {
const { result } = renderRawHook(null)
await act(async () => { await result.current.handleToggleRaw() })
// Cannot activate raw mode without an active tab path
expect(result.current.rawMode).toBe(false)
})
})

29
src/hooks/useRawMode.ts Normal file
View File

@@ -0,0 +1,29 @@
import { useState, useCallback } from 'react'
interface UseRawModeParams {
activeTabPath: string | null
/** Flush pending WYSIWYG edits to disk before entering raw mode. */
onFlushPending?: () => Promise<boolean>
}
/**
* Manages raw editor mode state.
* Raw mode is automatically inactive when the active tab changes,
* because rawMode is derived from whether the stored path matches the current tab.
*/
export function useRawMode({ activeTabPath, onFlushPending }: UseRawModeParams) {
// Track which path has raw mode active — automatically deactivates on tab switch
const [rawActivePath, setRawActivePath] = useState<string | null>(null)
const rawMode = rawActivePath !== null && rawActivePath === activeTabPath
const handleToggleRaw = useCallback(async () => {
if (rawMode) {
setRawActivePath(null)
} else {
await onFlushPending?.()
setRawActivePath(activeTabPath)
}
}, [rawMode, activeTabPath, onFlushPending])
return { rawMode, handleToggleRaw }
}

View File

@@ -0,0 +1,21 @@
/** Extract the wikilink query that the user is currently typing after [[ */
export function extractWikilinkQuery(text: string, cursor: number): string | null {
const before = text.slice(0, cursor)
const triggerIdx = before.lastIndexOf('[[')
if (triggerIdx === -1) return null
const afterTrigger = before.slice(triggerIdx + 2)
// Don't trigger if the query contains ] (already closed) or a newline
if (afterTrigger.includes(']') || afterTrigger.includes('\n')) return null
return afterTrigger
}
/** Basic YAML frontmatter structural checks. */
export function detectYamlError(content: string): string | null {
if (!content.startsWith('---')) return null
const rest = content.slice(3)
const closeIdx = rest.search(/\n---(\n|$)/)
if (closeIdx === -1) return 'Unclosed frontmatter block — add a closing --- line'
const block = rest.slice(0, closeIdx)
if (/^\t/m.test(block)) return 'YAML frontmatter contains tab indentation — use spaces'
return null
}