mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-03 19:25:19 +02:00
new knowledge view
This commit is contained in:
parent
2e930612f8
commit
0af48ecd4a
2 changed files with 597 additions and 212 deletions
|
|
@ -4554,10 +4554,8 @@ function App() {
|
||||||
void navigateToView({ type: 'workspace', path })
|
void navigateToView({ type: 'workspace', path })
|
||||||
},
|
},
|
||||||
openKnowledgeView: () => {
|
openKnowledgeView: () => {
|
||||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask) {
|
// Open in the middle pane without touching the chat sidebar — leave it
|
||||||
setIsChatSidebarOpen(false)
|
// open or closed exactly as the user had it (matches Email/Meetings).
|
||||||
setIsRightPaneMaximized(false)
|
|
||||||
}
|
|
||||||
void navigateToView({ type: 'knowledge-view' })
|
void navigateToView({ type: 'knowledge-view' })
|
||||||
},
|
},
|
||||||
createWorkspace: async (name: string): Promise<string> => {
|
createWorkspace: async (name: string): Promise<string> => {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
|
ArrowLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Copy,
|
Copy,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
File as FileIcon,
|
|
||||||
FilePlus,
|
FilePlus,
|
||||||
Folder as FolderIcon,
|
FileText,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
Network,
|
Network,
|
||||||
|
|
@ -56,9 +56,48 @@ type KnowledgeViewProps = {
|
||||||
onVoiceNoteCreated?: (path: string) => void
|
onVoiceNoteCreated?: (path: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type FlatRow = {
|
// Folders that have their own dedicated destinations elsewhere in the app.
|
||||||
node: TreeNode
|
const HIDDEN_PATHS = new Set(['knowledge/Meetings', 'knowledge/Workspace'])
|
||||||
depth: number
|
|
||||||
|
// 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[] {
|
function sortNodes(nodes: TreeNode[]): TreeNode[] {
|
||||||
|
|
@ -68,23 +107,22 @@ function sortNodes(nodes: TreeNode[]): TreeNode[] {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function flatten(
|
function findNode(nodes: TreeNode[], path: string): TreeNode | null {
|
||||||
nodes: TreeNode[],
|
for (const node of nodes) {
|
||||||
expanded: Set<string>,
|
if (node.path === path) return node
|
||||||
depth: number,
|
if (node.children) {
|
||||||
out: FlatRow[],
|
const found = findNode(node.children, path)
|
||||||
): void {
|
if (found) return found
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatModified(mtimeMs?: number): string {
|
function formatModified(mtimeMs?: number): string {
|
||||||
if (!mtimeMs) return ''
|
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 {
|
function getFileManagerName(): string {
|
||||||
|
|
@ -96,15 +134,10 @@ function getFileManagerName(): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayName(node: TreeNode): string {
|
function displayName(node: TreeNode): string {
|
||||||
if (node.kind === 'file' && node.name.toLowerCase().endsWith('.md')) {
|
if (isMarkdown(node)) return node.name.slice(0, -3)
|
||||||
return node.name.slice(0, -3)
|
|
||||||
}
|
|
||||||
return node.name
|
return node.name
|
||||||
}
|
}
|
||||||
|
|
||||||
const INDENT_PX = 16
|
|
||||||
const ROW_PADDING_PX = 12
|
|
||||||
|
|
||||||
export function KnowledgeView({
|
export function KnowledgeView({
|
||||||
tree,
|
tree,
|
||||||
actions,
|
actions,
|
||||||
|
|
@ -114,191 +147,594 @@ export function KnowledgeView({
|
||||||
onOpenBases,
|
onOpenBases,
|
||||||
onVoiceNoteCreated,
|
onVoiceNoteCreated,
|
||||||
}: KnowledgeViewProps) {
|
}: KnowledgeViewProps) {
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
// null = root (folder overview); otherwise the path of the folder being browsed.
|
||||||
|
const [folderPath, setFolderPath] = useState<string | null>(null)
|
||||||
const [renameTarget, setRenameTarget] = useState<string | null>(null)
|
const [renameTarget, setRenameTarget] = useState<string | null>(null)
|
||||||
|
|
||||||
const rows = useMemo<FlatRow[]>(() => {
|
const topLevel = useMemo(
|
||||||
const out: FlatRow[] = []
|
() => tree.filter((n) => !HIDDEN_PATHS.has(n.path)),
|
||||||
// Meetings and Workspace have dedicated destinations, so hide them here.
|
[tree],
|
||||||
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 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 (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-border px-8 py-6">
|
<div className="shrink-0 flex items-start justify-between gap-4 border-b border-border px-8 py-6">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Notes</h1>
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<h1 className="text-2xl font-bold tracking-tight">Notes</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{totalNotes} {totalNotes === 1 ? 'note' : 'notes'} across {folders.length}{' '}
|
||||||
|
{folders.length === 1 ? 'folder' : 'folders'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<VoiceNoteButton onNoteCreated={onVoiceNoteCreated} />
|
||||||
|
<SecondaryButton icon={SearchIcon} label="Search" onClick={onOpenSearch} />
|
||||||
|
<SecondaryButton icon={Network} label="Graph" onClick={onOpenGraph} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => actions.createNote()}
|
onClick={() => actions.createNote(currentFolder?.path)}
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
|
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
<FilePlus className="size-4" />
|
<FilePlus className="size-4" />
|
||||||
<span>New note</span>
|
<span>New note</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
const path = await actions.createFolder()
|
|
||||||
setRenameTarget(path)
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}}
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
|
|
||||||
>
|
|
||||||
<FolderPlus className="size-4" />
|
|
||||||
<span>New folder</span>
|
|
||||||
</button>
|
|
||||||
<VoiceNoteButton onNoteCreated={onVoiceNoteCreated} />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onOpenSearch}
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
|
|
||||||
>
|
|
||||||
<SearchIcon className="size-4" />
|
|
||||||
<span>Search</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onOpenBases}
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
|
|
||||||
>
|
|
||||||
<Table2 className="size-4" />
|
|
||||||
<span>Bases</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onOpenGraph}
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
|
|
||||||
>
|
|
||||||
<Network className="size-4" />
|
|
||||||
<span>Graph view</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => actions.revealInFileManager('knowledge', true)}
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
|
|
||||||
>
|
|
||||||
<FolderOpen className="size-4" />
|
|
||||||
<span>Open in {getFileManagerName()}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="min-w-[480px]">
|
<div className="mx-auto w-full max-w-3xl px-8 py-6">
|
||||||
<div className="sticky top-0 z-10 flex items-center border-b border-border bg-background px-6 py-2 text-xs font-medium text-muted-foreground">
|
{currentFolder ? (
|
||||||
<div className="flex-1">Page name</div>
|
<FolderDetail
|
||||||
<div className="w-32 shrink-0">Modified</div>
|
folder={currentFolder}
|
||||||
</div>
|
actions={actions}
|
||||||
|
renameTarget={renameTarget}
|
||||||
{rows.length === 0 ? (
|
onRequestRename={setRenameTarget}
|
||||||
<div className="px-6 py-8 text-sm text-muted-foreground">No pages yet.</div>
|
onClearRename={() => setRenameTarget(null)}
|
||||||
|
onNavigate={setFolderPath}
|
||||||
|
onOpenFolder={openFolder}
|
||||||
|
onOpenNote={onOpenNote}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
rows.map(({ node, depth }) => (
|
<>
|
||||||
<KnowledgeRow
|
<SectionHeader label={`Folders · ${folders.length}`} aside="Sorted by name" />
|
||||||
key={node.path}
|
{folders.length === 0 ? (
|
||||||
node={node}
|
<EmptyState text="No folders yet." />
|
||||||
depth={depth}
|
) : (
|
||||||
isExpanded={expanded.has(node.path)}
|
<div className="overflow-hidden rounded-xl border border-border">
|
||||||
actions={actions}
|
{folders.map((node, i) => (
|
||||||
renameActive={renameTarget === node.path}
|
<div key={node.path} className={cn(i > 0 && 'border-t border-border/60')}>
|
||||||
onRequestRename={(p) => setRenameTarget(p)}
|
<FolderCard
|
||||||
onClearRename={() => setRenameTarget(null)}
|
node={node}
|
||||||
onClick={handleRowClick}
|
actions={actions}
|
||||||
/>
|
renameTarget={renameTarget}
|
||||||
))
|
onRequestRename={setRenameTarget}
|
||||||
|
onClearRename={() => setRenameTarget(null)}
|
||||||
|
onOpenFolder={openFolder}
|
||||||
|
onOpenNote={onOpenNote}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{looseNotes.length > 0 && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<SectionHeader label={`Loose notes · ${looseNotes.length}`} />
|
||||||
|
<div className="overflow-hidden rounded-xl border border-border">
|
||||||
|
{looseNotes.map((node, i) => (
|
||||||
|
<div key={node.path} className={cn(i > 0 && 'border-t border-border/60')}>
|
||||||
|
<ItemRow
|
||||||
|
node={node}
|
||||||
|
actions={actions}
|
||||||
|
renameTarget={renameTarget}
|
||||||
|
onRequestRename={setRenameTarget}
|
||||||
|
onClearRename={() => setRenameTarget(null)}
|
||||||
|
onOpenFolder={openFolder}
|
||||||
|
onOpenNote={onOpenNote}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<QuickActions
|
||||||
|
actions={actions}
|
||||||
|
currentFolder={currentFolder}
|
||||||
|
onOpenBases={onOpenBases}
|
||||||
|
onFolderCreated={setRenameTarget}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function KnowledgeRow({
|
function QuickActions({
|
||||||
node,
|
|
||||||
depth,
|
|
||||||
isExpanded,
|
|
||||||
actions,
|
actions,
|
||||||
renameActive,
|
currentFolder,
|
||||||
onRequestRename,
|
onOpenBases,
|
||||||
onClearRename,
|
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 (
|
||||||
|
<div className="mt-8">
|
||||||
|
<SectionHeader label="Quick actions" />
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<QuickAction icon={FilePlus} label="New note" onClick={() => actions.createNote(parent)} />
|
||||||
|
<QuickAction
|
||||||
|
icon={FolderPlus}
|
||||||
|
label="New folder"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const path = await actions.createFolder(parent)
|
||||||
|
onFolderCreated(path)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<QuickAction icon={Table2} label="Open as base" onClick={onOpenBases} />
|
||||||
|
<QuickAction
|
||||||
|
icon={FolderOpen}
|
||||||
|
label={`Reveal in ${getFileManagerName()}`}
|
||||||
|
onClick={() => actions.revealInFileManager(parent ?? 'knowledge', true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SecondaryButton({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
onClick,
|
onClick,
|
||||||
|
}: {
|
||||||
|
icon: typeof SearchIcon
|
||||||
|
label: string
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickAction({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
icon: typeof FilePlus
|
||||||
|
label: string
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
<Icon className="size-4 text-muted-foreground" />
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHeader({ label, aside }: { label: string; aside?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-2.5 flex items-center justify-between">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{aside && <span className="text-xs text-muted-foreground">{aside}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-dashed border-border px-6 py-10 text-center text-sm text-muted-foreground">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FolderAvatar({ name, className }: { name: string; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex size-8 shrink-0 items-center justify-center rounded-md text-[13px] font-bold',
|
||||||
|
avatarClass(name),
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{name.charAt(0).toUpperCase() || '?'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FolderCard({
|
||||||
|
node,
|
||||||
|
actions,
|
||||||
|
renameTarget,
|
||||||
|
onRequestRename,
|
||||||
|
onClearRename,
|
||||||
|
onOpenFolder,
|
||||||
|
onOpenNote,
|
||||||
}: {
|
}: {
|
||||||
node: TreeNode
|
node: TreeNode
|
||||||
depth: number
|
|
||||||
isExpanded: boolean
|
|
||||||
actions: KnowledgeViewActions
|
actions: KnowledgeViewActions
|
||||||
renameActive: boolean
|
renameTarget: string | null
|
||||||
onRequestRename: (path: string) => void
|
onRequestRename: (path: string) => void
|
||||||
onClearRename: () => 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 = (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => 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"
|
||||||
|
>
|
||||||
|
<FolderAvatar name={node.name} className="mt-0.5" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{renameActive ? (
|
||||||
|
<RenameField
|
||||||
|
initial={node.name}
|
||||||
|
isDir
|
||||||
|
path={node.path}
|
||||||
|
actions={actions}
|
||||||
|
onDone={onClearRename}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="block truncate text-sm font-semibold text-foreground">
|
||||||
|
{node.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
{count} {count === 1 ? 'note' : 'notes'}
|
||||||
|
</div>
|
||||||
|
{peek.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{peek.map((n) => (
|
||||||
|
<button
|
||||||
|
key={n.path}
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onOpenNote(n.path)
|
||||||
|
}}
|
||||||
|
className="max-w-[200px] truncate rounded-full border border-border/60 bg-muted px-2.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
{displayName(n)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2 pt-1">
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
|
||||||
|
{modified}
|
||||||
|
</span>
|
||||||
|
<ChevronRight className="size-4 text-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RowContextMenu node={node} actions={actions} onRequestRename={onRequestRename}>
|
||||||
|
{card}
|
||||||
|
</RowContextMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 flex min-w-0 items-center gap-1.5 text-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const parent = crumbs.length >= 2 ? crumbs[crumbs.length - 2].path : null
|
||||||
|
onNavigate(parent)
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
aria-label="Back"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNavigate(null)}
|
||||||
|
className="rounded-md px-1.5 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
Notes
|
||||||
|
</button>
|
||||||
|
{crumbs.map((c, i) => (
|
||||||
|
<span key={c.path} className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground/50" />
|
||||||
|
{i === crumbs.length - 1 ? (
|
||||||
|
<span className="truncate font-medium text-foreground">{c.name}</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNavigate(c.path)}
|
||||||
|
className="truncate rounded-md px-1.5 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionHeader label={`${items.length} ${items.length === 1 ? 'item' : 'items'}`} />
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<EmptyState text="This folder is empty." />
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-border">
|
||||||
|
{items.map((node, i) => (
|
||||||
|
<div key={node.path} className={cn(i > 0 && 'border-t border-border/60')}>
|
||||||
|
<ItemRow
|
||||||
|
node={node}
|
||||||
|
actions={actions}
|
||||||
|
renameTarget={renameTarget}
|
||||||
|
onRequestRename={onRequestRename}
|
||||||
|
onClearRename={onClearRename}
|
||||||
|
onOpenFolder={onOpenFolder}
|
||||||
|
onOpenNote={onOpenNote}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 isDir = node.kind === 'dir'
|
||||||
const Icon = isDir ? FolderIcon : FileIcon
|
const renameActive = renameTarget === node.path
|
||||||
const paddingLeft = ROW_PADDING_PX + depth * INDENT_PX
|
const modified = formatModified(isDir ? latestMtime(node) : node.stat?.mtimeMs)
|
||||||
const baseName = displayName(node)
|
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 = (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={handleOpen}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
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 ? (
|
||||||
|
<FolderAvatar name={node.name} />
|
||||||
|
) : (
|
||||||
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||||
|
<FileText className="size-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{renameActive ? (
|
||||||
|
<RenameField
|
||||||
|
initial={displayName(node)}
|
||||||
|
isDir={isDir}
|
||||||
|
path={node.path}
|
||||||
|
actions={actions}
|
||||||
|
onDone={onClearRename}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="block truncate text-sm text-foreground">{displayName(node)}</span>
|
||||||
|
)}
|
||||||
|
{isDir && (
|
||||||
|
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
{count} {count === 1 ? 'note' : 'notes'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
|
||||||
|
{modified}
|
||||||
|
</span>
|
||||||
|
{isDir && (
|
||||||
|
<ChevronRight className="size-4 text-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RowContextMenu node={node} actions={actions} onRequestRename={onRequestRename}>
|
||||||
|
{row}
|
||||||
|
</RowContextMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<HTMLInputElement | null>(null)
|
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||||
const isSubmittingRef = useRef(false)
|
const isSubmittingRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (renameActive) {
|
requestAnimationFrame(() => {
|
||||||
setNewName(baseName)
|
inputRef.current?.focus()
|
||||||
isSubmittingRef.current = false
|
inputRef.current?.select()
|
||||||
// focus on next tick after mount
|
})
|
||||||
requestAnimationFrame(() => {
|
}, [])
|
||||||
inputRef.current?.focus()
|
|
||||||
inputRef.current?.select()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [renameActive, baseName])
|
|
||||||
|
|
||||||
const handleRenameSubmit = useCallback(async () => {
|
const submit = useCallback(async () => {
|
||||||
if (isSubmittingRef.current) return
|
if (isSubmittingRef.current) return
|
||||||
isSubmittingRef.current = true
|
isSubmittingRef.current = true
|
||||||
const trimmed = newName.trim()
|
const trimmed = value.trim()
|
||||||
if (trimmed && trimmed !== baseName) {
|
if (trimmed && trimmed !== initial) {
|
||||||
try {
|
try {
|
||||||
await actions.rename(node.path, trimmed, isDir)
|
await actions.rename(path, trimmed, isDir)
|
||||||
toast('Renamed successfully', 'success')
|
toast('Renamed successfully', 'success')
|
||||||
} catch {
|
} catch {
|
||||||
toast('Failed to rename', 'error')
|
toast('Failed to rename', 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onClearRename()
|
onDone()
|
||||||
setTimeout(() => {
|
}, [actions, initial, isDir, onDone, path, value])
|
||||||
isSubmittingRef.current = false
|
|
||||||
}, 100)
|
|
||||||
}, [actions, baseName, isDir, newName, node.path, onClearRename])
|
|
||||||
|
|
||||||
const cancelRename = useCallback(() => {
|
const cancel = useCallback(() => {
|
||||||
isSubmittingRef.current = true
|
isSubmittingRef.current = true
|
||||||
setNewName(baseName)
|
onDone()
|
||||||
onClearRename()
|
}, [onDone])
|
||||||
setTimeout(() => {
|
|
||||||
isSubmittingRef.current = false
|
return (
|
||||||
}, 100)
|
<Input
|
||||||
}, [baseName, onClearRename])
|
ref={inputRef}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => 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 () => {
|
const handleDelete = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -314,58 +750,9 @@ function KnowledgeRow({
|
||||||
toast('Path copied', 'success')
|
toast('Path copied', 'success')
|
||||||
}, [actions, node.path])
|
}, [actions, node.path])
|
||||||
|
|
||||||
const row = (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onClick(node)}
|
|
||||||
className="group flex w-full items-center border-b border-border/60 px-6 py-1.5 text-left text-sm transition-colors hover:bg-accent"
|
|
||||||
>
|
|
||||||
<div className="flex flex-1 items-center gap-1.5 min-w-0" style={{ paddingLeft }}>
|
|
||||||
<span className="inline-flex w-4 shrink-0 items-center justify-center text-muted-foreground">
|
|
||||||
{isDir ? (
|
|
||||||
<ChevronRight
|
|
||||||
className={cn(
|
|
||||||
'size-3.5 transition-transform',
|
|
||||||
isExpanded && 'rotate-90',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
|
||||||
{renameActive ? (
|
|
||||||
<Input
|
|
||||||
ref={inputRef}
|
|
||||||
value={newName}
|
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault()
|
|
||||||
void handleRenameSubmit()
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
e.preventDefault()
|
|
||||||
cancelRename()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onBlur={() => {
|
|
||||||
if (!isSubmittingRef.current) void handleRenameSubmit()
|
|
||||||
}}
|
|
||||||
className="h-6 text-sm flex-1"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="min-w-0 truncate">{baseName}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="w-32 shrink-0 text-xs text-muted-foreground tabular-nums">
|
|
||||||
{formatModified(node.stat?.mtimeMs)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>{row}</ContextMenuTrigger>
|
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||||
<ContextMenuContent className="w-48" onCloseAutoFocus={(e) => e.preventDefault()}>
|
<ContextMenuContent className="w-48" onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||||
{isDir && (
|
{isDir && (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue