diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index fe2f5dd1..1a0cd396 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -12,6 +12,7 @@ import { ChatSidebar } from './components/chat-sidebar'; import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions'; import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view'; +import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view'; import { useDebounce } from './hooks/use-debounce'; import { SidebarContentPanel } from '@/components/sidebar-content'; import { SidebarSectionProvider } from '@/contexts/sidebar-context'; @@ -46,7 +47,7 @@ import { import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { Toaster } from "@/components/ui/sonner" import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' -import { splitFrontmatter, joinFrontmatter, extractTags } from '@/lib/frontmatter' +import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter' import { OnboardingModal } from '@/components/onboarding-modal' import { SearchDialog } from '@/components/search-dialog' import { BackgroundTaskDetail } from '@/components/background-task-detail' @@ -106,6 +107,7 @@ const TITLEBAR_TOGGLE_MARGIN_LEFT_PX = 12 const TITLEBAR_BUTTONS_COLLAPSED = 5 const TITLEBAR_BUTTON_GAPS_COLLAPSED = 4 const GRAPH_TAB_PATH = '__rowboat_graph_view__' +const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__' const clampNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)) @@ -233,6 +235,7 @@ const getAncestorDirectoryPaths = (path: string): string[] => { } const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH +const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH const normalizeUsage = (usage?: Partial | null): LanguageModelUsage | null => { if (!usage) return null @@ -470,6 +473,7 @@ function App() { const [recentWikiFiles, setRecentWikiFiles] = useState([]) const [isGraphOpen, setIsGraphOpen] = useState(false) const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean } | null>(null) + const [baseConfigByPath, setBaseConfigByPath] = useState>({}) const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({ nodes: [], edges: [], @@ -510,9 +514,8 @@ function App() { const initialContentRef = useRef('') const renameInProgressRef = useRef(false) - // Frontmatter state: store raw frontmatter per file path, tags for active file + // Frontmatter state: store raw frontmatter per file path const frontmatterByPathRef = useRef>(new Map()) - const [activeFileTags, setActiveFileTags] = useState([]) // Version history state const [versionHistoryPath, setVersionHistoryPath] = useState(null) @@ -621,6 +624,8 @@ function App() { const getFileTabTitle = useCallback((tab: FileTab) => { if (isGraphTabPath(tab.path)) return 'Graph View' + if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases' + if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base' return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path }, []) @@ -818,20 +823,45 @@ function App() { } }, [runId, processingRunIds]) - // Load directory tree + // Load directory tree (knowledge + bases) const loadDirectory = useCallback(async () => { try { - const result = await window.ipc.invoke('workspace:readdir', { - path: 'knowledge', - opts: { recursive: true, includeHidden: false } - }) - return buildTree(result) + const [knowledgeResult, basesResult] = await Promise.all([ + window.ipc.invoke('workspace:readdir', { + path: 'knowledge', + opts: { recursive: true, includeHidden: false, includeStats: true } + }), + window.ipc.invoke('workspace:readdir', { + path: 'bases', + opts: { recursive: false, includeHidden: false, includeStats: true } + }).catch(() => [] as DirEntry[]), + ]) + const knowledgeTree = buildTree(knowledgeResult) + const basesChildren: TreeNode[] = (basesResult as DirEntry[]) + .filter((e) => e.name.endsWith('.base')) + .map((e) => ({ ...e, kind: 'file' as const })) + if (basesChildren.length > 0) { + const basesFolder: TreeNode = { + name: 'Bases', + path: 'bases', + kind: 'dir', + children: basesChildren, + } + return [...knowledgeTree, basesFolder] + } + return knowledgeTree } catch (err) { console.error('Failed to load directory:', err) return [] } }, []) + // Ensure bases/ directory exists on startup + useEffect(() => { + window.ipc.invoke('workspace:mkdir', { path: 'bases', recursive: true }) + .catch((err: unknown) => console.error('Failed to ensure bases directory:', err)) + }, []) + // Load initial tree useEffect(() => { loadDirectory().then(setTree) @@ -905,7 +935,6 @@ function App() { editorPathRef.current = pathToReload initialContentByPathRef.current.set(pathToReload, body) initialContentRef.current = body - setActiveFileTags(extractTags(fm)) } } }) @@ -923,6 +952,31 @@ function App() { setLastSaved(null) return } + if (selectedPath === BASES_DEFAULT_TAB_PATH) { + // Virtual default base — no file to load, use DEFAULT_BASE_CONFIG + if (!baseConfigByPath[selectedPath]) { + setBaseConfigByPath((prev) => ({ ...prev, [selectedPath]: { ...DEFAULT_BASE_CONFIG } })) + } + return + } + if (selectedPath.endsWith('.base')) { + // Load base config from file only if not already cached + if (!baseConfigByPath[selectedPath]) { + window.ipc.invoke('workspace:readFile', { path: selectedPath, encoding: 'utf8' }) + .then((result: { data: string }) => { + try { + const parsed = JSON.parse(result.data) as BaseConfig + setBaseConfigByPath((prev) => ({ ...prev, [selectedPath]: parsed })) + } catch { + setBaseConfigByPath((prev) => ({ ...prev, [selectedPath]: { ...DEFAULT_BASE_CONFIG } })) + } + }) + .catch(() => { + setBaseConfigByPath((prev) => ({ ...prev, [selectedPath]: { ...DEFAULT_BASE_CONFIG } })) + }) + } + return + } if (selectedPath.endsWith('.md')) { const cachedContent = editorContentByPathRef.current.get(selectedPath) const hasBaseline = initialContentByPathRef.current.has(selectedPath) @@ -934,7 +988,6 @@ function App() { editorContentRef.current = cachedContent editorPathRef.current = selectedPath initialContentRef.current = initialContentByPathRef.current.get(selectedPath) ?? cachedContent - setActiveFileTags(extractTags(frontmatterByPathRef.current.get(selectedPath) ?? null)) return } } @@ -943,42 +996,43 @@ function App() { let cancelled = false ;(async () => { try { - const stat = await window.ipc.invoke('workspace:stat', { path: pathToLoad }) - if (cancelled || fileLoadRequestIdRef.current !== requestId || selectedPathRef.current !== pathToLoad) return - if (stat.kind === 'file') { - const result = await window.ipc.invoke('workspace:readFile', { path: pathToLoad }) + // For .md files (from the knowledge tree), skip stat and read directly. + // For other file types, stat first to check if it's a file vs directory. + const isKnownFile = pathToLoad.endsWith('.md') + if (!isKnownFile) { + const stat = await window.ipc.invoke('workspace:stat', { path: pathToLoad }) if (cancelled || fileLoadRequestIdRef.current !== requestId || selectedPathRef.current !== pathToLoad) return - setFileContent(result.data) - const { raw: fm, body } = splitFrontmatter(result.data) - frontmatterByPathRef.current.set(pathToLoad, fm) - const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim() - const isSameEditorFile = editorPathRef.current === pathToLoad - const knownBaseline = initialContentByPathRef.current.get(pathToLoad) - const hasKnownBaseline = knownBaseline !== undefined - const hasUnsavedEdits = - hasKnownBaseline - && normalizeForCompare(editorContentRef.current) !== normalizeForCompare(knownBaseline) - const shouldPreserveActiveDraft = isSameEditorFile && hasUnsavedEdits - if (!shouldPreserveActiveDraft) { - setEditorContent(body) - if (pathToLoad.endsWith('.md')) { - setEditorCacheForPath(pathToLoad, body) - } - editorContentRef.current = body - editorPathRef.current = pathToLoad - initialContentByPathRef.current.set(pathToLoad, body) - initialContentRef.current = body - setLastSaved(null) - setActiveFileTags(extractTags(fm)) - } else { - // Still update the editor's path so subsequent autosaves write to the correct file. - editorPathRef.current = pathToLoad + if (stat.kind !== 'file') { + setFileContent('') + setEditorContent('') + editorContentRef.current = '' + initialContentRef.current = '' + return } + } + const result = await window.ipc.invoke('workspace:readFile', { path: pathToLoad }) + if (cancelled || fileLoadRequestIdRef.current !== requestId || selectedPathRef.current !== pathToLoad) return + setFileContent(result.data) + const { raw: fm, body } = splitFrontmatter(result.data) + frontmatterByPathRef.current.set(pathToLoad, fm) + const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim() + const isSameEditorFile = editorPathRef.current === pathToLoad + const wouldClobberActiveEdits = + isSameEditorFile + && normalizeForCompare(editorContentRef.current) !== normalizeForCompare(body) + if (!wouldClobberActiveEdits) { + setEditorContent(body) + if (pathToLoad.endsWith('.md')) { + setEditorCacheForPath(pathToLoad, body) + } + editorContentRef.current = body + editorPathRef.current = pathToLoad + initialContentByPathRef.current.set(pathToLoad, body) + initialContentRef.current = body + setLastSaved(null) } else { - setFileContent('') - setEditorContent('') - editorContentRef.current = '' - initialContentRef.current = '' + // Still update the editor's path so subsequent autosaves write to the correct file. + editorPathRef.current = pathToLoad } } catch (err) { console.error('Failed to load file:', err) @@ -2177,7 +2231,7 @@ function App() { const closeFileTab = useCallback((tabId: string) => { const closingTab = fileTabs.find(t => t.id === tabId) - if (closingTab && !isGraphTabPath(closingTab.path)) { + if (closingTab && !isGraphTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) { removeEditorCacheForPath(closingTab.path) initialContentByPathRef.current.delete(closingTab.path) untitledRenameReadyPathsRef.current.delete(closingTab.path) @@ -2186,13 +2240,20 @@ function App() { editorPathRef.current = null } } + if (closingTab && isBaseFilePath(closingTab.path)) { + setBaseConfigByPath((prev) => { + const next = { ...prev } + delete next[closingTab.path] + return next + }) + } setFileTabs(prev => { if (prev.length <= 1) { // Last file tab - close it and go back to chat setActiveFileTabId(null) setSelectedPath(null) setIsGraphOpen(false) - return [] + return [] } const idx = prev.findIndex(t => t.id === tabId) if (idx === -1) return prev @@ -2206,7 +2267,7 @@ function App() { setIsGraphOpen(true) } else { setIsGraphOpen(false) - setSelectedPath(newActiveTab.path) + setSelectedPath(newActiveTab.path) } } return next @@ -2314,7 +2375,7 @@ function App() { if (activeFileTabId) { const activeTab = fileTabs.find((tab) => tab.id === activeFileTabId) - if (activeTab && !isGraphTabPath(activeTab.path)) { + if (activeTab && !isGraphTabPath(activeTab.path) && !isBaseFilePath(activeTab.path)) { setFileTabs((prev) => prev.map((tab) => ( tab.id === activeFileTabId ? { ...tab, path } : tab ))) @@ -2459,6 +2520,46 @@ function App() { void navigateToView({ type: 'file', path }) }, [navigateToView]) + const handleBaseConfigChange = useCallback((path: string, config: BaseConfig) => { + setBaseConfigByPath((prev) => ({ ...prev, [path]: config })) + }, []) + + const handleBaseSave = useCallback(async (name: string | null) => { + if (!selectedPath) return + const isDefault = selectedPath === BASES_DEFAULT_TAB_PATH + const config = baseConfigByPath[selectedPath] ?? DEFAULT_BASE_CONFIG + + if (isDefault && name) { + // Save as new base file + const safeName = name.replace(/[\\/]/g, '-').trim() + const newPath = `bases/${safeName}.base` + const fileConfig = { ...config, name: safeName } + try { + await window.ipc.invoke('workspace:writeFile', { + path: newPath, + data: JSON.stringify(fileConfig, null, 2), + }) + setBaseConfigByPath((prev) => ({ ...prev, [newPath]: fileConfig })) + // Refresh tree then navigate to the new file + const newTree = await loadDirectory() + setTree(newTree) + void navigateToView({ type: 'file', path: newPath }) + } catch (err) { + console.error('Failed to save base:', err) + } + } else if (!isDefault) { + // Save in place + try { + await window.ipc.invoke('workspace:writeFile', { + path: selectedPath, + data: JSON.stringify(config, null, 2), + }) + } catch (err) { + console.error('Failed to save base:', err) + } + } + }, [selectedPath, baseConfigByPath, loadDirectory, navigateToView]) + const navigateToFullScreenChat = useCallback(() => { // Only treat this as navigation when coming from another view if (currentViewState.type !== 'chat') { @@ -2771,6 +2872,13 @@ function App() { } void navigateToView({ type: 'graph' }) }, + openBases: () => { + if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) { + setIsChatSidebarOpen(false) + setIsRightPaneMaximized(false) + } + void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH }) + }, expandAll: () => setExpandedPaths(new Set(collectDirPaths(tree))), collapseAll: () => setExpandedPaths(new Set()), rename: async (oldPath: string, newName: string, isDir: boolean) => { @@ -3270,7 +3378,7 @@ function App() { getTabId={(t) => t.id} onSwitchTab={switchFileTab} onCloseTab={closeFileTab} - allowSingleTabClose={fileTabs.length === 1 && isGraphOpen} + allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} /> ) : ( )} - {selectedPath && ( + {selectedPath && selectedPath.endsWith('.md') && (
{isSaving ? ( <> @@ -3372,7 +3480,18 @@ function App() { )} - {isGraphOpen ? ( + {selectedPath && isBaseFilePath(selectedPath) ? ( +
+ navigateToFile(path)} + config={baseConfigByPath[selectedPath] ?? DEFAULT_BASE_CONFIG} + onConfigChange={(cfg) => handleBaseConfigChange(selectedPath, cfg)} + isDefaultBase={selectedPath === BASES_DEFAULT_TAB_PATH} + onSave={(name) => void handleBaseSave(name)} + /> +
+ ) : isGraphOpen ? (
{ + frontmatterByPathRef.current.set(tab.path, newRaw) + // Write updated frontmatter to disk immediately + const currentBody = editorContentRef.current + const fullContent = joinFrontmatter(newRaw, currentBody) + initialContentByPathRef.current.set(tab.path, splitFrontmatter(fullContent).body) + initialContentRef.current = splitFrontmatter(fullContent).body + void window.ipc.invoke('workspace:writeFile', { + path: tab.path, + data: fullContent, + opts: { encoding: 'utf8' }, + }) + }} onHistoryHandlersChange={(handlers) => { if (handlers) { fileHistoryHandlersRef.current.set(tab.id, handlers) diff --git a/apps/x/apps/renderer/src/components/bases-view.tsx b/apps/x/apps/renderer/src/components/bases-view.tsx index 7403435e..83fc07c0 100644 --- a/apps/x/apps/renderer/src/components/bases-view.tsx +++ b/apps/x/apps/renderer/src/components/bases-view.tsx @@ -1,9 +1,20 @@ import * as React from 'react' import { useEffect, useState, useMemo, useCallback, useRef } from 'react' -import { ArrowDown, ArrowUp, X } from 'lucide-react' +import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, X, Check, ListFilter, Filter, Search, Save } from 'lucide-react' import { Badge } from '@/components/ui/badge' +import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' +import { Command, CommandInput, CommandList, CommandItem, CommandEmpty, CommandGroup } from '@/components/ui/command' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { cn } from '@/lib/utils' -import { splitFrontmatter, extractTags } from '@/lib/frontmatter' +import { splitFrontmatter, extractAllFrontmatterValues } from '@/lib/frontmatter' +import { useDebounce } from '@/hooks/use-debounce' interface TreeNode { path: string @@ -17,34 +28,77 @@ type NoteEntry = { path: string name: string folder: string - tags: string[] + fields: Record mtimeMs: number } -type SortField = 'name' | 'folder' | 'mtimeMs' type SortDir = 'asc' | 'desc' +type ActiveFilter = { category: string; value: string } + +export type BaseConfig = { + name: string + visibleColumns: string[] + columnWidths: Record + sort: { field: string; dir: SortDir } + filters: ActiveFilter[] +} + +export const DEFAULT_BASE_CONFIG: BaseConfig = { + name: 'All Notes', + visibleColumns: ['name', 'folder', 'relationship', 'topic', 'status', 'mtimeMs'], + columnWidths: {}, + sort: { field: 'mtimeMs', dir: 'desc' }, + filters: [], +} + +const PAGE_SIZE = 25 + +/** Built-in columns that don't come from frontmatter */ +const BUILTIN_COLUMNS = ['name', 'folder', 'mtimeMs'] as const +type BuiltinColumn = (typeof BUILTIN_COLUMNS)[number] + +const BUILTIN_LABELS: Record = { + name: 'Name', + folder: 'Folder', + mtimeMs: 'Last Modified', +} + +/** Default pixel widths for columns */ +const DEFAULT_WIDTHS: Record = { + name: 200, + folder: 140, + mtimeMs: 140, +} +const DEFAULT_FRONTMATTER_WIDTH = 150 + +/** Convert key to title case: `first_met` → `First Met` */ +function toTitleCase(key: string): string { + if (key in BUILTIN_LABELS) return BUILTIN_LABELS[key as BuiltinColumn] + return key + .split('_') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' ') +} type BasesViewProps = { tree: TreeNode[] onSelectNote: (path: string) => void + config: BaseConfig + onConfigChange: (config: BaseConfig) => void + isDefaultBase: boolean + onSave: (name: string | null) => void } -function collectFilePaths(nodes: TreeNode[]): { path: string; name: string; mtimeMs: number }[] { +function collectFiles(nodes: TreeNode[]): { path: string; name: string; mtimeMs: number }[] { return nodes.flatMap((n) => n.kind === 'file' && n.name.endsWith('.md') ? [{ path: n.path, name: n.name.replace(/\.md$/i, ''), mtimeMs: n.stat?.mtimeMs ?? 0 }] : n.children - ? collectFilePaths(n.children) + ? collectFiles(n.children) : [], ) } -/** Build a stable fingerprint from the tree's file paths + mtimes so we only reload when files actually change. */ -function treeFingerprint(nodes: TreeNode[]): string { - const files = collectFilePaths(nodes) - return files.map((f) => `${f.path}:${f.mtimeMs}`).join('\n') -} - function getFolder(path: string): string { const parts = path.split('/') if (parts.length >= 3) return parts[1] @@ -57,247 +111,557 @@ function formatDate(ms: number): string { return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) } -export function BasesView({ tree, onSelectNote }: BasesViewProps) { - const [notes, setNotes] = useState([]) - const [initialLoading, setInitialLoading] = useState(true) - const [selectedTags, setSelectedTags] = useState>(new Set()) - const [sortField, setSortField] = useState('mtimeMs') - const [sortDir, setSortDir] = useState('desc') - const lastFingerprintRef = useRef('') +function filtersEqual(a: ActiveFilter, b: ActiveFilter): boolean { + return a.category === b.category && a.value === b.value +} - // Stable fingerprint — only changes when actual file paths/mtimes differ - const fingerprint = useMemo(() => treeFingerprint(tree), [tree]) +function hasFilter(filters: ActiveFilter[], f: ActiveFilter): boolean { + return filters.some((x) => filtersEqual(x, f)) +} - // Load notes data when fingerprint changes +/** Get the string values for a column from a note */ +function getColumnValues(note: NoteEntry, column: string): string[] { + if (column === 'name') return [note.name] + if (column === 'folder') return [note.folder] + if (column === 'mtimeMs') return [] + const v = note.fields[column] + if (!v) return [] + return Array.isArray(v) ? v : [v] +} + +/** Get a single sortable string for a column */ +function getSortValue(note: NoteEntry, column: string): string | number { + if (column === 'name') return note.name + if (column === 'folder') return note.folder + if (column === 'mtimeMs') return note.mtimeMs + const v = note.fields[column] + if (!v) return '' + return Array.isArray(v) ? v[0] ?? '' : v +} + +const isBuiltin = (col: string): col is BuiltinColumn => + (BUILTIN_COLUMNS as readonly string[]).includes(col) + +export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaultBase, onSave }: BasesViewProps) { + // Build notes instantly from tree + const notes = useMemo(() => { + return collectFiles(tree).map((f) => ({ + path: f.path, + name: f.name, + folder: getFolder(f.path), + fields: {}, + mtimeMs: f.mtimeMs, + })) + }, [tree]) + + // Frontmatter fields loaded async, keyed by path + const [fieldsByPath, setFieldsByPath] = useState>>(new Map()) + const loadGenRef = useRef(0) + + // Load frontmatter in background batches useEffect(() => { - if (fingerprint === lastFingerprintRef.current) return - lastFingerprintRef.current = fingerprint - + const gen = ++loadGenRef.current let cancelled = false - const files = collectFilePaths(tree) + const paths = notes.map((n) => n.path) - async function loadNotes() { - const entries: NoteEntry[] = [] - - for (const file of files) { - try { - const result = await window.ipc.invoke('workspace:readFile', { - path: file.path, - encoding: 'utf8', - }) - const { raw } = splitFrontmatter(result.data) - const tags = extractTags(raw) - entries.push({ - path: file.path, - name: file.name, - folder: getFolder(file.path), - tags, - mtimeMs: file.mtimeMs, - }) - } catch { - entries.push({ - path: file.path, - name: file.name, - folder: getFolder(file.path), - tags: [], - mtimeMs: file.mtimeMs, - }) - } - } - - if (!cancelled) { - setNotes(entries) - setInitialLoading(false) + async function load() { + const BATCH = 30 + for (let i = 0; i < paths.length; i += BATCH) { + if (cancelled) return + const batch = paths.slice(i, i + BATCH) + const results = await Promise.all( + batch.map(async (p) => { + try { + const result = await window.ipc.invoke('workspace:readFile', { path: p, encoding: 'utf8' }) + const { raw } = splitFrontmatter(result.data) + return { path: p, fields: extractAllFrontmatterValues(raw) } + } catch { + return { path: p, fields: {} as Record } + } + }), + ) + if (cancelled || gen !== loadGenRef.current) return + setFieldsByPath((prev) => { + const next = new Map(prev) + for (const r of results) next.set(r.path, r.fields) + return next + }) } } - loadNotes() + load() return () => { cancelled = true } - }, [fingerprint, tree]) - - // Collect all unique tags - const allTags = useMemo(() => { - const tagSet = new Set() - for (const note of notes) { - for (const tag of note.tags) { - tagSet.add(tag) - } - } - return [...tagSet].sort((a, b) => a.localeCompare(b)) }, [notes]) - // Filter and sort - const filteredNotes = useMemo(() => { - let result = notes - if (selectedTags.size > 0) { - const tagsArray = [...selectedTags] - result = result.filter((note) => - tagsArray.every((tag) => note.tags.includes(tag)), - ) + // Merge tree-derived notes with async-loaded fields + const enrichedNotes = useMemo(() => { + if (fieldsByPath.size === 0) return notes + return notes.map((n) => { + const f = fieldsByPath.get(n.path) + return f ? { ...n, fields: f } : n + }) + }, [notes, fieldsByPath]) + + // Collect all unique frontmatter property keys across all notes + const allPropertyKeys = useMemo(() => { + const keys = new Set() + for (const fields of fieldsByPath.values()) { + for (const k of Object.keys(fields)) keys.add(k) } - result = [...result].sort((a, b) => { - let cmp = 0 - if (sortField === 'name') { - cmp = a.name.localeCompare(b.name) - } else if (sortField === 'folder') { - cmp = a.folder.localeCompare(b.folder) + return Array.from(keys).sort() + }, [fieldsByPath]) + + // Filterable categories: "folder" + all frontmatter keys + const filterCategories = useMemo(() => { + return ['folder', ...allPropertyKeys] + }, [allPropertyKeys]) + + // All unique values per category, across all enriched notes + const valuesByCategory = useMemo>(() => { + const result: Record> = {} + for (const cat of filterCategories) result[cat] = new Set() + for (const note of enrichedNotes) { + for (const cat of filterCategories) { + for (const v of getColumnValues(note, cat)) { + if (v) result[cat]?.add(v) + } + } + } + const out: Record = {} + for (const [cat, set] of Object.entries(result)) { + out[cat] = Array.from(set).sort((a, b) => a.localeCompare(b)) + } + return out + }, [filterCategories, enrichedNotes]) + + const visibleColumns = config.visibleColumns + const columnWidths = config.columnWidths + const filters = config.filters + const sortField = config.sort.field + const sortDir = config.sort.dir + const [page, setPage] = useState(0) + const [saveDialogOpen, setSaveDialogOpen] = useState(false) + const [saveName, setSaveName] = useState('') + const saveInputRef = useRef(null) + const [filterCategory, setFilterCategory] = useState(null) + + const handleSaveClick = useCallback(() => { + if (isDefaultBase) { + setSaveName('') + setSaveDialogOpen(true) + } else { + onSave(null) + } + }, [isDefaultBase, onSave]) + + const handleSaveConfirm = useCallback(() => { + const name = saveName.trim() + if (!name) return + setSaveDialogOpen(false) + onSave(name) + }, [saveName, onSave]) + + const getColWidth = useCallback((col: string) => { + return columnWidths[col] ?? DEFAULT_WIDTHS[col] ?? DEFAULT_FRONTMATTER_WIDTH + }, [columnWidths]) + + // Column resize via drag + const resizingRef = useRef<{ col: string; startX: number; startW: number } | null>(null) + + const configRef = useRef(config) + configRef.current = config + + const onResizeStart = useCallback((col: string, e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + const startX = e.clientX + const startW = configRef.current.columnWidths[col] ?? DEFAULT_WIDTHS[col] ?? DEFAULT_FRONTMATTER_WIDTH + resizingRef.current = { col, startX, startW } + + const onMouseMove = (ev: MouseEvent) => { + if (!resizingRef.current) return + const delta = ev.clientX - resizingRef.current.startX + const newW = Math.max(60, resizingRef.current.startW + delta) + const c = configRef.current + const updated = { ...c, columnWidths: { ...c.columnWidths, [resizingRef.current!.col]: newW } } + onConfigChange(updated) + } + + const onMouseUp = () => { + resizingRef.current = null + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + } + + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + }, [onConfigChange]) + + // Search + const [searchOpen, setSearchOpen] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const debouncedSearch = useDebounce(searchQuery, 250) + const [searchMatchPaths, setSearchMatchPaths] = useState | null>(null) + const searchInputRef = useRef(null) + + useEffect(() => { + if (!debouncedSearch.trim()) { + setSearchMatchPaths(null) + return + } + let cancelled = false + window.ipc.invoke('search:query', { query: debouncedSearch, limit: 200, types: ['knowledge'] }) + .then((res: { results: { path: string }[] }) => { + if (!cancelled) { + setSearchMatchPaths(new Set(res.results.map((r) => r.path))) + } + }) + .catch(() => { + if (!cancelled) setSearchMatchPaths(new Set()) + }) + return () => { cancelled = true } + }, [debouncedSearch]) + + const toggleSearch = useCallback(() => { + setSearchOpen((prev) => { + if (prev) { + setSearchQuery('') + setSearchMatchPaths(null) + } + return !prev + }) + }, []) + + // Focus input when search opens + useEffect(() => { + if (searchOpen) searchInputRef.current?.focus() + }, [searchOpen]) + + // Reset page when filters or search change + useEffect(() => { setPage(0) }, [filters, searchMatchPaths]) + + // Filter (search + badge filters) + const filteredNotes = useMemo(() => { + let result = enrichedNotes + // Apply search filter + if (searchMatchPaths) { + result = result.filter((note) => searchMatchPaths.has(note.path)) + } + // Apply badge filters + if (filters.length > 0) { + const byCategory = new Map() + for (const f of filters) { + const vals = byCategory.get(f.category) ?? [] + vals.push(f.value) + byCategory.set(f.category, vals) + } + result = result.filter((note) => { + for (const [category, requiredValues] of byCategory) { + const noteValues = getColumnValues(note, category) + if (!requiredValues.some((v) => noteValues.includes(v))) return false + } + return true + }) + } + return result + }, [enrichedNotes, filters, searchMatchPaths]) + + // Sort + const sortedNotes = useMemo(() => { + return [...filteredNotes].sort((a, b) => { + const va = getSortValue(a, sortField) + const vb = getSortValue(b, sortField) + let cmp: number + if (typeof va === 'number' && typeof vb === 'number') { + cmp = va - vb } else { - cmp = a.mtimeMs - b.mtimeMs + cmp = String(va).localeCompare(String(vb)) } return sortDir === 'asc' ? cmp : -cmp }) - return result - }, [notes, selectedTags, sortField, sortDir]) + }, [filteredNotes, sortField, sortDir]) - const toggleTag = useCallback((tag: string) => { - setSelectedTags((prev) => { - const next = new Set(prev) - if (next.has(tag)) { - next.delete(tag) - } else { - next.add(tag) - } - return next - }) - }, []) + // Paginate + const totalPages = Math.max(1, Math.ceil(sortedNotes.length / PAGE_SIZE)) + const clampedPage = Math.min(page, totalPages - 1) + const pageNotes = useMemo( + () => sortedNotes.slice(clampedPage * PAGE_SIZE, (clampedPage + 1) * PAGE_SIZE), + [sortedNotes, clampedPage], + ) + + const toggleFilter = useCallback((category: string, value: string) => { + const c = configRef.current + const f: ActiveFilter = { category, value } + const next = hasFilter(c.filters, f) + ? c.filters.filter((x) => !filtersEqual(x, f)) + : [...c.filters, f] + onConfigChange({ ...c, filters: next }) + }, [onConfigChange]) const clearFilters = useCallback(() => { - setSelectedTags(new Set()) - }, []) + onConfigChange({ ...configRef.current, filters: [] }) + }, [onConfigChange]) - const handleSort = useCallback((field: SortField) => { - setSortField((prev) => { - if (prev === field) { - setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')) - return prev - } - setSortDir(field === 'mtimeMs' ? 'desc' : 'asc') - return field - }) - }, []) + const handleSort = useCallback((field: string) => { + const c = configRef.current + if (field === c.sort.field) { + onConfigChange({ ...c, sort: { field, dir: c.sort.dir === 'asc' ? 'desc' : 'asc' } }) + } else { + onConfigChange({ ...c, sort: { field, dir: field === 'mtimeMs' ? 'desc' : 'asc' } }) + } + }, [onConfigChange]) - const SortIcon = ({ field }: { field: SortField }) => { + const toggleColumn = useCallback((key: string) => { + const c = configRef.current + const next = c.visibleColumns.includes(key) + ? c.visibleColumns.filter((col) => col !== key) + : [...c.visibleColumns, key] + onConfigChange({ ...c, visibleColumns: next }) + }, [onConfigChange]) + + const SortIcon = ({ field }: { field: string }) => { if (sortField !== field) return null - return sortDir === 'asc' ? ( - - ) : ( - - ) - } - - if (initialLoading) { - return ( -
-
- {Array.from({ length: 8 }).map((_, i) => ( -
- ))} -
-
- ) + return sortDir === 'asc' + ? + : } return (
- {/* Filter bar */} -
-
- - Showing {filteredNotes.length} of {notes.length} notes - - {selectedTags.size > 0 && ( + {/* Toolbar */} +
+ + + + + + + + + No properties found. + + {BUILTIN_COLUMNS.map((col) => ( + toggleColumn(col)}> + + {BUILTIN_LABELS[col]} + + ))} + + + {allPropertyKeys.map((key) => ( + toggleColumn(key)}> + + {toTitleCase(key)} + + ))} + + + + + + + { if (!open) setFilterCategory(null) }}> + + + + +
+ {/* Left: categories */} +
+
+ Attributes + {filters.length > 0 && ( + + )} +
+ {filterCategories.map((cat) => { + const activeCount = filters.filter((f) => f.category === cat).length + const isSelected = filterCategory === cat + return ( + + ) + })} +
+ {/* Right: values for selected category */} + {filterCategory && ( +
+ + + + No values found. + + {(valuesByCategory[filterCategory] ?? []).map((val) => { + const active = hasFilter(filters, { category: filterCategory, value: val }) + return ( + toggleFilter(filterCategory, val)}> + + {val} + + ) + })} + + + +
+ )} +
+
+
+ + + + {searchOpen && ( +
+ setSearchQuery(e.target.value)} + placeholder="Search notes..." + className="flex-1 min-w-0 bg-transparent text-xs text-foreground placeholder:text-muted-foreground outline-none" + /> + {searchQuery && ( + + {searchMatchPaths ? `${searchMatchPaths.size} matches` : '...'} + + )} - )} -
- {allTags.map((tag) => ( +
+ )} + +
+ + +
+ + {/* Filter bar */} + {filters.length > 0 && ( +
+
+ + {sortedNotes.length} of {enrichedNotes.length} notes + + {filters.map((f) => ( ))} +
-
+ )} {/* Table */}
- - +
+ + {visibleColumns.map((col) => ( + + ))} + + - - - - + {visibleColumns.map((col) => ( + + ))} - {filteredNotes.map((note) => ( + {pageNotes.map((note) => ( onSelectNote(note.path)} > - - - - + {visibleColumns.map((col) => ( + + ))} ))} - {filteredNotes.length === 0 && ( + {pageNotes.length === 0 && ( - @@ -305,6 +669,152 @@ export function BasesView({ tree, onSelectNote }: BasesViewProps) {
handleSort('name')} - > - Name - - handleSort('folder')} - > - Folder - - - Tags - handleSort('mtimeMs')} - > - Last Modified - - handleSort(col)} + > + {toTitleCase(col)} + {/* Resize handle */} +
onResizeStart(col, e)} + onClick={(e) => e.stopPropagation()} + /> +
{note.name}{note.folder} -
- {note.tags.map((tag) => ( - { - e.stopPropagation() - toggleTag(tag) - }} - > - {tag} - - ))} -
-
- {formatDate(note.mtimeMs)} - + +
+ No notes found
+ + {/* Pagination */} +
+ + {sortedNotes.length === 0 + ? '0 notes' + : `${clampedPage * PAGE_SIZE + 1}\u2013${Math.min((clampedPage + 1) * PAGE_SIZE, sortedNotes.length)} of ${sortedNotes.length}`} + + {totalPages > 1 && ( +
+ + + Page {clampedPage + 1} of {totalPages} + + +
+ )} +
+ + {/* Save As dialog */} + + + + Save Base + Choose a name for this base view. + + setSaveName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSaveConfirm() }} + placeholder="e.g. Contacts, Projects..." + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring" + autoFocus + /> + + + + + +
) } + +/** Renders a single table cell based on the column type */ +function CellRenderer({ + note, + column, + filters, + toggleFilter, +}: { + note: NoteEntry + column: string + filters: ActiveFilter[] + toggleFilter: (category: string, value: string) => void +}) { + if (column === 'name') { + return {note.name} + } + if (column === 'folder') { + return {note.folder} + } + if (column === 'mtimeMs') { + return {formatDate(note.mtimeMs)} + } + + // Frontmatter column + const value = note.fields[column] + if (!value) return null + + if (Array.isArray(value)) { + return ( +
+ {value.map((v) => ( + + ))} +
+ ) + } + + // Single string value — render as badge for filterability + return ( + + ) +} + +function CategoryBadge({ + category, + value, + active, + onClick, +}: { + category: string + value: string + active: boolean + onClick: (category: string, value: string) => void +}) { + return ( + { + e.stopPropagation() + onClick(category, value) + }} + > + {value} + + ) +} diff --git a/apps/x/apps/renderer/src/components/frontmatter-properties.tsx b/apps/x/apps/renderer/src/components/frontmatter-properties.tsx new file mode 100644 index 00000000..280d45f1 --- /dev/null +++ b/apps/x/apps/renderer/src/components/frontmatter-properties.tsx @@ -0,0 +1,252 @@ +import { useState, useCallback, useRef, useEffect } from 'react' +import { ChevronRight, X, Plus } from 'lucide-react' +import { extractAllFrontmatterValues, buildFrontmatter } from '@/lib/frontmatter' + +interface FrontmatterPropertiesProps { + raw: string | null + onRawChange: (raw: string | null) => void + editable?: boolean +} + +type FieldEntry = { key: string; value: string | string[] } + +function fieldsFromRaw(raw: string | null): FieldEntry[] { + const record = extractAllFrontmatterValues(raw) + return Object.entries(record).map(([key, value]) => ({ key, value })) +} + +function fieldsToRaw(fields: FieldEntry[]): string | null { + const record: Record = {} + for (const { key, value } of fields) { + if (key.trim()) record[key.trim()] = value + } + return buildFrontmatter(record) +} + +export function FrontmatterProperties({ raw, onRawChange, editable = true }: FrontmatterPropertiesProps) { + const [expanded, setExpanded] = useState(false) + const [fields, setFields] = useState(() => fieldsFromRaw(raw)) + const [editingNewKey, setEditingNewKey] = useState(false) + const newKeyRef = useRef(null) + const lastCommittedRaw = useRef(raw) + + // Sync local fields when raw changes externally (e.g. tab switch) + useEffect(() => { + if (raw !== lastCommittedRaw.current) { + setFields(fieldsFromRaw(raw)) + lastCommittedRaw.current = raw + } + }, [raw]) + + useEffect(() => { + if (editingNewKey && newKeyRef.current) { + newKeyRef.current.focus() + } + }, [editingNewKey]) + + const commit = useCallback((updated: FieldEntry[]) => { + const newRaw = fieldsToRaw(updated) + lastCommittedRaw.current = newRaw + onRawChange(newRaw) + }, [onRawChange]) + + // For scalar fields: update local state immediately, commit on blur + const updateLocalValue = useCallback((index: number, newValue: string) => { + setFields(prev => { + const next = [...prev] + next[index] = { ...next[index], value: newValue } + return next + }) + }, []) + + const commitField = useCallback((index: number) => { + setFields(prev => { + commit(prev) + return prev + }) + }, [commit]) + + // For array fields and structural changes: update + commit immediately + const updateAndCommit = useCallback((updater: (prev: FieldEntry[]) => FieldEntry[]) => { + setFields(prev => { + const next = updater(prev) + commit(next) + return next + }) + }, [commit]) + + const removeField = useCallback((index: number) => { + updateAndCommit(prev => prev.filter((_, i) => i !== index)) + }, [updateAndCommit]) + + const addField = useCallback((key: string) => { + const trimmed = key.trim() + if (!trimmed) return + if (fields.some(f => f.key === trimmed)) return + updateAndCommit(prev => [...prev, { key: trimmed, value: '' }]) + setEditingNewKey(false) + }, [fields, updateAndCommit]) + + const count = fields.length + + return ( +
+ + + {expanded && ( +
+ {fields.map((field, index) => ( +
+ + {field.key} + +
+ {Array.isArray(field.value) ? ( + updateAndCommit(prev => { + const next = [...prev] + next[index] = { ...next[index], value: v } + return next + })} + /> + ) : ( + updateLocalValue(index, e.target.value)} + onBlur={() => commitField(index)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.currentTarget.blur() + } + }} + /> + )} +
+ {editable && ( + + )} +
+ ))} + + {editable && ( + editingNewKey ? ( +
+ { + if (e.key === 'Enter') { + addField(e.currentTarget.value) + } else if (e.key === 'Escape') { + setEditingNewKey(false) + } + }} + onBlur={(e) => { + if (e.currentTarget.value.trim()) { + addField(e.currentTarget.value) + } else { + setEditingNewKey(false) + } + }} + /> +
+ ) : ( + + ) + )} +
+ )} +
+ ) +} + +function ArrayField({ + value, + editable, + onChange, +}: { + value: string[] + editable: boolean + onChange: (v: string[]) => void +}) { + const removeItem = (index: number) => { + onChange(value.filter((_, i) => i !== index)) + } + + const addItem = (text: string) => { + const trimmed = text.trim() + if (!trimmed) return + onChange([...value, trimmed]) + } + + return ( +
+ {value.map((item, i) => ( + + {item} + {editable && ( + + )} + + ))} + {editable && ( + { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault() + addItem(e.currentTarget.value) + e.currentTarget.value = '' + } else if (e.key === 'Backspace' && !e.currentTarget.value && value.length > 0) { + removeItem(value.length - 1) + } + }} + onBlur={(e) => { + if (e.currentTarget.value.trim()) { + addItem(e.currentTarget.value) + e.currentTarget.value = '' + } + }} + /> + )} +
+ ) +} diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 35cab547..7858d2df 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -176,7 +176,7 @@ function getMarkdownWithBlankLines(editor: Editor): string { return result } import { EditorToolbar } from './editor-toolbar' -import { TagPills } from './tag-pills' +import { FrontmatterProperties } from './frontmatter-properties' import { WikiLink } from '@/extensions/wiki-link' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command' @@ -201,7 +201,8 @@ interface MarkdownEditorProps { editorSessionKey?: number onHistoryHandlersChange?: (handlers: { undo: () => boolean; redo: () => boolean } | null) => void editable?: boolean - tags?: string[] + frontmatter?: string | null + onFrontmatterChange?: (raw: string | null) => void } type WikiLinkMatch = { @@ -290,7 +291,8 @@ export function MarkdownEditor({ editorSessionKey = 0, onHistoryHandlersChange, editable = true, - tags, + frontmatter, + onFrontmatterChange, }: MarkdownEditorProps) { const isInternalUpdate = useRef(false) const wrapperRef = useRef(null) @@ -724,7 +726,13 @@ export function MarkdownEditor({ onSelectionHighlight={setSelectionHighlight} onImageUpload={handleImageUploadWithPlaceholder} /> - {tags && } + {(frontmatter !== undefined) && onFrontmatterChange && ( + + )}
{wikiLinks ? ( diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index fb890ecb..2ae699a9 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -16,6 +16,7 @@ import { Mic, Network, Pencil, + Table2, Plug, LoaderIcon, Settings, @@ -101,6 +102,7 @@ type KnowledgeActions = { createNote: (parentPath?: string) => void createFolder: (parentPath?: string) => void openGraph: () => void + openBases: () => void expandAll: () => void collapseAll: () => void rename: (path: string, newName: string, isDir: boolean) => Promise @@ -855,6 +857,7 @@ function KnowledgeSection({ { icon: FilePlus, label: "New Note", action: () => actions.createNote() }, { icon: FolderPlus, label: "New Folder", action: () => actions.createFolder() }, { icon: Network, label: "Graph View", action: () => actions.openGraph() }, + { icon: Table2, label: "Bases", action: () => actions.openBases() }, ] return ( diff --git a/apps/x/apps/renderer/src/components/tag-pills.tsx b/apps/x/apps/renderer/src/components/tag-pills.tsx deleted file mode 100644 index eead6558..00000000 --- a/apps/x/apps/renderer/src/components/tag-pills.tsx +++ /dev/null @@ -1,17 +0,0 @@ -interface TagPillsProps { - tags: string[] -} - -export function TagPills({ tags }: TagPillsProps) { - if (tags.length === 0) return null - - return ( -
- {tags.map((tag, i) => ( - - {tag} - - ))} -
- ) -} diff --git a/apps/x/apps/renderer/src/lib/frontmatter.ts b/apps/x/apps/renderer/src/lib/frontmatter.ts index a9b6b2ff..85a74dcf 100644 --- a/apps/x/apps/renderer/src/lib/frontmatter.ts +++ b/apps/x/apps/renderer/src/lib/frontmatter.ts @@ -29,6 +29,209 @@ export function joinFrontmatter(raw: string | null, body: string): string { return raw + '\n' + body } +/** Structured frontmatter fields extracted from categorized YAML. */ +export type FrontmatterFields = { + relationship: string | null + relationship_sub: string[] + topic: string[] + email_type: string[] + action: string[] + status: string | null + source: string[] +} + +/** + * Extract structured tag categories from raw frontmatter YAML. + * + * Handles both the new categorized format (top-level keys) and the legacy + * flat `tags:` list. For legacy notes the flat tags are mapped into + * categories using known tag values. + */ +export function extractFrontmatterFields(raw: string | null): FrontmatterFields { + const fields: FrontmatterFields = { + relationship: null, + relationship_sub: [], + topic: [], + email_type: [], + action: [], + status: null, + source: [], + } + if (!raw) return fields + + const lines = raw.split('\n') + let currentKey: string | null = null + + for (const line of lines) { + // Top-level key detection + const topMatch = line.match(/^(\w+):\s*(.*)$/) + if (topMatch || line === '---') { + currentKey = null + } + + if (topMatch) { + const key = topMatch[1] + const value = topMatch[2].trim() + + if (key in fields) { + currentKey = key + if (value) { + const field = fields[key as keyof FrontmatterFields] + if (Array.isArray(field)) { + (field as string[]).push(value) + } else { + // single-value field + ;(fields as Record)[key] = value + } + currentKey = null // inline value, no list follows + } + continue + } + + // Legacy flat tags: — parse and distribute into categories + if (key === 'tags') { + currentKey = '__legacy_tags' + continue + } + } + + // List items under a categorized key + if (currentKey && currentKey !== '__legacy_tags') { + const itemMatch = line.match(/^\s+-\s+(.+)$/) + if (itemMatch) { + const value = itemMatch[1].trim() + const field = fields[currentKey as keyof FrontmatterFields] + if (Array.isArray(field)) { + (field as string[]).push(value) + } else { + ;(fields as Record)[currentKey] = value + } + } + continue + } + + // Legacy flat tag items → map into categories + if (currentKey === '__legacy_tags') { + const itemMatch = line.match(/^\s+-\s+(.+)$/) + if (itemMatch) { + const tag = itemMatch[1].trim() + const cat = LEGACY_TAG_TO_CATEGORY[tag] + if (cat) { + const field = fields[cat as keyof FrontmatterFields] + if (Array.isArray(field)) { + (field as string[]).push(tag) + } else if (!(fields as Record)[cat]) { + ;(fields as Record)[cat] = tag + } + } + } + continue + } + } + + return fields +} + +/** + * Extract ALL top-level YAML key/value pairs from raw frontmatter. + * Returns a flat record where scalar values are strings and list values are string[]. + * Skips `---` delimiters and blank lines. + */ +export function extractAllFrontmatterValues(raw: string | null): Record { + const result: Record = {} + if (!raw) return result + + const lines = raw.split('\n') + let currentKey: string | null = null + + for (const line of lines) { + if (line === '---' || line.trim() === '') { + currentKey = null + continue + } + + // Top-level key: value + const topMatch = line.match(/^(\w[\w\s]*\w|\w+):\s*(.*)$/) + if (topMatch) { + const key = topMatch[1] + const value = topMatch[2].trim() + if (value) { + result[key] = value + currentKey = null + } else { + // List will follow + currentKey = key + result[key] = [] + } + continue + } + + // List item under current key + if (currentKey) { + const itemMatch = line.match(/^\s+-\s+(.+)$/) + if (itemMatch) { + const arr = result[currentKey] + if (Array.isArray(arr)) { + arr.push(itemMatch[1].trim()) + } + } + } + } + + return result +} + +/** + * Convert a Record of frontmatter fields back to a raw YAML frontmatter string. + * Returns null if no non-empty fields remain. + */ +export function buildFrontmatter(fields: Record): string | null { + const lines: string[] = [] + for (const [key, value] of Object.entries(fields)) { + if (Array.isArray(value)) { + if (value.length === 0) continue + lines.push(`${key}:`) + for (const item of value) { + if (item.trim()) lines.push(` - ${item.trim()}`) + } + } else { + const trimmed = (value ?? '').trim() + if (!trimmed) continue + lines.push(`${key}: ${trimmed}`) + } + } + if (lines.length === 0) return null + return `---\n${lines.join('\n')}\n---` +} + +/** Map known tag values → category for legacy flat-list frontmatter. */ +const LEGACY_TAG_TO_CATEGORY: Record = { + // relationship + investor: 'relationship', customer: 'relationship', prospect: 'relationship', + partner: 'relationship', vendor: 'relationship', product: 'relationship', + candidate: 'relationship', team: 'relationship', advisor: 'relationship', + personal: 'relationship', press: 'relationship', community: 'relationship', + government: 'relationship', + // relationship_sub + primary: 'relationship_sub', secondary: 'relationship_sub', + 'executive-assistant': 'relationship_sub', cc: 'relationship_sub', + 'referred-by': 'relationship_sub', former: 'relationship_sub', + champion: 'relationship_sub', blocker: 'relationship_sub', + // topic + sales: 'topic', support: 'topic', legal: 'topic', finance: 'topic', + hiring: 'topic', fundraising: 'topic', travel: 'topic', event: 'topic', + shopping: 'topic', health: 'topic', learning: 'topic', research: 'topic', + // email_type + intro: 'email_type', followup: 'email_type', + // action + 'action-required': 'action', urgent: 'action', waiting: 'action', + // status + active: 'status', archived: 'status', stale: 'status', + // source + email: 'source', meeting: 'source', browser: 'source', + 'web-search': 'source', manual: 'source', import: 'source', +} + /** Tag category keys used in the categorized frontmatter format. */ const TAG_CATEGORY_KEYS = new Set([ 'relationship', diff --git a/apps/x/apps/renderer/src/styles/editor.css b/apps/x/apps/renderer/src/styles/editor.css index 6e1c0deb..7bbff762 100644 --- a/apps/x/apps/renderer/src/styles/editor.css +++ b/apps/x/apps/renderer/src/styles/editor.css @@ -237,34 +237,200 @@ flex-shrink: 0; } -/* Tag pills row shown between toolbar and editor content */ -.tag-pills-row { - display: flex; - flex-wrap: wrap; - gap: 4px; - padding: 4px 12px; +/* Frontmatter properties panel between toolbar and editor content */ +.frontmatter-properties { + flex-shrink: 0; border-bottom: 1px solid var(--border); background-color: var(--background); - flex-shrink: 0; - max-height: 4.5em; - overflow: hidden; + font-size: 13px; + color: var(--foreground); } -.tag-pill { - font-size: 11px; - line-height: 18px; - padding: 0 8px; - border-radius: 9999px; +.frontmatter-toggle { + display: flex; + align-items: center; + gap: 4px; + width: 100%; + padding: 4px 12px; + background: none; + border: none; + cursor: pointer; + color: color-mix(in srgb, var(--foreground) 60%, transparent); + font-size: 12px; + user-select: none; +} + +.frontmatter-toggle:hover { + color: var(--foreground); + background-color: color-mix(in srgb, var(--foreground) 4%, transparent); +} + +.frontmatter-chevron { + transition: transform 0.15s ease; + flex-shrink: 0; +} + +.frontmatter-chevron.expanded { + transform: rotate(90deg); +} + +.frontmatter-label { + font-weight: 500; +} + +.frontmatter-fields { + padding: 2px 12px 6px 30px; +} + +.frontmatter-row { + display: flex; + align-items: center; + gap: 8px; + min-height: 28px; +} + +.frontmatter-key { + flex-shrink: 0; + width: 110px; + font-size: 12px; + color: color-mix(in srgb, var(--foreground) 60%, transparent); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.frontmatter-value-area { + flex: 1; + min-width: 0; +} + +.frontmatter-input { + width: 100%; + background: none; + border: none; + border-bottom: 1px solid transparent; + padding: 2px 4px; + font-size: 13px; + color: var(--foreground); + outline: none; +} + +.frontmatter-input:focus { + border-bottom-color: var(--primary); +} + +.frontmatter-input:read-only { + cursor: default; +} + +.frontmatter-new-key-input { + width: 110px; + flex-shrink: 0; +} + +.frontmatter-remove { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + background: none; + border: none; + border-radius: 4px; + cursor: pointer; + color: color-mix(in srgb, var(--foreground) 40%, transparent); + opacity: 0; +} + +.frontmatter-row:hover .frontmatter-remove { + opacity: 1; +} + +.frontmatter-remove:hover { background-color: color-mix(in srgb, var(--foreground) 8%, transparent); color: var(--foreground); +} + +.frontmatter-add { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 4px; + margin-top: 2px; + background: none; + border: none; + cursor: pointer; + font-size: 12px; + color: color-mix(in srgb, var(--foreground) 50%, transparent); +} + +.frontmatter-add:hover { + color: var(--foreground); +} + +/* Array field chips */ +.frontmatter-array { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + min-height: 24px; +} + +.frontmatter-chip { + display: inline-flex; + align-items: center; + gap: 2px; + font-size: 11px; + line-height: 18px; + padding: 0 6px; + border-radius: 9999px; + background-color: color-mix(in srgb, var(--foreground) 8%, transparent); white-space: nowrap; user-select: none; } -.dark .tag-pill { +.dark .frontmatter-chip { background-color: color-mix(in srgb, var(--foreground) 12%, transparent); } +.frontmatter-chip-text { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; +} + +.frontmatter-chip-remove { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + cursor: pointer; + padding: 0; + color: color-mix(in srgb, var(--foreground) 50%, transparent); + margin-left: 2px; +} + +.frontmatter-chip-remove:hover { + color: var(--foreground); +} + +.frontmatter-chip-input { + background: none; + border: none; + outline: none; + font-size: 12px; + color: var(--foreground); + width: 60px; + padding: 2px 0; +} + +.frontmatter-chip-input::placeholder { + color: color-mix(in srgb, var(--foreground) 30%, transparent); +} + .editor-toolbar .separator { width: 1px; height: 1.5rem;