2026-03-02 18:38:25 +01:00
|
|
|
import { useRef, useState, useCallback, useEffect, useMemo } from 'react'
|
feat: implement PostHog event tracking plan
Add trackEvent() calls for all user actions in the tracking plan:
- Vault: vault_opened (with has_git, note_count), vault_switched
- Notes: note_created (with has_type, creation_path), note_deleted,
note_trashed, note_archived, note_favorited, note_unfavorited
- Git: commit_made, sync_triggered
- Search: search_used
- Editor: raw_mode_toggled, wikilink_inserted
- Types & Views: type_created, view_created
- Telemetry: telemetry_opted_in, telemetry_opted_out
All events gated by PostHog initialization (only fires when opted in).
No PII in any event properties — counts, booleans, and enums only.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:42:43 +02:00
|
|
|
import { trackEvent } from '../lib/telemetry'
|
2026-03-03 18:42:05 +01:00
|
|
|
import type { EditorView } from '@codemirror/view'
|
2026-04-10 19:57:21 +02:00
|
|
|
import { MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
|
2026-03-02 18:38:25 +01:00
|
|
|
import { buildTypeEntryMap } from '../utils/typeColors'
|
|
|
|
|
import { NoteSearchList } from './NoteSearchList'
|
2026-04-10 19:57:21 +02:00
|
|
|
import {
|
|
|
|
|
buildRawEditorAutocompleteState,
|
|
|
|
|
buildRawEditorBaseItems,
|
|
|
|
|
detectYamlError,
|
|
|
|
|
extractWikilinkQuery,
|
|
|
|
|
getRawEditorDropdownPosition,
|
|
|
|
|
replaceActiveWikilinkQuery,
|
|
|
|
|
type RawEditorAutocompleteState,
|
|
|
|
|
} from '../utils/rawEditorUtils'
|
2026-03-03 18:42:05 +01:00
|
|
|
import { useCodeMirror } from '../hooks/useCodeMirror'
|
2026-03-02 18:38:25 +01:00
|
|
|
import type { VaultEntry } from '../types'
|
|
|
|
|
|
2026-03-03 18:42:05 +01:00
|
|
|
export interface RawEditorViewProps {
|
2026-03-02 18:38:25 +01:00
|
|
|
content: string
|
|
|
|
|
path: string
|
|
|
|
|
entries: VaultEntry[]
|
|
|
|
|
onContentChange: (path: string, content: string) => void
|
2026-03-31 18:15:17 +02:00
|
|
|
vaultPath?: string
|
2026-03-02 18:38:25 +01:00
|
|
|
onSave: () => void
|
2026-03-11 22:14:20 +01:00
|
|
|
/** Mutable ref updated on every keystroke with the latest doc string.
|
|
|
|
|
* Allows the parent to flush debounced content before unmount. */
|
|
|
|
|
latestContentRef?: React.MutableRefObject<string | null>
|
2026-03-02 18:38:25 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const DEBOUNCE_MS = 500
|
|
|
|
|
const DROPDOWN_MAX_HEIGHT = 200
|
|
|
|
|
|
2026-03-31 18:15:17 +02:00
|
|
|
export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef, vaultPath }: RawEditorViewProps) {
|
2026-03-03 18:42:05 +01:00
|
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
2026-03-02 18:38:25 +01:00
|
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
|
|
|
const pathRef = useRef(path)
|
|
|
|
|
const onContentChangeRef = useRef(onContentChange)
|
|
|
|
|
const onSaveRef = useRef(onSave)
|
2026-03-03 18:42:05 +01:00
|
|
|
const latestDocRef = useRef(content)
|
2026-03-02 18:38:25 +01:00
|
|
|
useEffect(() => { pathRef.current = path }, [path])
|
2026-03-11 22:14:20 +01:00
|
|
|
// Expose latest doc content to parent via ref
|
|
|
|
|
useEffect(() => { if (latestContentRef) latestContentRef.current = content }, [latestContentRef, content])
|
2026-03-02 18:38:25 +01:00
|
|
|
useEffect(() => { onContentChangeRef.current = onContentChange }, [onContentChange])
|
|
|
|
|
useEffect(() => { onSaveRef.current = onSave }, [onSave])
|
|
|
|
|
|
2026-04-10 19:57:21 +02:00
|
|
|
const [autocomplete, setAutocomplete] = useState<RawEditorAutocompleteState | null>(null)
|
2026-03-02 18:38:25 +01:00
|
|
|
const [yamlError, setYamlError] = useState<string | null>(() => detectYamlError(content))
|
|
|
|
|
|
|
|
|
|
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
|
|
|
|
|
2026-04-10 19:57:21 +02:00
|
|
|
const baseItems = useMemo(() => buildRawEditorBaseItems(entries), [entries])
|
2026-03-02 18:38:25 +01:00
|
|
|
|
2026-04-10 19:57:21 +02:00
|
|
|
const insertWikilinkRef = useRef<(target: string) => void>(() => {})
|
2026-03-02 18:38:25 +01:00
|
|
|
|
2026-03-11 22:14:20 +01:00
|
|
|
const latestContentRefStable = useRef(latestContentRef)
|
|
|
|
|
useEffect(() => { latestContentRefStable.current = latestContentRef }, [latestContentRef])
|
|
|
|
|
|
2026-03-03 18:42:05 +01:00
|
|
|
const handleDocChange = useCallback((doc: string) => {
|
|
|
|
|
latestDocRef.current = doc
|
2026-03-11 22:14:20 +01:00
|
|
|
if (latestContentRefStable.current) latestContentRefStable.current.current = doc
|
2026-03-03 18:42:05 +01:00
|
|
|
setYamlError(detectYamlError(doc))
|
2026-03-02 18:38:25 +01:00
|
|
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
|
|
|
debounceRef.current = setTimeout(() => {
|
2026-03-03 18:42:05 +01:00
|
|
|
onContentChangeRef.current(pathRef.current, doc)
|
2026-03-02 18:38:25 +01:00
|
|
|
}, DEBOUNCE_MS)
|
2026-03-03 18:42:05 +01:00
|
|
|
}, [])
|
2026-03-02 18:38:25 +01:00
|
|
|
|
2026-03-03 18:42:05 +01:00
|
|
|
const handleCursorActivity = useCallback((view: EditorView) => {
|
|
|
|
|
const doc = view.state.doc.toString()
|
|
|
|
|
const cursor = view.state.selection.main.head
|
|
|
|
|
const query = extractWikilinkQuery(doc, cursor)
|
2026-03-02 18:38:25 +01:00
|
|
|
if (query === null || query.length < MIN_QUERY_LENGTH) {
|
|
|
|
|
setAutocomplete(null)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-10 19:57:21 +02:00
|
|
|
const nextAutocomplete = buildRawEditorAutocompleteState({
|
|
|
|
|
view,
|
|
|
|
|
baseItems,
|
|
|
|
|
query,
|
|
|
|
|
typeEntryMap,
|
|
|
|
|
onInsertTarget: (target: string) => insertWikilinkRef.current(target),
|
|
|
|
|
vaultPath: vaultPath ?? '',
|
|
|
|
|
})
|
|
|
|
|
setAutocomplete(nextAutocomplete)
|
2026-03-31 18:15:17 +02:00
|
|
|
}, [baseItems, typeEntryMap, vaultPath])
|
2026-03-02 18:38:25 +01:00
|
|
|
|
2026-03-03 18:42:05 +01:00
|
|
|
const handleSave = useCallback(() => {
|
|
|
|
|
if (debounceRef.current) {
|
|
|
|
|
clearTimeout(debounceRef.current)
|
|
|
|
|
debounceRef.current = null
|
|
|
|
|
onContentChangeRef.current(pathRef.current, latestDocRef.current)
|
2026-03-02 18:38:25 +01:00
|
|
|
}
|
2026-03-03 18:42:05 +01:00
|
|
|
onSaveRef.current()
|
|
|
|
|
}, [])
|
2026-03-02 18:38:25 +01:00
|
|
|
|
2026-03-03 18:42:05 +01:00
|
|
|
const handleEscape = useCallback(() => {
|
|
|
|
|
if (autocomplete) { setAutocomplete(null); return true }
|
|
|
|
|
return false
|
|
|
|
|
}, [autocomplete])
|
|
|
|
|
|
refactor: remove theming system entirely
Remove all theme switching, creation, and management functionality
from the app — backend (Rust theme module, Tauri commands, menu items,
startup tasks, vault seeding), frontend (useThemeManager, ThemePropertyEditor,
themeSchema, command palette Appearance group, settings panel appearance
section, isDarkTheme prop chain), mock data, and all related tests.
Editor styling via theme.json/EditorTheme.css is preserved (not user theming).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 19:57:58 +01:00
|
|
|
const viewRef = useCodeMirror(containerRef, content, {
|
2026-03-03 18:42:05 +01:00
|
|
|
onDocChange: handleDocChange,
|
|
|
|
|
onCursorActivity: handleCursorActivity,
|
|
|
|
|
onSave: handleSave,
|
|
|
|
|
onEscape: handleEscape,
|
|
|
|
|
})
|
2026-03-02 18:38:25 +01:00
|
|
|
|
2026-04-10 19:57:21 +02:00
|
|
|
const insertWikilink = useCallback((target: string) => {
|
2026-03-03 18:42:05 +01:00
|
|
|
const view = viewRef.current
|
|
|
|
|
if (!view) return
|
|
|
|
|
const cursor = view.state.selection.main.head
|
|
|
|
|
const doc = view.state.doc.toString()
|
2026-04-10 19:57:21 +02:00
|
|
|
const replacement = replaceActiveWikilinkQuery(doc, cursor, target)
|
|
|
|
|
if (!replacement) return
|
2026-03-03 18:42:05 +01:00
|
|
|
|
|
|
|
|
view.dispatch({
|
2026-04-10 19:57:21 +02:00
|
|
|
changes: { from: 0, to: doc.length, insert: replacement.text },
|
|
|
|
|
selection: { anchor: replacement.cursor },
|
2026-03-03 18:42:05 +01:00
|
|
|
})
|
feat: implement PostHog event tracking plan
Add trackEvent() calls for all user actions in the tracking plan:
- Vault: vault_opened (with has_git, note_count), vault_switched
- Notes: note_created (with has_type, creation_path), note_deleted,
note_trashed, note_archived, note_favorited, note_unfavorited
- Git: commit_made, sync_triggered
- Search: search_used
- Editor: raw_mode_toggled, wikilink_inserted
- Types & Views: type_created, view_created
- Telemetry: telemetry_opted_in, telemetry_opted_out
All events gated by PostHog initialization (only fires when opted in).
No PII in any event properties — counts, booleans, and enums only.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:42:43 +02:00
|
|
|
trackEvent('wikilink_inserted')
|
2026-03-03 18:42:05 +01:00
|
|
|
setAutocomplete(null)
|
|
|
|
|
|
|
|
|
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
|
|
|
debounceRef.current = null
|
2026-04-10 19:57:21 +02:00
|
|
|
latestDocRef.current = replacement.text
|
|
|
|
|
onContentChangeRef.current(pathRef.current, replacement.text)
|
2026-03-03 18:42:05 +01:00
|
|
|
|
|
|
|
|
view.focus()
|
|
|
|
|
}, [viewRef])
|
|
|
|
|
|
|
|
|
|
useEffect(() => { insertWikilinkRef.current = insertWikilink }, [insertWikilink])
|
|
|
|
|
|
|
|
|
|
const handleAutocompleteKey = useCallback((e: React.KeyboardEvent) => {
|
|
|
|
|
if (!autocomplete) return
|
2026-03-02 18:38:25 +01:00
|
|
|
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]
|
2026-04-10 19:57:21 +02:00
|
|
|
if (item) item.onItemClick()
|
2026-03-02 18:38:25 +01:00
|
|
|
}
|
2026-04-10 19:57:21 +02:00
|
|
|
}, [autocomplete])
|
2026-03-02 18:38:25 +01:00
|
|
|
|
2026-03-03 18:42:05 +01:00
|
|
|
// Flush pending debounce on unmount
|
2026-03-02 18:38:25 +01:00
|
|
|
useEffect(() => {
|
|
|
|
|
return () => {
|
2026-03-03 18:42:05 +01:00
|
|
|
if (debounceRef.current) {
|
|
|
|
|
clearTimeout(debounceRef.current)
|
|
|
|
|
onContentChangeRef.current(pathRef.current, latestDocRef.current)
|
2026-03-02 18:38:25 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [])
|
|
|
|
|
|
2026-04-10 19:57:21 +02:00
|
|
|
const dropdownPosition = getRawEditorDropdownPosition(autocomplete, DROPDOWN_MAX_HEIGHT, window)
|
2026-03-02 18:38:25 +01:00
|
|
|
|
|
|
|
|
return (
|
2026-03-03 18:42:05 +01:00
|
|
|
<div className="flex flex-1 flex-col min-h-0 relative" style={{ background: 'var(--background)' }} onKeyDown={handleAutocompleteKey} role="presentation">
|
2026-03-02 18:38:25 +01:00
|
|
|
{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>
|
|
|
|
|
)}
|
2026-03-03 18:42:05 +01:00
|
|
|
<div
|
|
|
|
|
ref={containerRef}
|
|
|
|
|
className="flex flex-1 min-h-0"
|
|
|
|
|
data-testid="raw-editor-codemirror"
|
2026-03-02 18:38:25 +01:00
|
|
|
aria-label="Raw editor"
|
|
|
|
|
/>
|
|
|
|
|
{autocomplete && autocomplete.items.length > 0 && (
|
|
|
|
|
<div
|
|
|
|
|
className="fixed z-50 min-w-64 max-w-xs rounded-md border shadow-lg overflow-auto"
|
|
|
|
|
style={{
|
2026-04-10 19:57:21 +02:00
|
|
|
top: dropdownPosition.top,
|
|
|
|
|
left: dropdownPosition.left,
|
2026-03-02 18:38:25 +01:00
|
|
|
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}`}
|
2026-04-10 19:57:21 +02:00
|
|
|
onItemClick={(item) => item.onItemClick()}
|
2026-03-02 18:38:25 +01:00
|
|
|
onItemHover={(i) => setAutocomplete(prev => prev ? { ...prev, selectedIndex: i } : null)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|