mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-19 18:35:18 +02:00
bases
This commit is contained in:
parent
5131fb7f9e
commit
5aba6025dc
8 changed files with 1547 additions and 290 deletions
|
|
@ -12,6 +12,7 @@ import { ChatSidebar } from './components/chat-sidebar';
|
||||||
import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions';
|
import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions';
|
||||||
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||||
import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view';
|
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 { useDebounce } from './hooks/use-debounce';
|
||||||
import { SidebarContentPanel } from '@/components/sidebar-content';
|
import { SidebarContentPanel } from '@/components/sidebar-content';
|
||||||
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
|
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
|
||||||
|
|
@ -46,7 +47,7 @@ import {
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
import { Toaster } from "@/components/ui/sonner"
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
|
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 { OnboardingModal } from '@/components/onboarding-modal'
|
||||||
import { SearchDialog } from '@/components/search-dialog'
|
import { SearchDialog } from '@/components/search-dialog'
|
||||||
import { BackgroundTaskDetail } from '@/components/background-task-detail'
|
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_BUTTONS_COLLAPSED = 5
|
||||||
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 4
|
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 4
|
||||||
const GRAPH_TAB_PATH = '__rowboat_graph_view__'
|
const GRAPH_TAB_PATH = '__rowboat_graph_view__'
|
||||||
|
const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
|
||||||
|
|
||||||
const clampNumber = (value: number, min: number, max: number) =>
|
const clampNumber = (value: number, min: number, max: number) =>
|
||||||
Math.min(max, Math.max(min, value))
|
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 isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH
|
||||||
|
const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH
|
||||||
|
|
||||||
const normalizeUsage = (usage?: Partial<LanguageModelUsage> | null): LanguageModelUsage | null => {
|
const normalizeUsage = (usage?: Partial<LanguageModelUsage> | null): LanguageModelUsage | null => {
|
||||||
if (!usage) return null
|
if (!usage) return null
|
||||||
|
|
@ -470,6 +473,7 @@ function App() {
|
||||||
const [recentWikiFiles, setRecentWikiFiles] = useState<string[]>([])
|
const [recentWikiFiles, setRecentWikiFiles] = useState<string[]>([])
|
||||||
const [isGraphOpen, setIsGraphOpen] = useState(false)
|
const [isGraphOpen, setIsGraphOpen] = useState(false)
|
||||||
const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean } | null>(null)
|
const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean } | null>(null)
|
||||||
|
const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({})
|
||||||
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
|
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
|
||||||
nodes: [],
|
nodes: [],
|
||||||
edges: [],
|
edges: [],
|
||||||
|
|
@ -510,9 +514,8 @@ function App() {
|
||||||
const initialContentRef = useRef<string>('')
|
const initialContentRef = useRef<string>('')
|
||||||
const renameInProgressRef = useRef(false)
|
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<Map<string, string | null>>(new Map())
|
const frontmatterByPathRef = useRef<Map<string, string | null>>(new Map())
|
||||||
const [activeFileTags, setActiveFileTags] = useState<string[]>([])
|
|
||||||
|
|
||||||
// Version history state
|
// Version history state
|
||||||
const [versionHistoryPath, setVersionHistoryPath] = useState<string | null>(null)
|
const [versionHistoryPath, setVersionHistoryPath] = useState<string | null>(null)
|
||||||
|
|
@ -621,6 +624,8 @@ function App() {
|
||||||
|
|
||||||
const getFileTabTitle = useCallback((tab: FileTab) => {
|
const getFileTabTitle = useCallback((tab: FileTab) => {
|
||||||
if (isGraphTabPath(tab.path)) return 'Graph View'
|
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
|
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
@ -818,20 +823,45 @@ function App() {
|
||||||
}
|
}
|
||||||
}, [runId, processingRunIds])
|
}, [runId, processingRunIds])
|
||||||
|
|
||||||
// Load directory tree
|
// Load directory tree (knowledge + bases)
|
||||||
const loadDirectory = useCallback(async () => {
|
const loadDirectory = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await window.ipc.invoke('workspace:readdir', {
|
const [knowledgeResult, basesResult] = await Promise.all([
|
||||||
|
window.ipc.invoke('workspace:readdir', {
|
||||||
path: 'knowledge',
|
path: 'knowledge',
|
||||||
opts: { recursive: true, includeHidden: false }
|
opts: { recursive: true, includeHidden: false, includeStats: true }
|
||||||
})
|
}),
|
||||||
return buildTree(result)
|
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) {
|
} catch (err) {
|
||||||
console.error('Failed to load directory:', err)
|
console.error('Failed to load directory:', err)
|
||||||
return []
|
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
|
// Load initial tree
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadDirectory().then(setTree)
|
loadDirectory().then(setTree)
|
||||||
|
|
@ -905,7 +935,6 @@ function App() {
|
||||||
editorPathRef.current = pathToReload
|
editorPathRef.current = pathToReload
|
||||||
initialContentByPathRef.current.set(pathToReload, body)
|
initialContentByPathRef.current.set(pathToReload, body)
|
||||||
initialContentRef.current = body
|
initialContentRef.current = body
|
||||||
setActiveFileTags(extractTags(fm))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -923,6 +952,31 @@ function App() {
|
||||||
setLastSaved(null)
|
setLastSaved(null)
|
||||||
return
|
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')) {
|
if (selectedPath.endsWith('.md')) {
|
||||||
const cachedContent = editorContentByPathRef.current.get(selectedPath)
|
const cachedContent = editorContentByPathRef.current.get(selectedPath)
|
||||||
const hasBaseline = initialContentByPathRef.current.has(selectedPath)
|
const hasBaseline = initialContentByPathRef.current.has(selectedPath)
|
||||||
|
|
@ -934,7 +988,6 @@ function App() {
|
||||||
editorContentRef.current = cachedContent
|
editorContentRef.current = cachedContent
|
||||||
editorPathRef.current = selectedPath
|
editorPathRef.current = selectedPath
|
||||||
initialContentRef.current = initialContentByPathRef.current.get(selectedPath) ?? cachedContent
|
initialContentRef.current = initialContentByPathRef.current.get(selectedPath) ?? cachedContent
|
||||||
setActiveFileTags(extractTags(frontmatterByPathRef.current.get(selectedPath) ?? null))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -943,9 +996,20 @@ function App() {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
|
// 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 })
|
const stat = await window.ipc.invoke('workspace:stat', { path: pathToLoad })
|
||||||
if (cancelled || fileLoadRequestIdRef.current !== requestId || selectedPathRef.current !== pathToLoad) return
|
if (cancelled || fileLoadRequestIdRef.current !== requestId || selectedPathRef.current !== pathToLoad) return
|
||||||
if (stat.kind === 'file') {
|
if (stat.kind !== 'file') {
|
||||||
|
setFileContent('')
|
||||||
|
setEditorContent('')
|
||||||
|
editorContentRef.current = ''
|
||||||
|
initialContentRef.current = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
const result = await window.ipc.invoke('workspace:readFile', { path: pathToLoad })
|
const result = await window.ipc.invoke('workspace:readFile', { path: pathToLoad })
|
||||||
if (cancelled || fileLoadRequestIdRef.current !== requestId || selectedPathRef.current !== pathToLoad) return
|
if (cancelled || fileLoadRequestIdRef.current !== requestId || selectedPathRef.current !== pathToLoad) return
|
||||||
setFileContent(result.data)
|
setFileContent(result.data)
|
||||||
|
|
@ -953,13 +1017,10 @@ function App() {
|
||||||
frontmatterByPathRef.current.set(pathToLoad, fm)
|
frontmatterByPathRef.current.set(pathToLoad, fm)
|
||||||
const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim()
|
const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim()
|
||||||
const isSameEditorFile = editorPathRef.current === pathToLoad
|
const isSameEditorFile = editorPathRef.current === pathToLoad
|
||||||
const knownBaseline = initialContentByPathRef.current.get(pathToLoad)
|
const wouldClobberActiveEdits =
|
||||||
const hasKnownBaseline = knownBaseline !== undefined
|
isSameEditorFile
|
||||||
const hasUnsavedEdits =
|
&& normalizeForCompare(editorContentRef.current) !== normalizeForCompare(body)
|
||||||
hasKnownBaseline
|
if (!wouldClobberActiveEdits) {
|
||||||
&& normalizeForCompare(editorContentRef.current) !== normalizeForCompare(knownBaseline)
|
|
||||||
const shouldPreserveActiveDraft = isSameEditorFile && hasUnsavedEdits
|
|
||||||
if (!shouldPreserveActiveDraft) {
|
|
||||||
setEditorContent(body)
|
setEditorContent(body)
|
||||||
if (pathToLoad.endsWith('.md')) {
|
if (pathToLoad.endsWith('.md')) {
|
||||||
setEditorCacheForPath(pathToLoad, body)
|
setEditorCacheForPath(pathToLoad, body)
|
||||||
|
|
@ -969,17 +1030,10 @@ function App() {
|
||||||
initialContentByPathRef.current.set(pathToLoad, body)
|
initialContentByPathRef.current.set(pathToLoad, body)
|
||||||
initialContentRef.current = body
|
initialContentRef.current = body
|
||||||
setLastSaved(null)
|
setLastSaved(null)
|
||||||
setActiveFileTags(extractTags(fm))
|
|
||||||
} else {
|
} else {
|
||||||
// Still update the editor's path so subsequent autosaves write to the correct file.
|
// Still update the editor's path so subsequent autosaves write to the correct file.
|
||||||
editorPathRef.current = pathToLoad
|
editorPathRef.current = pathToLoad
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
setFileContent('')
|
|
||||||
setEditorContent('')
|
|
||||||
editorContentRef.current = ''
|
|
||||||
initialContentRef.current = ''
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load file:', err)
|
console.error('Failed to load file:', err)
|
||||||
if (!cancelled && fileLoadRequestIdRef.current === requestId && selectedPathRef.current === pathToLoad) {
|
if (!cancelled && fileLoadRequestIdRef.current === requestId && selectedPathRef.current === pathToLoad) {
|
||||||
|
|
@ -2177,7 +2231,7 @@ function App() {
|
||||||
|
|
||||||
const closeFileTab = useCallback((tabId: string) => {
|
const closeFileTab = useCallback((tabId: string) => {
|
||||||
const closingTab = fileTabs.find(t => t.id === tabId)
|
const closingTab = fileTabs.find(t => t.id === tabId)
|
||||||
if (closingTab && !isGraphTabPath(closingTab.path)) {
|
if (closingTab && !isGraphTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) {
|
||||||
removeEditorCacheForPath(closingTab.path)
|
removeEditorCacheForPath(closingTab.path)
|
||||||
initialContentByPathRef.current.delete(closingTab.path)
|
initialContentByPathRef.current.delete(closingTab.path)
|
||||||
untitledRenameReadyPathsRef.current.delete(closingTab.path)
|
untitledRenameReadyPathsRef.current.delete(closingTab.path)
|
||||||
|
|
@ -2186,6 +2240,13 @@ function App() {
|
||||||
editorPathRef.current = null
|
editorPathRef.current = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (closingTab && isBaseFilePath(closingTab.path)) {
|
||||||
|
setBaseConfigByPath((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[closingTab.path]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
setFileTabs(prev => {
|
setFileTabs(prev => {
|
||||||
if (prev.length <= 1) {
|
if (prev.length <= 1) {
|
||||||
// Last file tab - close it and go back to chat
|
// Last file tab - close it and go back to chat
|
||||||
|
|
@ -2314,7 +2375,7 @@ function App() {
|
||||||
|
|
||||||
if (activeFileTabId) {
|
if (activeFileTabId) {
|
||||||
const activeTab = fileTabs.find((tab) => tab.id === 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) => (
|
setFileTabs((prev) => prev.map((tab) => (
|
||||||
tab.id === activeFileTabId ? { ...tab, path } : tab
|
tab.id === activeFileTabId ? { ...tab, path } : tab
|
||||||
)))
|
)))
|
||||||
|
|
@ -2459,6 +2520,46 @@ function App() {
|
||||||
void navigateToView({ type: 'file', path })
|
void navigateToView({ type: 'file', path })
|
||||||
}, [navigateToView])
|
}, [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(() => {
|
const navigateToFullScreenChat = useCallback(() => {
|
||||||
// Only treat this as navigation when coming from another view
|
// Only treat this as navigation when coming from another view
|
||||||
if (currentViewState.type !== 'chat') {
|
if (currentViewState.type !== 'chat') {
|
||||||
|
|
@ -2771,6 +2872,13 @@ function App() {
|
||||||
}
|
}
|
||||||
void navigateToView({ type: 'graph' })
|
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))),
|
expandAll: () => setExpandedPaths(new Set(collectDirPaths(tree))),
|
||||||
collapseAll: () => setExpandedPaths(new Set()),
|
collapseAll: () => setExpandedPaths(new Set()),
|
||||||
rename: async (oldPath: string, newName: string, isDir: boolean) => {
|
rename: async (oldPath: string, newName: string, isDir: boolean) => {
|
||||||
|
|
@ -3270,7 +3378,7 @@ function App() {
|
||||||
getTabId={(t) => t.id}
|
getTabId={(t) => t.id}
|
||||||
onSwitchTab={switchFileTab}
|
onSwitchTab={switchFileTab}
|
||||||
onCloseTab={closeFileTab}
|
onCloseTab={closeFileTab}
|
||||||
allowSingleTabClose={fileTabs.length === 1 && isGraphOpen}
|
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TabBar
|
<TabBar
|
||||||
|
|
@ -3283,7 +3391,7 @@ function App() {
|
||||||
onCloseTab={closeChatTab}
|
onCloseTab={closeChatTab}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{selectedPath && (
|
{selectedPath && selectedPath.endsWith('.md') && (
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground self-center shrink-0 pl-2">
|
<div className="flex items-center gap-1 text-xs text-muted-foreground self-center shrink-0 pl-2">
|
||||||
{isSaving ? (
|
{isSaving ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -3372,7 +3480,18 @@ function App() {
|
||||||
)}
|
)}
|
||||||
</ContentHeader>
|
</ContentHeader>
|
||||||
|
|
||||||
{isGraphOpen ? (
|
{selectedPath && isBaseFilePath(selectedPath) ? (
|
||||||
|
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||||
|
<BasesView
|
||||||
|
tree={tree}
|
||||||
|
onSelectNote={(path) => navigateToFile(path)}
|
||||||
|
config={baseConfigByPath[selectedPath] ?? DEFAULT_BASE_CONFIG}
|
||||||
|
onConfigChange={(cfg) => handleBaseConfigChange(selectedPath, cfg)}
|
||||||
|
isDefaultBase={selectedPath === BASES_DEFAULT_TAB_PATH}
|
||||||
|
onSave={(name) => void handleBaseSave(name)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : isGraphOpen ? (
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
<GraphView
|
<GraphView
|
||||||
nodes={graphData.nodes}
|
nodes={graphData.nodes}
|
||||||
|
|
@ -3418,7 +3537,20 @@ function App() {
|
||||||
wikiLinks={wikiLinkConfig}
|
wikiLinks={wikiLinkConfig}
|
||||||
onImageUpload={handleImageUpload}
|
onImageUpload={handleImageUpload}
|
||||||
editorSessionKey={editorSessionByTabId[tab.id] ?? 0}
|
editorSessionKey={editorSessionByTabId[tab.id] ?? 0}
|
||||||
tags={isActive ? activeFileTags : undefined}
|
frontmatter={frontmatterByPathRef.current.get(tab.path) ?? null}
|
||||||
|
onFrontmatterChange={(newRaw) => {
|
||||||
|
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) => {
|
onHistoryHandlersChange={(handlers) => {
|
||||||
if (handlers) {
|
if (handlers) {
|
||||||
fileHistoryHandlersRef.current.set(tab.id, handlers)
|
fileHistoryHandlersRef.current.set(tab.id, handlers)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,20 @@
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useEffect, useState, useMemo, useCallback, useRef } 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 { 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 { cn } from '@/lib/utils'
|
||||||
import { splitFrontmatter, extractTags } from '@/lib/frontmatter'
|
import { splitFrontmatter, extractAllFrontmatterValues } from '@/lib/frontmatter'
|
||||||
|
import { useDebounce } from '@/hooks/use-debounce'
|
||||||
|
|
||||||
interface TreeNode {
|
interface TreeNode {
|
||||||
path: string
|
path: string
|
||||||
|
|
@ -17,34 +28,77 @@ type NoteEntry = {
|
||||||
path: string
|
path: string
|
||||||
name: string
|
name: string
|
||||||
folder: string
|
folder: string
|
||||||
tags: string[]
|
fields: Record<string, string | string[]>
|
||||||
mtimeMs: number
|
mtimeMs: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortField = 'name' | 'folder' | 'mtimeMs'
|
|
||||||
type SortDir = 'asc' | 'desc'
|
type SortDir = 'asc' | 'desc'
|
||||||
|
type ActiveFilter = { category: string; value: string }
|
||||||
|
|
||||||
|
export type BaseConfig = {
|
||||||
|
name: string
|
||||||
|
visibleColumns: string[]
|
||||||
|
columnWidths: Record<string, number>
|
||||||
|
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<BuiltinColumn, string> = {
|
||||||
|
name: 'Name',
|
||||||
|
folder: 'Folder',
|
||||||
|
mtimeMs: 'Last Modified',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default pixel widths for columns */
|
||||||
|
const DEFAULT_WIDTHS: Record<string, number> = {
|
||||||
|
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 = {
|
type BasesViewProps = {
|
||||||
tree: TreeNode[]
|
tree: TreeNode[]
|
||||||
onSelectNote: (path: string) => void
|
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) =>
|
return nodes.flatMap((n) =>
|
||||||
n.kind === 'file' && n.name.endsWith('.md')
|
n.kind === 'file' && n.name.endsWith('.md')
|
||||||
? [{ path: n.path, name: n.name.replace(/\.md$/i, ''), mtimeMs: n.stat?.mtimeMs ?? 0 }]
|
? [{ path: n.path, name: n.name.replace(/\.md$/i, ''), mtimeMs: n.stat?.mtimeMs ?? 0 }]
|
||||||
: n.children
|
: 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 {
|
function getFolder(path: string): string {
|
||||||
const parts = path.split('/')
|
const parts = path.split('/')
|
||||||
if (parts.length >= 3) return parts[1]
|
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' })
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BasesView({ tree, onSelectNote }: BasesViewProps) {
|
function filtersEqual(a: ActiveFilter, b: ActiveFilter): boolean {
|
||||||
const [notes, setNotes] = useState<NoteEntry[]>([])
|
return a.category === b.category && a.value === b.value
|
||||||
const [initialLoading, setInitialLoading] = useState(true)
|
}
|
||||||
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set())
|
|
||||||
const [sortField, setSortField] = useState<SortField>('mtimeMs')
|
|
||||||
const [sortDir, setSortDir] = useState<SortDir>('desc')
|
|
||||||
const lastFingerprintRef = useRef<string>('')
|
|
||||||
|
|
||||||
// Stable fingerprint — only changes when actual file paths/mtimes differ
|
function hasFilter(filters: ActiveFilter[], f: ActiveFilter): boolean {
|
||||||
const fingerprint = useMemo(() => treeFingerprint(tree), [tree])
|
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<NoteEntry[]>(() => {
|
||||||
|
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<Map<string, Record<string, string | string[]>>>(new Map())
|
||||||
|
const loadGenRef = useRef(0)
|
||||||
|
|
||||||
|
// Load frontmatter in background batches
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fingerprint === lastFingerprintRef.current) return
|
const gen = ++loadGenRef.current
|
||||||
lastFingerprintRef.current = fingerprint
|
|
||||||
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
const files = collectFilePaths(tree)
|
const paths = notes.map((n) => n.path)
|
||||||
|
|
||||||
async function loadNotes() {
|
async function load() {
|
||||||
const entries: NoteEntry[] = []
|
const BATCH = 30
|
||||||
|
for (let i = 0; i < paths.length; i += BATCH) {
|
||||||
for (const file of files) {
|
if (cancelled) return
|
||||||
|
const batch = paths.slice(i, i + BATCH)
|
||||||
|
const results = await Promise.all(
|
||||||
|
batch.map(async (p) => {
|
||||||
try {
|
try {
|
||||||
const result = await window.ipc.invoke('workspace:readFile', {
|
const result = await window.ipc.invoke('workspace:readFile', { path: p, encoding: 'utf8' })
|
||||||
path: file.path,
|
|
||||||
encoding: 'utf8',
|
|
||||||
})
|
|
||||||
const { raw } = splitFrontmatter(result.data)
|
const { raw } = splitFrontmatter(result.data)
|
||||||
const tags = extractTags(raw)
|
return { path: p, fields: extractAllFrontmatterValues(raw) }
|
||||||
entries.push({
|
|
||||||
path: file.path,
|
|
||||||
name: file.name,
|
|
||||||
folder: getFolder(file.path),
|
|
||||||
tags,
|
|
||||||
mtimeMs: file.mtimeMs,
|
|
||||||
})
|
|
||||||
} catch {
|
} catch {
|
||||||
entries.push({
|
return { path: p, fields: {} as Record<string, string | string[]> }
|
||||||
path: file.path,
|
}
|
||||||
name: file.name,
|
}),
|
||||||
folder: getFolder(file.path),
|
)
|
||||||
tags: [],
|
if (cancelled || gen !== loadGenRef.current) return
|
||||||
mtimeMs: file.mtimeMs,
|
setFieldsByPath((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
for (const r of results) next.set(r.path, r.fields)
|
||||||
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cancelled) {
|
load()
|
||||||
setNotes(entries)
|
|
||||||
setInitialLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadNotes()
|
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [fingerprint, tree])
|
|
||||||
|
|
||||||
// Collect all unique tags
|
|
||||||
const allTags = useMemo(() => {
|
|
||||||
const tagSet = new Set<string>()
|
|
||||||
for (const note of notes) {
|
|
||||||
for (const tag of note.tags) {
|
|
||||||
tagSet.add(tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...tagSet].sort((a, b) => a.localeCompare(b))
|
|
||||||
}, [notes])
|
}, [notes])
|
||||||
|
|
||||||
// Filter and sort
|
// Merge tree-derived notes with async-loaded fields
|
||||||
const filteredNotes = useMemo(() => {
|
const enrichedNotes = useMemo<NoteEntry[]>(() => {
|
||||||
let result = notes
|
if (fieldsByPath.size === 0) return notes
|
||||||
if (selectedTags.size > 0) {
|
return notes.map((n) => {
|
||||||
const tagsArray = [...selectedTags]
|
const f = fieldsByPath.get(n.path)
|
||||||
result = result.filter((note) =>
|
return f ? { ...n, fields: f } : n
|
||||||
tagsArray.every((tag) => note.tags.includes(tag)),
|
})
|
||||||
)
|
}, [notes, fieldsByPath])
|
||||||
|
|
||||||
|
// Collect all unique frontmatter property keys across all notes
|
||||||
|
const allPropertyKeys = useMemo<string[]>(() => {
|
||||||
|
const keys = new Set<string>()
|
||||||
|
for (const fields of fieldsByPath.values()) {
|
||||||
|
for (const k of Object.keys(fields)) keys.add(k)
|
||||||
}
|
}
|
||||||
result = [...result].sort((a, b) => {
|
return Array.from(keys).sort()
|
||||||
let cmp = 0
|
}, [fieldsByPath])
|
||||||
if (sortField === 'name') {
|
|
||||||
cmp = a.name.localeCompare(b.name)
|
// Filterable categories: "folder" + all frontmatter keys
|
||||||
} else if (sortField === 'folder') {
|
const filterCategories = useMemo<string[]>(() => {
|
||||||
cmp = a.folder.localeCompare(b.folder)
|
return ['folder', ...allPropertyKeys]
|
||||||
|
}, [allPropertyKeys])
|
||||||
|
|
||||||
|
// All unique values per category, across all enriched notes
|
||||||
|
const valuesByCategory = useMemo<Record<string, string[]>>(() => {
|
||||||
|
const result: Record<string, Set<string>> = {}
|
||||||
|
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<string, string[]> = {}
|
||||||
|
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<HTMLInputElement>(null)
|
||||||
|
const [filterCategory, setFilterCategory] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleSaveClick = useCallback(() => {
|
||||||
|
if (isDefaultBase) {
|
||||||
|
setSaveName('')
|
||||||
|
setSaveDialogOpen(true)
|
||||||
} else {
|
} else {
|
||||||
cmp = a.mtimeMs - b.mtimeMs
|
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<Set<string> | null>(null)
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(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<string, string[]>()
|
||||||
|
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 = String(va).localeCompare(String(vb))
|
||||||
}
|
}
|
||||||
return sortDir === 'asc' ? cmp : -cmp
|
return sortDir === 'asc' ? cmp : -cmp
|
||||||
})
|
})
|
||||||
return result
|
}, [filteredNotes, sortField, sortDir])
|
||||||
}, [notes, selectedTags, sortField, sortDir])
|
|
||||||
|
|
||||||
const toggleTag = useCallback((tag: string) => {
|
// Paginate
|
||||||
setSelectedTags((prev) => {
|
const totalPages = Math.max(1, Math.ceil(sortedNotes.length / PAGE_SIZE))
|
||||||
const next = new Set(prev)
|
const clampedPage = Math.min(page, totalPages - 1)
|
||||||
if (next.has(tag)) {
|
const pageNotes = useMemo(
|
||||||
next.delete(tag)
|
() => sortedNotes.slice(clampedPage * PAGE_SIZE, (clampedPage + 1) * PAGE_SIZE),
|
||||||
} else {
|
[sortedNotes, clampedPage],
|
||||||
next.add(tag)
|
)
|
||||||
}
|
|
||||||
return next
|
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(() => {
|
const clearFilters = useCallback(() => {
|
||||||
setSelectedTags(new Set())
|
onConfigChange({ ...configRef.current, filters: [] })
|
||||||
}, [])
|
}, [onConfigChange])
|
||||||
|
|
||||||
const handleSort = useCallback((field: SortField) => {
|
const handleSort = useCallback((field: string) => {
|
||||||
setSortField((prev) => {
|
const c = configRef.current
|
||||||
if (prev === field) {
|
if (field === c.sort.field) {
|
||||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
|
onConfigChange({ ...c, sort: { field, dir: c.sort.dir === 'asc' ? 'desc' : 'asc' } })
|
||||||
return prev
|
} else {
|
||||||
|
onConfigChange({ ...c, sort: { field, dir: field === 'mtimeMs' ? 'desc' : 'asc' } })
|
||||||
}
|
}
|
||||||
setSortDir(field === 'mtimeMs' ? 'desc' : 'asc')
|
}, [onConfigChange])
|
||||||
return field
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
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
|
if (sortField !== field) return null
|
||||||
return sortDir === 'asc' ? (
|
return sortDir === 'asc'
|
||||||
<ArrowUp className="size-3 inline ml-1" />
|
? <ArrowUp className="size-3 inline ml-1" />
|
||||||
) : (
|
: <ArrowDown className="size-3 inline ml-1" />
|
||||||
<ArrowDown className="size-3 inline ml-1" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (initialLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex items-center justify-center">
|
|
||||||
<div className="space-y-3 w-full max-w-2xl px-6">
|
|
||||||
{Array.from({ length: 8 }).map((_, i) => (
|
|
||||||
<div key={i} className="h-10 rounded bg-muted/50 animate-pulse" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
{/* Filter bar */}
|
{/* Toolbar */}
|
||||||
<div className="shrink-0 border-b border-border px-4 py-2.5">
|
<div className="shrink-0 border-b border-border px-4 py-2 flex items-center gap-3">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<Popover>
|
||||||
<span className="text-xs text-muted-foreground shrink-0">
|
<PopoverTrigger asChild>
|
||||||
Showing {filteredNotes.length} of {notes.length} notes
|
<button className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground">
|
||||||
|
<ListFilter className="size-3.5" />
|
||||||
|
Properties
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="start" className="w-56 p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search properties..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No properties found.</CommandEmpty>
|
||||||
|
<CommandGroup heading="Built-in">
|
||||||
|
{BUILTIN_COLUMNS.map((col) => (
|
||||||
|
<CommandItem key={col} onSelect={() => toggleColumn(col)}>
|
||||||
|
<Check className={cn('size-3.5 mr-2', visibleColumns.includes(col) ? 'opacity-100' : 'opacity-0')} />
|
||||||
|
{BUILTIN_LABELS[col]}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
<CommandGroup heading="Frontmatter">
|
||||||
|
{allPropertyKeys.map((key) => (
|
||||||
|
<CommandItem key={key} onSelect={() => toggleColumn(key)}>
|
||||||
|
<Check className={cn('size-3.5 mr-2', visibleColumns.includes(key) ? 'opacity-100' : 'opacity-0')} />
|
||||||
|
{toTitleCase(key)}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<Popover onOpenChange={(open) => { if (!open) setFilterCategory(null) }}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground',
|
||||||
|
filters.length > 0 && 'text-foreground',
|
||||||
|
)}>
|
||||||
|
<Filter className="size-3.5" />
|
||||||
|
Filter
|
||||||
|
{filters.length > 0 && (
|
||||||
|
<span className="rounded-full bg-primary text-primary-foreground px-1.5 text-[10px] font-medium leading-tight">
|
||||||
|
{filters.length}
|
||||||
</span>
|
</span>
|
||||||
{selectedTags.size > 0 && (
|
)}
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="start" className={cn('p-0', filterCategory ? 'w-[420px]' : 'w-[200px]')}>
|
||||||
|
<div className="flex h-[300px]">
|
||||||
|
{/* Left: categories */}
|
||||||
|
<div className={cn('overflow-auto', filterCategory ? 'w-[160px] border-r border-border' : 'flex-1')}>
|
||||||
|
<div className="flex items-center justify-between px-2 py-1.5">
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Attributes</span>
|
||||||
|
{filters.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={clearFilters}
|
onClick={clearFilters}
|
||||||
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 shrink-0"
|
className="text-[10px] text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{filterCategories.map((cat) => {
|
||||||
|
const activeCount = filters.filter((f) => f.category === cat).length
|
||||||
|
const isSelected = filterCategory === cat
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cat}
|
||||||
|
onClick={() => setFilterCategory(cat)}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-1.5 px-2 py-1.5 text-xs text-left hover:bg-accent transition-colors',
|
||||||
|
isSelected && 'bg-accent text-foreground',
|
||||||
|
!isSelected && 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate">{toTitleCase(cat)}</span>
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<span className="rounded-full bg-primary text-primary-foreground px-1.5 text-[10px] font-medium leading-tight shrink-0">
|
||||||
|
{activeCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* Right: values for selected category */}
|
||||||
|
{filterCategory && (
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
|
<Command className="flex-1 flex flex-col">
|
||||||
|
<CommandInput placeholder={`Search ${toTitleCase(filterCategory).toLowerCase()}...`} />
|
||||||
|
<CommandList className="flex-1 overflow-auto max-h-none">
|
||||||
|
<CommandEmpty>No values found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{(valuesByCategory[filterCategory] ?? []).map((val) => {
|
||||||
|
const active = hasFilter(filters, { category: filterCategory, value: val })
|
||||||
|
return (
|
||||||
|
<CommandItem key={val} onSelect={() => toggleFilter(filterCategory, val)}>
|
||||||
|
<Check className={cn('size-3.5 mr-2 shrink-0', active ? 'opacity-100' : 'opacity-0')} />
|
||||||
|
<span className="truncate">{val}</span>
|
||||||
|
</CommandItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={toggleSearch}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground shrink-0',
|
||||||
|
searchOpen && 'text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Search className="size-3.5" />
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{searchOpen && (
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => 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 && (
|
||||||
|
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||||
|
{searchMatchPaths ? `${searchMatchPaths.size} matches` : '...'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={toggleSearch}
|
||||||
|
className="text-muted-foreground hover:text-foreground shrink-0"
|
||||||
>
|
>
|
||||||
<X className="size-3" />
|
<X className="size-3" />
|
||||||
Clear filters
|
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-1 flex-wrap">
|
|
||||||
{allTags.map((tag) => (
|
<div className="flex-1" />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
key={tag}
|
onClick={handleSaveClick}
|
||||||
onClick={() => toggleTag(tag)}
|
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground shrink-0"
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors',
|
|
||||||
selectedTags.has(tag)
|
|
||||||
? 'bg-primary text-primary-foreground border-transparent'
|
|
||||||
: 'bg-transparent text-muted-foreground border-border hover:bg-accent hover:text-accent-foreground',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{tag}
|
<Save className="size-3.5" />
|
||||||
|
{isDefaultBase ? 'Save As' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter bar */}
|
||||||
|
{filters.length > 0 && (
|
||||||
|
<div className="shrink-0 border-b border-border px-4 py-2">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">
|
||||||
|
{sortedNotes.length} of {enrichedNotes.length} notes
|
||||||
|
</span>
|
||||||
|
{filters.map((f) => (
|
||||||
|
<button
|
||||||
|
key={`${f.category}:${f.value}`}
|
||||||
|
onClick={() => toggleFilter(f.category, f.value)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full bg-primary text-primary-foreground px-2 py-0.5 text-[11px] font-medium"
|
||||||
|
>
|
||||||
|
<span className="text-primary-foreground/60">{f.category}:</span>
|
||||||
|
{f.value}
|
||||||
|
<X className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
<button onClick={clearFilters} className="text-xs text-muted-foreground hover:text-foreground">
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm" style={{ tableLayout: 'fixed' }}>
|
||||||
<thead className="sticky top-0 bg-background border-b border-border">
|
<colgroup>
|
||||||
|
{visibleColumns.map((col) => (
|
||||||
|
<col key={col} style={{ width: getColWidth(col) }} />
|
||||||
|
))}
|
||||||
|
</colgroup>
|
||||||
|
<thead className="sticky top-0 bg-background border-b border-border z-10">
|
||||||
<tr>
|
<tr>
|
||||||
|
{visibleColumns.map((col) => (
|
||||||
<th
|
<th
|
||||||
className="text-left px-4 py-2 font-medium text-muted-foreground cursor-pointer hover:text-foreground select-none"
|
key={col}
|
||||||
onClick={() => handleSort('name')}
|
className="relative text-left px-4 py-2 font-medium text-muted-foreground cursor-pointer hover:text-foreground select-none group"
|
||||||
|
onClick={() => handleSort(col)}
|
||||||
>
|
>
|
||||||
Name
|
<span className="truncate block">{toTitleCase(col)}<SortIcon field={col} /></span>
|
||||||
<SortIcon field="name" />
|
{/* Resize handle */}
|
||||||
</th>
|
<div
|
||||||
<th
|
className="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize opacity-0 group-hover:opacity-100 hover:!opacity-100 bg-border/60"
|
||||||
className="text-left px-4 py-2 font-medium text-muted-foreground cursor-pointer hover:text-foreground select-none"
|
onMouseDown={(e) => onResizeStart(col, e)}
|
||||||
onClick={() => handleSort('folder')}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
/>
|
||||||
Folder
|
|
||||||
<SortIcon field="folder" />
|
|
||||||
</th>
|
|
||||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground select-none">
|
|
||||||
Tags
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
className="text-left px-4 py-2 font-medium text-muted-foreground cursor-pointer hover:text-foreground select-none"
|
|
||||||
onClick={() => handleSort('mtimeMs')}
|
|
||||||
>
|
|
||||||
Last Modified
|
|
||||||
<SortIcon field="mtimeMs" />
|
|
||||||
</th>
|
</th>
|
||||||
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredNotes.map((note) => (
|
{pageNotes.map((note) => (
|
||||||
<tr
|
<tr
|
||||||
key={note.path}
|
key={note.path}
|
||||||
className="border-b border-border/50 hover:bg-accent/50 cursor-pointer transition-colors"
|
className="border-b border-border/50 hover:bg-accent/50 cursor-pointer transition-colors"
|
||||||
onClick={() => onSelectNote(note.path)}
|
onClick={() => onSelectNote(note.path)}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-2 font-medium">{note.name}</td>
|
{visibleColumns.map((col) => (
|
||||||
<td className="px-4 py-2 text-muted-foreground">{note.folder}</td>
|
<td key={col} className="px-4 py-2 overflow-hidden">
|
||||||
<td className="px-4 py-2">
|
<CellRenderer
|
||||||
<div className="flex items-center gap-1 flex-wrap">
|
note={note}
|
||||||
{note.tags.map((tag) => (
|
column={col}
|
||||||
<Badge
|
filters={filters}
|
||||||
key={tag}
|
toggleFilter={toggleFilter}
|
||||||
variant="secondary"
|
/>
|
||||||
className="text-[10px] px-1.5 py-0 cursor-pointer hover:bg-primary hover:text-primary-foreground"
|
</td>
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
toggleTag(tag)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</Badge>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-muted-foreground whitespace-nowrap">
|
|
||||||
{formatDate(note.mtimeMs)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{filteredNotes.length === 0 && (
|
{pageNotes.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">
|
<td colSpan={visibleColumns.length} className="px-4 py-8 text-center text-muted-foreground">
|
||||||
No notes found
|
No notes found
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -305,6 +669,152 @@ export function BasesView({ tree, onSelectNote }: BasesViewProps) {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="shrink-0 border-t border-border px-4 py-2 flex items-center justify-between">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{sortedNotes.length === 0
|
||||||
|
? '0 notes'
|
||||||
|
: `${clampedPage * PAGE_SIZE + 1}\u2013${Math.min((clampedPage + 1) * PAGE_SIZE, sortedNotes.length)} of ${sortedNotes.length}`}
|
||||||
|
</span>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
disabled={clampedPage === 0}
|
||||||
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-muted-foreground px-2">
|
||||||
|
Page {clampedPage + 1} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
disabled={clampedPage >= totalPages - 1}
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save As dialog */}
|
||||||
|
<Dialog open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[360px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Save Base</DialogTitle>
|
||||||
|
<DialogDescription>Choose a name for this base view.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<input
|
||||||
|
ref={saveInputRef}
|
||||||
|
type="text"
|
||||||
|
value={saveName}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<button
|
||||||
|
onClick={() => setSaveDialogOpen(false)}
|
||||||
|
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveConfirm}
|
||||||
|
disabled={!saveName.trim()}
|
||||||
|
className="rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 <span className="font-medium truncate block">{note.name}</span>
|
||||||
|
}
|
||||||
|
if (column === 'folder') {
|
||||||
|
return <span className="text-muted-foreground truncate block">{note.folder}</span>
|
||||||
|
}
|
||||||
|
if (column === 'mtimeMs') {
|
||||||
|
return <span className="text-muted-foreground whitespace-nowrap truncate block">{formatDate(note.mtimeMs)}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frontmatter column
|
||||||
|
const value = note.fields[column]
|
||||||
|
if (!value) return null
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
{value.map((v) => (
|
||||||
|
<CategoryBadge
|
||||||
|
key={v}
|
||||||
|
category={column}
|
||||||
|
value={v}
|
||||||
|
active={hasFilter(filters, { category: column, value: v })}
|
||||||
|
onClick={toggleFilter}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single string value — render as badge for filterability
|
||||||
|
return (
|
||||||
|
<CategoryBadge
|
||||||
|
category={column}
|
||||||
|
value={value}
|
||||||
|
active={hasFilter(filters, { category: column, value })}
|
||||||
|
onClick={toggleFilter}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryBadge({
|
||||||
|
category,
|
||||||
|
value,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
category: string
|
||||||
|
value: string
|
||||||
|
active: boolean
|
||||||
|
onClick: (category: string, value: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={active ? 'default' : 'secondary'}
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] px-1.5 py-0 cursor-pointer',
|
||||||
|
!active && 'hover:bg-primary hover:text-primary-foreground',
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onClick(category, value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
252
apps/x/apps/renderer/src/components/frontmatter-properties.tsx
Normal file
252
apps/x/apps/renderer/src/components/frontmatter-properties.tsx
Normal file
|
|
@ -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<string, string | string[]> = {}
|
||||||
|
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<FieldEntry[]>(() => fieldsFromRaw(raw))
|
||||||
|
const [editingNewKey, setEditingNewKey] = useState(false)
|
||||||
|
const newKeyRef = useRef<HTMLInputElement>(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 (
|
||||||
|
<div className="frontmatter-properties">
|
||||||
|
<button
|
||||||
|
className="frontmatter-toggle"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
size={14}
|
||||||
|
className={`frontmatter-chevron ${expanded ? 'expanded' : ''}`}
|
||||||
|
/>
|
||||||
|
<span className="frontmatter-label">
|
||||||
|
Properties{count > 0 ? ` (${count})` : ''}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="frontmatter-fields">
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<div key={`${field.key}-${index}`} className="frontmatter-row">
|
||||||
|
<span className="frontmatter-key" title={field.key}>
|
||||||
|
{field.key}
|
||||||
|
</span>
|
||||||
|
<div className="frontmatter-value-area">
|
||||||
|
{Array.isArray(field.value) ? (
|
||||||
|
<ArrayField
|
||||||
|
value={field.value}
|
||||||
|
editable={editable}
|
||||||
|
onChange={(v) => updateAndCommit(prev => {
|
||||||
|
const next = [...prev]
|
||||||
|
next[index] = { ...next[index], value: v }
|
||||||
|
return next
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
className="frontmatter-input"
|
||||||
|
value={field.value}
|
||||||
|
readOnly={!editable}
|
||||||
|
onChange={(e) => updateLocalValue(index, e.target.value)}
|
||||||
|
onBlur={() => commitField(index)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.currentTarget.blur()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editable && (
|
||||||
|
<button
|
||||||
|
className="frontmatter-remove"
|
||||||
|
onClick={() => removeField(index)}
|
||||||
|
type="button"
|
||||||
|
title="Remove property"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{editable && (
|
||||||
|
editingNewKey ? (
|
||||||
|
<div className="frontmatter-row frontmatter-new-row">
|
||||||
|
<input
|
||||||
|
ref={newKeyRef}
|
||||||
|
className="frontmatter-input frontmatter-new-key-input"
|
||||||
|
placeholder="Property name"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="frontmatter-add"
|
||||||
|
onClick={() => setEditingNewKey(true)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
<span>Add property</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="frontmatter-array">
|
||||||
|
{value.map((item, i) => (
|
||||||
|
<span key={i} className="frontmatter-chip">
|
||||||
|
<span className="frontmatter-chip-text">{item}</span>
|
||||||
|
{editable && (
|
||||||
|
<button
|
||||||
|
className="frontmatter-chip-remove"
|
||||||
|
onClick={() => removeItem(i)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{editable && (
|
||||||
|
<input
|
||||||
|
className="frontmatter-chip-input"
|
||||||
|
placeholder="Add..."
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
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 = ''
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -176,7 +176,7 @@ function getMarkdownWithBlankLines(editor: Editor): string {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
import { EditorToolbar } from './editor-toolbar'
|
import { EditorToolbar } from './editor-toolbar'
|
||||||
import { TagPills } from './tag-pills'
|
import { FrontmatterProperties } from './frontmatter-properties'
|
||||||
import { WikiLink } from '@/extensions/wiki-link'
|
import { WikiLink } from '@/extensions/wiki-link'
|
||||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||||
import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command'
|
import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command'
|
||||||
|
|
@ -201,7 +201,8 @@ interface MarkdownEditorProps {
|
||||||
editorSessionKey?: number
|
editorSessionKey?: number
|
||||||
onHistoryHandlersChange?: (handlers: { undo: () => boolean; redo: () => boolean } | null) => void
|
onHistoryHandlersChange?: (handlers: { undo: () => boolean; redo: () => boolean } | null) => void
|
||||||
editable?: boolean
|
editable?: boolean
|
||||||
tags?: string[]
|
frontmatter?: string | null
|
||||||
|
onFrontmatterChange?: (raw: string | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type WikiLinkMatch = {
|
type WikiLinkMatch = {
|
||||||
|
|
@ -290,7 +291,8 @@ export function MarkdownEditor({
|
||||||
editorSessionKey = 0,
|
editorSessionKey = 0,
|
||||||
onHistoryHandlersChange,
|
onHistoryHandlersChange,
|
||||||
editable = true,
|
editable = true,
|
||||||
tags,
|
frontmatter,
|
||||||
|
onFrontmatterChange,
|
||||||
}: MarkdownEditorProps) {
|
}: MarkdownEditorProps) {
|
||||||
const isInternalUpdate = useRef(false)
|
const isInternalUpdate = useRef(false)
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
@ -724,7 +726,13 @@ export function MarkdownEditor({
|
||||||
onSelectionHighlight={setSelectionHighlight}
|
onSelectionHighlight={setSelectionHighlight}
|
||||||
onImageUpload={handleImageUploadWithPlaceholder}
|
onImageUpload={handleImageUploadWithPlaceholder}
|
||||||
/>
|
/>
|
||||||
{tags && <TagPills tags={tags} />}
|
{(frontmatter !== undefined) && onFrontmatterChange && (
|
||||||
|
<FrontmatterProperties
|
||||||
|
raw={frontmatter}
|
||||||
|
onRawChange={onFrontmatterChange}
|
||||||
|
editable={editable}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
|
<div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
{wikiLinks ? (
|
{wikiLinks ? (
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
Mic,
|
Mic,
|
||||||
Network,
|
Network,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Table2,
|
||||||
Plug,
|
Plug,
|
||||||
LoaderIcon,
|
LoaderIcon,
|
||||||
Settings,
|
Settings,
|
||||||
|
|
@ -101,6 +102,7 @@ type KnowledgeActions = {
|
||||||
createNote: (parentPath?: string) => void
|
createNote: (parentPath?: string) => void
|
||||||
createFolder: (parentPath?: string) => void
|
createFolder: (parentPath?: string) => void
|
||||||
openGraph: () => void
|
openGraph: () => void
|
||||||
|
openBases: () => void
|
||||||
expandAll: () => void
|
expandAll: () => void
|
||||||
collapseAll: () => void
|
collapseAll: () => void
|
||||||
rename: (path: string, newName: string, isDir: boolean) => Promise<void>
|
rename: (path: string, newName: string, isDir: boolean) => Promise<void>
|
||||||
|
|
@ -855,6 +857,7 @@ function KnowledgeSection({
|
||||||
{ icon: FilePlus, label: "New Note", action: () => actions.createNote() },
|
{ icon: FilePlus, label: "New Note", action: () => actions.createNote() },
|
||||||
{ icon: FolderPlus, label: "New Folder", action: () => actions.createFolder() },
|
{ icon: FolderPlus, label: "New Folder", action: () => actions.createFolder() },
|
||||||
{ icon: Network, label: "Graph View", action: () => actions.openGraph() },
|
{ icon: Network, label: "Graph View", action: () => actions.openGraph() },
|
||||||
|
{ icon: Table2, label: "Bases", action: () => actions.openBases() },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
interface TagPillsProps {
|
|
||||||
tags: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TagPills({ tags }: TagPillsProps) {
|
|
||||||
if (tags.length === 0) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="tag-pills-row">
|
|
||||||
{tags.map((tag, i) => (
|
|
||||||
<span key={`${tag}-${i}`} className="tag-pill">
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -29,6 +29,209 @@ export function joinFrontmatter(raw: string | null, body: string): string {
|
||||||
return raw + '\n' + body
|
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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[cat]) {
|
||||||
|
;(fields as Record<string, unknown>)[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<string, string | string[]> {
|
||||||
|
const result: Record<string, string | string[]> = {}
|
||||||
|
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, string | string[]>): 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<string, string> = {
|
||||||
|
// 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. */
|
/** Tag category keys used in the categorized frontmatter format. */
|
||||||
const TAG_CATEGORY_KEYS = new Set([
|
const TAG_CATEGORY_KEYS = new Set([
|
||||||
'relationship',
|
'relationship',
|
||||||
|
|
|
||||||
|
|
@ -237,34 +237,200 @@
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tag pills row shown between toolbar and editor content */
|
/* Frontmatter properties panel between toolbar and editor content */
|
||||||
.tag-pills-row {
|
.frontmatter-properties {
|
||||||
display: flex;
|
flex-shrink: 0;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
background-color: var(--background);
|
background-color: var(--background);
|
||||||
flex-shrink: 0;
|
font-size: 13px;
|
||||||
max-height: 4.5em;
|
color: var(--foreground);
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-pill {
|
.frontmatter-toggle {
|
||||||
font-size: 11px;
|
display: flex;
|
||||||
line-height: 18px;
|
align-items: center;
|
||||||
padding: 0 8px;
|
gap: 4px;
|
||||||
border-radius: 9999px;
|
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);
|
background-color: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||||
color: var(--foreground);
|
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;
|
white-space: nowrap;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .tag-pill {
|
.dark .frontmatter-chip {
|
||||||
background-color: color-mix(in srgb, var(--foreground) 12%, transparent);
|
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 {
|
.editor-toolbar .separator {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue