From 0af48ecd4ad0ec22757215aba09a4af13383529a Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Thu, 28 May 2026 00:33:21 +0530 Subject: [PATCH] new knowledge view --- apps/x/apps/renderer/src/App.tsx | 6 +- .../src/components/knowledge-view.tsx | 803 +++++++++++++----- 2 files changed, 597 insertions(+), 212 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index f758e180..dd671eda 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -4554,10 +4554,8 @@ function App() { void navigateToView({ type: 'workspace', path }) }, openKnowledgeView: () => { - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask) { - setIsChatSidebarOpen(false) - setIsRightPaneMaximized(false) - } + // Open in the middle pane without touching the chat sidebar — leave it + // open or closed exactly as the user had it (matches Email/Meetings). void navigateToView({ type: 'knowledge-view' }) }, createWorkspace: async (name: string): Promise => { diff --git a/apps/x/apps/renderer/src/components/knowledge-view.tsx b/apps/x/apps/renderer/src/components/knowledge-view.tsx index a757a34c..c7674fb4 100644 --- a/apps/x/apps/renderer/src/components/knowledge-view.tsx +++ b/apps/x/apps/renderer/src/components/knowledge-view.tsx @@ -1,11 +1,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { + ArrowLeft, ChevronRight, Copy, ExternalLink, - File as FileIcon, FilePlus, - Folder as FolderIcon, + FileText, FolderOpen, FolderPlus, Network, @@ -56,9 +56,48 @@ type KnowledgeViewProps = { onVoiceNoteCreated?: (path: string) => void } -type FlatRow = { - node: TreeNode - depth: number +// Folders that have their own dedicated destinations elsewhere in the app. +const HIDDEN_PATHS = new Set(['knowledge/Meetings', 'knowledge/Workspace']) + +// Theme-aware accent palette for folder avatars — colored letter on a faint +// tint of the same hue. Mirrors the design's six-colour rotation. +const AVATAR_PALETTE = [ + 'bg-indigo-500/10 text-indigo-600 dark:text-indigo-400', + 'bg-violet-500/10 text-violet-600 dark:text-violet-400', + 'bg-amber-500/10 text-amber-600 dark:text-amber-400', + 'bg-rose-500/10 text-rose-600 dark:text-rose-400', + 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', + 'bg-sky-500/10 text-sky-600 dark:text-sky-400', +] as const + +function avatarClass(name: string): string { + let hash = 0 + for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) >>> 0 + return AVATAR_PALETTE[hash % AVATAR_PALETTE.length] +} + +function isMarkdown(node: TreeNode): boolean { + return node.kind === 'file' && node.name.toLowerCase().endsWith('.md') +} + +// All markdown notes within a node (recurses into subfolders). +function collectNotes(node: TreeNode): TreeNode[] { + if (node.kind === 'file') return isMarkdown(node) ? [node] : [] + const out: TreeNode[] = [] + for (const child of node.children ?? []) out.push(...collectNotes(child)) + return out +} + +function recentNotes(node: TreeNode, limit: number): TreeNode[] { + return collectNotes(node) + .sort((a, b) => (b.stat?.mtimeMs ?? 0) - (a.stat?.mtimeMs ?? 0)) + .slice(0, limit) +} + +function latestMtime(node: TreeNode): number { + let max = node.stat?.mtimeMs ?? 0 + for (const child of node.children ?? []) max = Math.max(max, latestMtime(child)) + return max } function sortNodes(nodes: TreeNode[]): TreeNode[] { @@ -68,23 +107,22 @@ function sortNodes(nodes: TreeNode[]): TreeNode[] { }) } -function flatten( - nodes: TreeNode[], - expanded: Set, - depth: number, - out: FlatRow[], -): void { - for (const node of sortNodes(nodes)) { - out.push({ node, depth }) - if (node.kind === 'dir' && expanded.has(node.path) && node.children?.length) { - flatten(node.children, expanded, depth + 1, out) +function findNode(nodes: TreeNode[], path: string): TreeNode | null { + for (const node of nodes) { + if (node.path === path) return node + if (node.children) { + const found = findNode(node.children, path) + if (found) return found } } + return null } function formatModified(mtimeMs?: number): string { if (!mtimeMs) return '' - return formatRelativeTime(new Date(mtimeMs).toISOString()) + const rel = formatRelativeTime(new Date(mtimeMs).toISOString()) + if (!rel || rel === 'just now') return rel + return `${rel} ago` } function getFileManagerName(): string { @@ -96,15 +134,10 @@ function getFileManagerName(): string { } function displayName(node: TreeNode): string { - if (node.kind === 'file' && node.name.toLowerCase().endsWith('.md')) { - return node.name.slice(0, -3) - } + if (isMarkdown(node)) return node.name.slice(0, -3) return node.name } -const INDENT_PX = 16 -const ROW_PADDING_PX = 12 - export function KnowledgeView({ tree, actions, @@ -114,191 +147,594 @@ export function KnowledgeView({ onOpenBases, onVoiceNoteCreated, }: KnowledgeViewProps) { - const [expanded, setExpanded] = useState>(new Set()) + // null = root (folder overview); otherwise the path of the folder being browsed. + const [folderPath, setFolderPath] = useState(null) const [renameTarget, setRenameTarget] = useState(null) - const rows = useMemo(() => { - const out: FlatRow[] = [] - // Meetings and Workspace have dedicated destinations, so hide them here. - const visible = tree.filter((n) => n.path !== 'knowledge/Meetings' && n.path !== 'knowledge/Workspace') - flatten(visible, expanded, 0, out) - return out - }, [tree, expanded]) - - const handleRowClick = useCallback( - (node: TreeNode) => { - if (node.kind === 'dir') { - setExpanded((prev) => { - const next = new Set(prev) - if (next.has(node.path)) next.delete(node.path) - else next.add(node.path) - return next - }) - } else { - onOpenNote(node.path) - } - }, - [onOpenNote], + const topLevel = useMemo( + () => tree.filter((n) => !HIDDEN_PATHS.has(n.path)), + [tree], ) + const folders = useMemo( + () => sortNodes(topLevel.filter((n) => n.kind === 'dir')), + [topLevel], + ) + const looseNotes = useMemo( + () => sortNodes(topLevel.filter((n) => isMarkdown(n))), + [topLevel], + ) + + const totalNotes = useMemo( + () => topLevel.reduce((sum, n) => sum + collectNotes(n).length, 0), + [topLevel], + ) + + const openFolder = useCallback((path: string) => setFolderPath(path), []) + + // When the open folder no longer exists (deleted/renamed externally), fall + // back to the root overview rather than holding a dangling drill-down. + const currentFolder = folderPath ? findNode(tree, folderPath) : null + return (
-
-

Notes

-
+
+
+

Notes

+

+ {totalNotes} {totalNotes === 1 ? 'note' : 'notes'} across {folders.length}{' '} + {folders.length === 1 ? 'folder' : 'folders'} +

+
+
+ + + - - - - - -
-
-
-
Page name
-
Modified
-
- - {rows.length === 0 ? ( -
No pages yet.
+
+ {currentFolder ? ( + setRenameTarget(null)} + onNavigate={setFolderPath} + onOpenFolder={openFolder} + onOpenNote={onOpenNote} + /> ) : ( - rows.map(({ node, depth }) => ( - setRenameTarget(p)} - onClearRename={() => setRenameTarget(null)} - onClick={handleRowClick} - /> - )) + <> + + {folders.length === 0 ? ( + + ) : ( +
+ {folders.map((node, i) => ( +
0 && 'border-t border-border/60')}> + setRenameTarget(null)} + onOpenFolder={openFolder} + onOpenNote={onOpenNote} + /> +
+ ))} +
+ )} + + {looseNotes.length > 0 && ( +
+ +
+ {looseNotes.map((node, i) => ( +
0 && 'border-t border-border/60')}> + setRenameTarget(null)} + onOpenFolder={openFolder} + onOpenNote={onOpenNote} + /> +
+ ))} +
+
+ )} + )} + +
) } -function KnowledgeRow({ - node, - depth, - isExpanded, +function QuickActions({ actions, - renameActive, - onRequestRename, - onClearRename, + currentFolder, + onOpenBases, + onFolderCreated, +}: { + actions: KnowledgeViewActions + currentFolder: TreeNode | null + onOpenBases: () => void + onFolderCreated: (path: string) => void +}) { + // Inside a folder these target that folder; at the root they target knowledge/. + const parent = currentFolder?.path + return ( +
+ +
+ actions.createNote(parent)} /> + { + try { + const path = await actions.createFolder(parent) + onFolderCreated(path) + } catch { /* ignore */ } + }} + /> + + actions.revealInFileManager(parent ?? 'knowledge', true)} + /> +
+
+ ) +} + +function SecondaryButton({ + icon: Icon, + label, onClick, +}: { + icon: typeof SearchIcon + label: string + onClick: () => void +}) { + return ( + + ) +} + +function QuickAction({ + icon: Icon, + label, + onClick, +}: { + icon: typeof FilePlus + label: string + onClick: () => void +}) { + return ( + + ) +} + +function SectionHeader({ label, aside }: { label: string; aside?: string }) { + return ( +
+ + {label} + + {aside && {aside}} +
+ ) +} + +function EmptyState({ text }: { text: string }) { + return ( +
+ {text} +
+ ) +} + +function FolderAvatar({ name, className }: { name: string; className?: string }) { + return ( +
+ {name.charAt(0).toUpperCase() || '?'} +
+ ) +} + +function FolderCard({ + node, + actions, + renameTarget, + onRequestRename, + onClearRename, + onOpenFolder, + onOpenNote, }: { node: TreeNode - depth: number - isExpanded: boolean actions: KnowledgeViewActions - renameActive: boolean + renameTarget: string | null onRequestRename: (path: string) => void onClearRename: () => void - onClick: (node: TreeNode) => void + onOpenFolder: (path: string) => void + onOpenNote: (path: string) => void +}) { + const count = useMemo(() => collectNotes(node).length, [node]) + const peek = useMemo(() => recentNotes(node, 3), [node]) + const modified = formatModified(latestMtime(node)) + const renameActive = renameTarget === node.path + + const card = ( +
onOpenFolder(node.path)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onOpenFolder(node.path) + } + }} + className="group flex w-full cursor-pointer items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/50" + > + +
+ {renameActive ? ( + + ) : ( + + {node.name} + + )} +
+ {count} {count === 1 ? 'note' : 'notes'} +
+ {peek.length > 0 && ( +
+ {peek.map((n) => ( + + ))} +
+ )} +
+
+ + {modified} + + +
+
+ ) + + return ( + + {card} + + ) +} + +function FolderDetail({ + folder, + actions, + renameTarget, + onRequestRename, + onClearRename, + onNavigate, + onOpenFolder, + onOpenNote, +}: { + folder: TreeNode + actions: KnowledgeViewActions + renameTarget: string | null + onRequestRename: (path: string) => void + onClearRename: () => void + onNavigate: (path: string | null) => void + onOpenFolder: (path: string) => void + onOpenNote: (path: string) => void +}) { + const items = useMemo(() => sortNodes(folder.children ?? []), [folder]) + + // Breadcrumb segments from "knowledge/A/B" → [{ name: 'A', path }, ...]. + const crumbs = useMemo(() => { + const rel = folder.path.startsWith('knowledge/') + ? folder.path.slice('knowledge/'.length) + : folder.path + const parts = rel.split('/').filter(Boolean) + const out: { name: string; path: string }[] = [] + let acc = 'knowledge' + for (const part of parts) { + acc = `${acc}/${part}` + out.push({ name: part, path: acc }) + } + return out + }, [folder.path]) + + return ( + <> +
+ + + {crumbs.map((c, i) => ( + + + {i === crumbs.length - 1 ? ( + {c.name} + ) : ( + + )} + + ))} +
+ + + {items.length === 0 ? ( + + ) : ( +
+ {items.map((node, i) => ( +
0 && 'border-t border-border/60')}> + +
+ ))} +
+ )} + + ) +} + +function ItemRow({ + node, + actions, + renameTarget, + onRequestRename, + onClearRename, + onOpenFolder, + onOpenNote, +}: { + node: TreeNode + actions: KnowledgeViewActions + renameTarget: string | null + onRequestRename: (path: string) => void + onClearRename: () => void + onOpenFolder: (path: string) => void + onOpenNote: (path: string) => void }) { const isDir = node.kind === 'dir' - const Icon = isDir ? FolderIcon : FileIcon - const paddingLeft = ROW_PADDING_PX + depth * INDENT_PX - const baseName = displayName(node) + const renameActive = renameTarget === node.path + const modified = formatModified(isDir ? latestMtime(node) : node.stat?.mtimeMs) + const count = useMemo(() => (isDir ? collectNotes(node).length : 0), [isDir, node]) - const [newName, setNewName] = useState(baseName) + const handleOpen = useCallback(() => { + if (isDir) onOpenFolder(node.path) + else onOpenNote(node.path) + }, [isDir, node.path, onOpenFolder, onOpenNote]) + + const row = ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handleOpen() + } + }} + className="group flex w-full cursor-pointer items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/50" + > + {isDir ? ( + + ) : ( +
+ +
+ )} +
+ {renameActive ? ( + + ) : ( + {displayName(node)} + )} + {isDir && ( +
+ {count} {count === 1 ? 'note' : 'notes'} +
+ )} +
+
+ + {modified} + + {isDir && ( + + )} +
+
+ ) + + return ( + + {row} + + ) +} + +function RenameField({ + initial, + isDir, + path, + actions, + onDone, +}: { + initial: string + isDir: boolean + path: string + actions: KnowledgeViewActions + onDone: () => void +}) { + const [value, setValue] = useState(initial) const inputRef = useRef(null) const isSubmittingRef = useRef(false) useEffect(() => { - if (renameActive) { - setNewName(baseName) - isSubmittingRef.current = false - // focus on next tick after mount - requestAnimationFrame(() => { - inputRef.current?.focus() - inputRef.current?.select() - }) - } - }, [renameActive, baseName]) + requestAnimationFrame(() => { + inputRef.current?.focus() + inputRef.current?.select() + }) + }, []) - const handleRenameSubmit = useCallback(async () => { + const submit = useCallback(async () => { if (isSubmittingRef.current) return isSubmittingRef.current = true - const trimmed = newName.trim() - if (trimmed && trimmed !== baseName) { + const trimmed = value.trim() + if (trimmed && trimmed !== initial) { try { - await actions.rename(node.path, trimmed, isDir) + await actions.rename(path, trimmed, isDir) toast('Renamed successfully', 'success') } catch { toast('Failed to rename', 'error') } } - onClearRename() - setTimeout(() => { - isSubmittingRef.current = false - }, 100) - }, [actions, baseName, isDir, newName, node.path, onClearRename]) + onDone() + }, [actions, initial, isDir, onDone, path, value]) - const cancelRename = useCallback(() => { + const cancel = useCallback(() => { isSubmittingRef.current = true - setNewName(baseName) - onClearRename() - setTimeout(() => { - isSubmittingRef.current = false - }, 100) - }, [baseName, onClearRename]) + onDone() + }, [onDone]) + + return ( + setValue(e.target.value)} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + e.stopPropagation() + if (e.key === 'Enter') { + e.preventDefault() + void submit() + } else if (e.key === 'Escape') { + e.preventDefault() + cancel() + } + }} + onBlur={() => { + if (!isSubmittingRef.current) void submit() + }} + className="h-7 text-sm" + /> + ) +} + +function RowContextMenu({ + node, + actions, + onRequestRename, + children, +}: { + node: TreeNode + actions: KnowledgeViewActions + onRequestRename: (path: string) => void + children: React.ReactNode +}) { + const isDir = node.kind === 'dir' const handleDelete = useCallback(async () => { try { @@ -314,58 +750,9 @@ function KnowledgeRow({ toast('Path copied', 'success') }, [actions, node.path]) - const row = ( - - ) - return ( - {row} + {children} e.preventDefault()}> {isDir && ( <>