From c5545898261bf0a05a3f483f666b0aa11032ced6 Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Sat, 4 Oct 2025 16:41:33 +0200 Subject: [PATCH 1/7] Add npm run dev alias --- App.tsx | 198 +++++++++++++++++++++++++++++++++- components/FolderOverview.tsx | 198 ++++++++++++++++++++++++++++++++++ package.json | 1 + 3 files changed, 394 insertions(+), 3 deletions(-) create mode 100644 components/FolderOverview.tsx diff --git a/App.tsx b/App.tsx index 8e5587b..28c6e27 100644 --- a/App.tsx +++ b/App.tsx @@ -18,6 +18,7 @@ import InfoView from './components/InfoView'; import UpdateNotification from './components/UpdateNotification'; import CreateFromTemplateModal from './components/CreateFromTemplateModal'; import DocumentHistoryView from './components/PromptHistoryView'; +import FolderOverview, { FolderOverviewMetrics } from './components/FolderOverview'; import { PlusIcon, FolderPlusIcon, TrashIcon, GearIcon, InfoIcon, TerminalIcon, DocumentDuplicateIcon, PencilIcon, CopyIcon, CommandIcon, CodeIcon, FolderDownIcon, FormatIcon, SparklesIcon } from './components/Icons'; import AboutModal from './components/AboutModal'; import Header from './components/Header'; @@ -295,6 +296,151 @@ const MainApp: React.FC = () => { return { documentTree: finalTree, navigableItems: flatList }; }, [itemsWithSearchMetadata, templates, searchTerm, expandedFolderIds]); + const activeFolderMetrics = useMemo(() => { + if (!activeNode || activeNode.type !== 'folder') { + return null; + } + + const parseDate = (value?: string | null): Date | null => { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; + }; + + const computeFromTree = (folderNode: DocumentNode): FolderOverviewMetrics => { + const recordLatest = (() => { + let latest: Date | null = null; + return { + update(value?: string | null) { + const parsed = parseDate(value); + if (parsed && (!latest || parsed > latest)) { + latest = parsed; + } + }, + getValue() { + return latest; + }, + }; + })(); + + recordLatest.update(folderNode.updatedAt); + + const directDocumentCount = folderNode.children.filter(child => child.type === 'document').length; + const directFolderCount = folderNode.children.filter(child => child.type === 'folder').length; + + let totalDocumentCount = 0; + let totalFolderCount = 0; + const stack = [...folderNode.children]; + + while (stack.length > 0) { + const current = stack.pop()!; + recordLatest.update(current.updatedAt); + if (current.type === 'document') { + totalDocumentCount += 1; + } else if (current.type === 'folder') { + totalFolderCount += 1; + stack.push(...current.children); + } + } + + const latestDate = recordLatest.getValue(); + + return { + directDocumentCount, + directFolderCount, + totalDocumentCount, + totalFolderCount, + totalItemCount: totalDocumentCount + totalFolderCount, + lastUpdated: latestDate ? latestDate.toISOString() : null, + }; + }; + + const buildChildMap = () => { + const map = new Map(); + for (const item of items) { + const key = item.parentId; + if (!map.has(key)) { + map.set(key, []); + } + map.get(key)!.push(item); + } + return map; + }; + + const computeFromList = (): FolderOverviewMetrics => { + const childMap = buildChildMap(); + const directChildren = childMap.get(activeNode.id) ?? []; + + const recordLatest = (() => { + let latest: Date | null = null; + return { + update(value?: string | null) { + const parsed = parseDate(value); + if (parsed && (!latest || parsed > latest)) { + latest = parsed; + } + }, + getValue() { + return latest; + }, + }; + })(); + + recordLatest.update(activeNode.updatedAt); + + const directDocumentCount = directChildren.filter(child => child.type === 'document').length; + const directFolderCount = directChildren.filter(child => child.type === 'folder').length; + + let totalDocumentCount = 0; + let totalFolderCount = 0; + const stack = [...directChildren]; + + while (stack.length > 0) { + const current = stack.pop()!; + recordLatest.update(current.updatedAt); + if (current.type === 'document') { + totalDocumentCount += 1; + } else { + totalFolderCount += 1; + const childItems = childMap.get(current.id) ?? []; + stack.push(...childItems); + } + } + + const latestDate = recordLatest.getValue(); + + return { + directDocumentCount, + directFolderCount, + totalDocumentCount, + totalFolderCount, + totalItemCount: totalDocumentCount + totalFolderCount, + lastUpdated: latestDate ? latestDate.toISOString() : null, + }; + }; + + const findNodeInTree = (nodes: DocumentNode[]): DocumentNode | null => { + for (const node of nodes) { + if (node.id === activeNode.id) { + return node; + } + if (node.type === 'folder') { + const match = findNodeInTree(node.children); + if (match) { + return match; + } + } + } + return null; + }; + + const folderNode = findNodeInTree(documentTree); + if (folderNode) { + return computeFromTree(folderNode); + } + return computeFromList(); + }, [activeNode, documentTree, items]); + useEffect(() => { if (window.electronAPI?.getAppVersion) { window.electronAPI.getAppVersion().then(setAppVersion); @@ -390,6 +536,18 @@ const MainApp: React.FC = () => { } }, [addDocumentsFromFiles, setActiveNodeId, setSelectedIds, setLastClickedId, setActiveTemplateId, setDocumentView, setView]); + const handleImportFilesIntoFolder = useCallback((files: FileList, parentId: string) => { + if (!files || files.length === 0) { + return; + } + + const targetFolder = items.find(item => item.id === parentId && item.type === 'folder'); + const folderTitle = targetFolder?.title?.trim() || 'Untitled Folder'; + + addLog('INFO', `User action: Import ${files.length} file(s) into folder "${folderTitle}".`); + void handleDropFiles(files, parentId); + }, [items, addLog, handleDropFiles]); + useEffect(() => { const handleDragEnter = (e: DragEvent) => { if (e.dataTransfer?.types.includes('Files')) { @@ -686,6 +844,20 @@ const MainApp: React.FC = () => { updateItem(id, { title }); }; + const handleStartRenamingNode = useCallback((id: string) => { + const target = items.find(item => item.id === id) ?? null; + if (target) { + const trimmedTitle = target.title?.trim(); + const fallbackTitle = target.type === 'folder' ? 'Untitled Folder' : 'Untitled Document'; + const displayTitle = trimmedTitle && trimmedTitle.length > 0 ? trimmedTitle : fallbackTitle; + addLog('INFO', `User action: Rename ${target.type} "${displayTitle}".`); + ensureNodeVisible({ id: target.id, type: target.type, parentId: target.parentId ?? null }); + } else { + addLog('INFO', 'User action: Rename item.'); + } + setRenamingNodeId(id); + }, [items, addLog, ensureNodeVisible]); + const handleRenameTemplate = (id: string, title: string) => { updateTemplate(id, { title }); }; @@ -971,7 +1143,7 @@ const MainApp: React.FC = () => { { label: 'New from Template...', icon: DocumentDuplicateIcon, action: newFromTemplateAction, shortcut: getCommand('new-from-template')?.shortcutString }, { type: 'separator' }, { label: 'Format', icon: FormatIcon, action: handleFormatDocument, disabled: !isFormattable || currentSelection.size !== 1, shortcut: getCommand('format-document')?.shortcutString }, - { label: 'Rename', icon: PencilIcon, action: () => setRenamingNodeId(nodeId), disabled: currentSelection.size !== 1 }, + { label: 'Rename', icon: PencilIcon, action: () => handleStartRenamingNode(nodeId), disabled: currentSelection.size !== 1 }, { label: 'Duplicate', icon: DocumentDuplicateIcon, action: handleDuplicateSelection, disabled: currentSelection.size === 0, shortcut: getCommand('duplicate-item')?.shortcutString }, { type: 'separator' }, { label: 'Copy Content', icon: CopyIcon, action: () => hasDocuments && handleCopyNodeContent(selectedNodes.find(n => n.type === 'document')!.id), disabled: !hasDocuments}, @@ -992,7 +1164,7 @@ const MainApp: React.FC = () => { position: { x: e.clientX, y: e.clientY }, items: menuItems }); - }, [selectedIds, items, handleNewDocument, handleNewFolder, handleDuplicateSelection, handleDeleteSelection, handleCopyNodeContent, addLog, enrichedCommands, handleOpenNewCodeFileModal, handleFormatDocument]); + }, [selectedIds, items, handleNewDocument, handleNewFolder, handleDuplicateSelection, handleDeleteSelection, handleCopyNodeContent, addLog, enrichedCommands, handleOpenNewCodeFileModal, handleFormatDocument, handleStartRenamingNode]); const handleSidebarMouseDown = useCallback((e: React.MouseEvent) => { @@ -1128,7 +1300,27 @@ const MainApp: React.FC = () => { /> ); } - return handleNewDocument()} />; + if (activeNode.type === 'folder') { + const fallbackMetrics: FolderOverviewMetrics = { + directDocumentCount: 0, + directFolderCount: 0, + totalDocumentCount: 0, + totalFolderCount: 0, + totalItemCount: 0, + lastUpdated: activeNode.updatedAt, + }; + return ( + handleNewDocument(parentId)} + onNewSubfolder={(parentId) => handleNewFolder(parentId)} + onImportFiles={handleImportFilesIntoFolder} + onRenameFolder={handleStartRenamingNode} + /> + ); + } } return handleNewDocument()} />; }; diff --git a/components/FolderOverview.tsx b/components/FolderOverview.tsx new file mode 100644 index 0000000..a8c3c11 --- /dev/null +++ b/components/FolderOverview.tsx @@ -0,0 +1,198 @@ +import React, { useRef } from 'react'; +import type { DocumentOrFolder } from '../types'; +import Button from './Button'; +import { FolderIcon, FileIcon, InfoIcon, PlusIcon, FolderPlusIcon, FolderDownIcon, PencilIcon } from './Icons'; + +export interface FolderOverviewMetrics { + directDocumentCount: number; + directFolderCount: number; + totalDocumentCount: number; + totalFolderCount: number; + totalItemCount: number; + lastUpdated: string | null; +} + +interface FolderOverviewProps { + folder: DocumentOrFolder; + metrics: FolderOverviewMetrics; + onNewDocument: (parentId: string) => void; + onNewSubfolder: (parentId: string) => void; + onImportFiles: (files: FileList, parentId: string) => void; + onRenameFolder: (folderId: string) => void; +} + +const formatDateTime = (value: string | null) => { + if (!value) { + return 'Unknown'; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return 'Unknown'; + } + return date.toLocaleString(); +}; + +const StatCard: React.FC<{ label: string; value: number; icon: React.ReactNode }> = ({ label, value, icon }) => ( +
+
+ {icon} +
+
+
{value}
+
{label}
+
+
+); + +const FolderOverview: React.FC = ({ + folder, + metrics, + onNewDocument, + onNewSubfolder, + onImportFiles, + onRenameFolder, +}) => { + const { + directDocumentCount, + directFolderCount, + totalDocumentCount, + totalFolderCount, + totalItemCount, + lastUpdated, + } = metrics; + + const hasChildren = totalItemCount > 0; + const fileInputRef = useRef(null); + + const handleImportClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange: React.ChangeEventHandler = (event) => { + const files = event.target.files; + if (files && files.length > 0) { + onImportFiles(files, folder.id); + event.target.value = ''; + } + }; + + return ( +
+
+
+
+
+
+ +
+
+

+ {folder.title.trim() || 'Untitled Folder'} +

+

+ Last updated {formatDateTime(lastUpdated ?? folder.updatedAt)} +

+
+
+
+ + + + + +
+
+ +
+ } + /> + } + /> + } + /> + } + /> +
+ +
+
+

Total items

+

{totalItemCount}

+

+ Counting documents ({totalDocumentCount}) and folders ({totalFolderCount}) nested within this folder. +

+
+
+

Activity

+

+ Most recent change {formatDateTime(lastUpdated ?? folder.updatedAt)} +

+

+ Includes updates to all documents and subfolders contained here. +

+
+
+ + {!hasChildren && ( +
+ + This folder is empty. Create a document or add subfolders to start building content. +
+ )} +
+
+
+ ); +}; + +export default FolderOverview; diff --git a/package.json b/package.json index cbc6866..1071075 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "start": "npm run build:css && node esbuild.config.js --watch & electron .", "build": "npm run build:css && node esbuild.config.js && node scripts/prepare-icons.mjs", + "dev": "npm run start", "package": "npm run build && electron-builder", "publish": "npm run build && electron-builder --publish always", "prepare:icons": "node scripts/prepare-icons.mjs", From d7d2e8b7d8f9f5ebe727ec6940f4e34cf67fd519 Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Sat, 4 Oct 2025 16:51:49 +0200 Subject: [PATCH 2/7] Show recent folder document activity --- App.tsx | 65 ++++++++++++++++++++++++++++++----- components/FolderOverview.tsx | 53 ++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 8 deletions(-) diff --git a/App.tsx b/App.tsx index 28c6e27..f71f217 100644 --- a/App.tsx +++ b/App.tsx @@ -307,6 +307,14 @@ const MainApp: React.FC = () => { return Number.isNaN(date.getTime()) ? null : date; }; + const formatNodeTitle = (node: DocumentOrFolder) => { + const trimmed = node.title.trim(); + if (trimmed) { + return trimmed; + } + return node.type === 'folder' ? 'Untitled Folder' : 'Untitled Document'; + }; + const computeFromTree = (folderNode: DocumentNode): FolderOverviewMetrics => { const recordLatest = (() => { let latest: Date | null = null; @@ -325,25 +333,45 @@ const MainApp: React.FC = () => { recordLatest.update(folderNode.updatedAt); - const directDocumentCount = folderNode.children.filter(child => child.type === 'document').length; - const directFolderCount = folderNode.children.filter(child => child.type === 'folder').length; + const folderChildren = folderNode.children ?? []; + const directDocumentCount = folderChildren.filter(child => child.type === 'document').length; + const directFolderCount = folderChildren.filter(child => child.type === 'folder').length; let totalDocumentCount = 0; let totalFolderCount = 0; - const stack = [...folderNode.children]; + const stack: { node: DocumentNode; parentPath: string[] }[] = folderChildren.map(child => ({ + node: child, + parentPath: [], + })); + const recentDocuments: FolderOverviewMetrics['recentDocuments'] = []; while (stack.length > 0) { - const current = stack.pop()!; + const { node: current, parentPath } = stack.pop()!; recordLatest.update(current.updatedAt); if (current.type === 'document') { totalDocumentCount += 1; + recentDocuments.push({ + id: current.id, + title: current.title, + updatedAt: current.updatedAt, + parentPath, + }); } else if (current.type === 'folder') { totalFolderCount += 1; - stack.push(...current.children); + const nextPath = [...parentPath, formatNodeTitle(current)]; + const childNodes = current.children ?? []; + stack.push(...childNodes.map(child => ({ node: child, parentPath: nextPath }))); } } const latestDate = recordLatest.getValue(); + const sortedRecent = recentDocuments + .sort((a, b) => { + const aDate = parseDate(a.updatedAt)?.getTime() ?? 0; + const bDate = parseDate(b.updatedAt)?.getTime() ?? 0; + return bDate - aDate; + }) + .slice(0, 5); return { directDocumentCount, @@ -352,6 +380,7 @@ const MainApp: React.FC = () => { totalFolderCount, totalItemCount: totalDocumentCount + totalFolderCount, lastUpdated: latestDate ? latestDate.toISOString() : null, + recentDocuments: sortedRecent, }; }; @@ -393,21 +422,39 @@ const MainApp: React.FC = () => { let totalDocumentCount = 0; let totalFolderCount = 0; - const stack = [...directChildren]; + const stack: { node: DocumentOrFolder; parentPath: string[] }[] = directChildren.map(child => ({ + node: child, + parentPath: [], + })); + const recentDocuments: FolderOverviewMetrics['recentDocuments'] = []; while (stack.length > 0) { - const current = stack.pop()!; + const { node: current, parentPath } = stack.pop()!; recordLatest.update(current.updatedAt); if (current.type === 'document') { totalDocumentCount += 1; + recentDocuments.push({ + id: current.id, + title: current.title, + updatedAt: current.updatedAt, + parentPath, + }); } else { totalFolderCount += 1; const childItems = childMap.get(current.id) ?? []; - stack.push(...childItems); + const nextPath = [...parentPath, formatNodeTitle(current)]; + stack.push(...childItems.map(item => ({ node: item, parentPath: nextPath }))); } } const latestDate = recordLatest.getValue(); + const sortedRecent = recentDocuments + .sort((a, b) => { + const aDate = parseDate(a.updatedAt)?.getTime() ?? 0; + const bDate = parseDate(b.updatedAt)?.getTime() ?? 0; + return bDate - aDate; + }) + .slice(0, 5); return { directDocumentCount, @@ -416,6 +463,7 @@ const MainApp: React.FC = () => { totalFolderCount, totalItemCount: totalDocumentCount + totalFolderCount, lastUpdated: latestDate ? latestDate.toISOString() : null, + recentDocuments: sortedRecent, }; }; @@ -1308,6 +1356,7 @@ const MainApp: React.FC = () => { totalFolderCount: 0, totalItemCount: 0, lastUpdated: activeNode.updatedAt, + recentDocuments: [], }; return ( = ({ totalFolderCount, totalItemCount, lastUpdated, + recentDocuments, } = metrics; const hasChildren = totalItemCount > 0; @@ -183,6 +192,50 @@ const FolderOverview: React.FC = ({ +
+

+ Recently updated in this folder +

+ {recentDocuments.length > 0 ? ( +
    + {recentDocuments.map((doc) => { + const formattedTitle = doc.title.trim() || 'Untitled document'; + const formattedDate = formatDateTime(doc.updatedAt); + const hasPath = doc.parentPath.length > 0; + const isUnknownDate = formattedDate === 'Unknown'; + return ( +
  • +
    +
    + +
    +
    +

    {formattedTitle}

    + {hasPath && ( +

    + {doc.parentPath.join(' / ')} +

    + )} +
    +
    +
    + Updated {isUnknownDate ? 'recently' : formattedDate} +
    +
  • + ); + })} +
+ ) : ( +
+ + No recent document activity yet. Updates will appear here as you work. +
+ )} +
+ {!hasChildren && (
From 8aa048d93069511cb23d06f67ab034d5127fe347 Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Sat, 4 Oct 2025 17:02:52 +0200 Subject: [PATCH 3/7] Add folder scoped search to overview --- App.tsx | 192 +++++++++++++++++++++++++++++----- components/FolderOverview.tsx | 158 +++++++++++++++++++++++++++- 2 files changed, 323 insertions(+), 27 deletions(-) diff --git a/App.tsx b/App.tsx index f71f217..f25af90 100644 --- a/App.tsx +++ b/App.tsx @@ -18,7 +18,7 @@ import InfoView from './components/InfoView'; import UpdateNotification from './components/UpdateNotification'; import CreateFromTemplateModal from './components/CreateFromTemplateModal'; import DocumentHistoryView from './components/PromptHistoryView'; -import FolderOverview, { FolderOverviewMetrics } from './components/FolderOverview'; +import FolderOverview, { FolderOverviewMetrics, FolderSearchResult, RecentDocumentSummary } from './components/FolderOverview'; import { PlusIcon, FolderPlusIcon, TrashIcon, GearIcon, InfoIcon, TerminalIcon, DocumentDuplicateIcon, PencilIcon, CopyIcon, CommandIcon, CodeIcon, FolderDownIcon, FormatIcon, SparklesIcon } from './components/Icons'; import AboutModal from './components/AboutModal'; import Header from './components/Header'; @@ -122,6 +122,9 @@ const MainApp: React.FC = () => { const [isDraggingFile, setIsDraggingFile] = useState(false); const [formatTrigger, setFormatTrigger] = useState(0); const [bodySearchMatches, setBodySearchMatches] = useState>(new Map()); + const [folderSearchTerm, setFolderSearchTerm] = useState(''); + const [folderBodySearchMatches, setFolderBodySearchMatches] = useState>(new Map()); + const [isFolderSearchLoading, setIsFolderSearchLoading] = useState(false); const isSidebarResizing = useRef(false); @@ -189,6 +192,12 @@ const MainApp: React.FC = () => { return itemsWithSearchMetadata.find(p => p.id === activeNodeId) || null; }, [itemsWithSearchMetadata, activeNodeId]); + useEffect(() => { + setFolderSearchTerm(''); + setFolderBodySearchMatches(new Map()); + setIsFolderSearchLoading(false); + }, [activeNode?.id, activeNode?.type]); + const activeTemplate = useMemo(() => { return templates.find(t => t.template_id === activeTemplateId) || null; }, [templates, activeTemplateId]); @@ -225,6 +234,53 @@ const MainApp: React.FC = () => { }; }, [searchTerm]); + useEffect(() => { + if (!activeNode || activeNode.type !== 'folder') { + return; + } + + const term = folderSearchTerm.trim(); + if (!term) { + setFolderBodySearchMatches(new Map()); + setIsFolderSearchLoading(false); + return; + } + + let isCancelled = false; + setIsFolderSearchLoading(true); + setFolderBodySearchMatches(new Map()); + + repository.searchDocumentsByBody(term, 200) + .then(results => { + if (isCancelled) { + return; + } + const descendantIds = getDescendantIds(activeNode.id); + const matches = new Map(); + for (const result of results) { + if (descendantIds.has(result.nodeId)) { + matches.set(result.nodeId, result.snippet); + } + } + setFolderBodySearchMatches(matches); + }) + .catch(error => { + if (!isCancelled) { + console.error('Failed to search within folder:', error); + setFolderBodySearchMatches(new Map()); + } + }) + .finally(() => { + if (!isCancelled) { + setIsFolderSearchLoading(false); + } + }); + + return () => { + isCancelled = true; + }; + }, [activeNode, folderSearchTerm, getDescendantIds]); + const { documentTree, navigableItems } = useMemo(() => { let itemsToBuildFrom = itemsWithSearchMetadata; if (searchTerm.trim()) { @@ -296,9 +352,9 @@ const MainApp: React.FC = () => { return { documentTree: finalTree, navigableItems: flatList }; }, [itemsWithSearchMetadata, templates, searchTerm, expandedFolderIds]); - const activeFolderMetrics = useMemo(() => { + const { metrics: activeFolderMetrics, documents: activeFolderDocuments } = useMemo(() => { if (!activeNode || activeNode.type !== 'folder') { - return null; + return { metrics: null as FolderOverviewMetrics | null, documents: [] as RecentDocumentSummary[] }; } const parseDate = (value?: string | null): Date | null => { @@ -307,7 +363,7 @@ const MainApp: React.FC = () => { return Number.isNaN(date.getTime()) ? null : date; }; - const formatNodeTitle = (node: DocumentOrFolder) => { + const formatNodeTitle = (node: { title: string; type: 'document' | 'folder' }) => { const trimmed = node.title.trim(); if (trimmed) { return trimmed; @@ -315,7 +371,7 @@ const MainApp: React.FC = () => { return node.type === 'folder' ? 'Untitled Folder' : 'Untitled Document'; }; - const computeFromTree = (folderNode: DocumentNode): FolderOverviewMetrics => { + const computeFromTree = (folderNode: DocumentNode) => { const recordLatest = (() => { let latest: Date | null = null; return { @@ -343,14 +399,14 @@ const MainApp: React.FC = () => { node: child, parentPath: [], })); - const recentDocuments: FolderOverviewMetrics['recentDocuments'] = []; + const allDocuments: RecentDocumentSummary[] = []; while (stack.length > 0) { const { node: current, parentPath } = stack.pop()!; recordLatest.update(current.updatedAt); if (current.type === 'document') { totalDocumentCount += 1; - recentDocuments.push({ + allDocuments.push({ id: current.id, title: current.title, updatedAt: current.updatedAt, @@ -365,7 +421,7 @@ const MainApp: React.FC = () => { } const latestDate = recordLatest.getValue(); - const sortedRecent = recentDocuments + const recentDocuments = [...allDocuments] .sort((a, b) => { const aDate = parseDate(a.updatedAt)?.getTime() ?? 0; const bDate = parseDate(b.updatedAt)?.getTime() ?? 0; @@ -374,13 +430,16 @@ const MainApp: React.FC = () => { .slice(0, 5); return { - directDocumentCount, - directFolderCount, - totalDocumentCount, - totalFolderCount, - totalItemCount: totalDocumentCount + totalFolderCount, - lastUpdated: latestDate ? latestDate.toISOString() : null, - recentDocuments: sortedRecent, + metrics: { + directDocumentCount, + directFolderCount, + totalDocumentCount, + totalFolderCount, + totalItemCount: totalDocumentCount + totalFolderCount, + lastUpdated: latestDate ? latestDate.toISOString() : null, + recentDocuments, + }, + documents: allDocuments, }; }; @@ -396,7 +455,7 @@ const MainApp: React.FC = () => { return map; }; - const computeFromList = (): FolderOverviewMetrics => { + const computeFromList = () => { const childMap = buildChildMap(); const directChildren = childMap.get(activeNode.id) ?? []; @@ -426,14 +485,14 @@ const MainApp: React.FC = () => { node: child, parentPath: [], })); - const recentDocuments: FolderOverviewMetrics['recentDocuments'] = []; + const allDocuments: RecentDocumentSummary[] = []; while (stack.length > 0) { const { node: current, parentPath } = stack.pop()!; recordLatest.update(current.updatedAt); if (current.type === 'document') { totalDocumentCount += 1; - recentDocuments.push({ + allDocuments.push({ id: current.id, title: current.title, updatedAt: current.updatedAt, @@ -448,7 +507,7 @@ const MainApp: React.FC = () => { } const latestDate = recordLatest.getValue(); - const sortedRecent = recentDocuments + const recentDocuments = [...allDocuments] .sort((a, b) => { const aDate = parseDate(a.updatedAt)?.getTime() ?? 0; const bDate = parseDate(b.updatedAt)?.getTime() ?? 0; @@ -457,13 +516,16 @@ const MainApp: React.FC = () => { .slice(0, 5); return { - directDocumentCount, - directFolderCount, - totalDocumentCount, - totalFolderCount, - totalItemCount: totalDocumentCount + totalFolderCount, - lastUpdated: latestDate ? latestDate.toISOString() : null, - recentDocuments: sortedRecent, + metrics: { + directDocumentCount, + directFolderCount, + totalDocumentCount, + totalFolderCount, + totalItemCount: totalDocumentCount + totalFolderCount, + lastUpdated: latestDate ? latestDate.toISOString() : null, + recentDocuments, + }, + documents: allDocuments, }; }; @@ -489,6 +551,80 @@ const MainApp: React.FC = () => { return computeFromList(); }, [activeNode, documentTree, items]); + const folderSearchResults = useMemo(() => { + if (!activeNode || activeNode.type !== 'folder') { + return []; + } + + const trimmed = folderSearchTerm.trim(); + if (!trimmed) { + return []; + } + + const lowerTerm = trimmed.toLowerCase(); + + const parseToTimestamp = (value?: string | null) => { + if (!value) { + return 0; + } + const date = new Date(value); + return Number.isNaN(date.getTime()) ? 0 : date.getTime(); + }; + + const computeMatchScore = (fields: ('title' | 'body')[]) => { + if (fields.length === 2) { + return 0; + } + return fields[0] === 'title' ? 1 : 2; + }; + + type FolderSearchResultWithScore = FolderSearchResult & { matchScore: number; sortTimestamp: number; }; + + const results: FolderSearchResultWithScore[] = []; + + for (const document of activeFolderDocuments) { + const titleLower = document.title.toLowerCase(); + const hasTitleMatch = titleLower.includes(lowerTerm); + const snippet = folderBodySearchMatches.get(document.id); + const hasBodyMatch = Boolean(snippet); + + if (!hasTitleMatch && !hasBodyMatch) { + continue; + } + + const matchedFields: ('title' | 'body')[] = []; + if (hasTitleMatch) { + matchedFields.push('title'); + } + if (hasBodyMatch) { + matchedFields.push('body'); + } + + results.push({ + id: document.id, + title: document.title, + updatedAt: document.updatedAt, + parentPath: document.parentPath, + searchSnippet: snippet, + matchedFields, + matchScore: computeMatchScore(matchedFields), + sortTimestamp: parseToTimestamp(document.updatedAt), + }); + } + + return results + .sort((a, b) => { + if (a.matchScore !== b.matchScore) { + return a.matchScore - b.matchScore; + } + if (a.sortTimestamp !== b.sortTimestamp) { + return b.sortTimestamp - a.sortTimestamp; + } + return a.title.localeCompare(b.title); + }) + .map(({ matchScore: _matchScore, sortTimestamp: _sortTimestamp, ...rest }) => rest); + }, [activeNode, activeFolderDocuments, folderBodySearchMatches, folderSearchTerm]); + useEffect(() => { if (window.electronAPI?.getAppVersion) { window.electronAPI.getAppVersion().then(setAppVersion); @@ -1367,6 +1503,10 @@ const MainApp: React.FC = () => { onNewSubfolder={(parentId) => handleNewFolder(parentId)} onImportFiles={handleImportFilesIntoFolder} onRenameFolder={handleStartRenamingNode} + folderSearchTerm={folderSearchTerm} + onFolderSearchTermChange={setFolderSearchTerm} + searchResults={folderSearchResults} + isSearchLoading={isFolderSearchLoading} /> ); } diff --git a/components/FolderOverview.tsx b/components/FolderOverview.tsx index ffc6ccc..9855b7a 100644 --- a/components/FolderOverview.tsx +++ b/components/FolderOverview.tsx @@ -1,7 +1,7 @@ import React, { useRef } from 'react'; import type { DocumentOrFolder } from '../types'; import Button from './Button'; -import { FolderIcon, FileIcon, InfoIcon, PlusIcon, FolderPlusIcon, FolderDownIcon, PencilIcon } from './Icons'; +import { FolderIcon, FileIcon, InfoIcon, PlusIcon, FolderPlusIcon, FolderDownIcon, PencilIcon, SearchIcon, XIcon } from './Icons'; export interface FolderOverviewMetrics { directDocumentCount: number; @@ -20,6 +20,11 @@ export interface RecentDocumentSummary { parentPath: string[]; } +export interface FolderSearchResult extends RecentDocumentSummary { + searchSnippet?: string | null; + matchedFields: ('title' | 'body')[]; +} + interface FolderOverviewProps { folder: DocumentOrFolder; metrics: FolderOverviewMetrics; @@ -27,6 +32,10 @@ interface FolderOverviewProps { onNewSubfolder: (parentId: string) => void; onImportFiles: (files: FileList, parentId: string) => void; onRenameFolder: (folderId: string) => void; + folderSearchTerm: string; + onFolderSearchTermChange: (value: string) => void; + searchResults: FolderSearchResult[]; + isSearchLoading: boolean; } const formatDateTime = (value: string | null) => { @@ -40,6 +49,27 @@ const formatDateTime = (value: string | null) => { return date.toLocaleString(); }; +const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const highlightMatches = (text: string, term: string): React.ReactNode => { + if (!term.trim()) { + return text; + } + const escaped = escapeRegExp(term.trim()); + const regex = new RegExp(`(${escaped})`, 'ig'); + const parts = text.split(regex); + return parts.map((part, index) => { + if (index % 2 === 1) { + return ( + + {part} + + ); + } + return {part}; + }); +}; + const StatCard: React.FC<{ label: string; value: number; icon: React.ReactNode }> = ({ label, value, icon }) => (
@@ -59,6 +89,10 @@ const FolderOverview: React.FC = ({ onNewSubfolder, onImportFiles, onRenameFolder, + folderSearchTerm, + onFolderSearchTermChange, + searchResults, + isSearchLoading, }) => { const { directDocumentCount, @@ -85,6 +119,16 @@ const FolderOverview: React.FC = ({ } }; + const handleSearchChange: React.ChangeEventHandler = (event) => { + onFolderSearchTermChange(event.target.value); + }; + + const handleClearSearch = () => { + onFolderSearchTermChange(''); + }; + + const hasSearchTerm = folderSearchTerm.trim().length > 0; + return (
@@ -150,6 +194,118 @@ const FolderOverview: React.FC = ({
+
+ +
+ + + {hasSearchTerm && ( + + )} +
+ + {hasSearchTerm && ( +
+
+

Search results

+ + {isSearchLoading + ? 'Searching…' + : searchResults.length === 1 + ? '1 match' + : `${searchResults.length} matches`} + +
+ + {searchResults.length > 0 ? ( +
    + {searchResults.map((result) => { + const formattedTitle = result.title.trim() || 'Untitled document'; + const formattedDate = formatDateTime(result.updatedAt); + const hasPath = result.parentPath.length > 0; + const isUnknownDate = formattedDate === 'Unknown'; + return ( +
  • +
    +
    +
    + +
    +
    +

    + {highlightMatches(formattedTitle, folderSearchTerm)} +

    + {hasPath && ( +

    + {result.parentPath.join(' / ')} +

    + )} + {result.searchSnippet && ( +

    + {highlightMatches(result.searchSnippet, folderSearchTerm)} +

    + )} +
    +
    +
    + + Updated {isUnknownDate ? 'recently' : formattedDate} + + {result.matchedFields.length > 0 && ( +
    + {result.matchedFields.map((field) => ( + + {field === 'title' ? 'Title match' : 'Body match'} + + ))} +
    + )} +
    +
    +
  • + ); + })} +
+ ) : ( +
+ + + {isSearchLoading + ? 'Searching folder contents…' + : ( + <> + No matches found for “{folderSearchTerm}”. + + )} + +
+ )} +
+ )} +
+
Date: Sat, 4 Oct 2025 17:12:12 +0200 Subject: [PATCH 4/7] Summarize folder document makeup --- App.tsx | 73 +++++++++++++++++++++++++++++++++- components/FolderOverview.tsx | 75 ++++++++++++++++++++++++++++++++++- 2 files changed, 145 insertions(+), 3 deletions(-) diff --git a/App.tsx b/App.tsx index f25af90..d1b605c 100644 --- a/App.tsx +++ b/App.tsx @@ -18,7 +18,7 @@ import InfoView from './components/InfoView'; import UpdateNotification from './components/UpdateNotification'; import CreateFromTemplateModal from './components/CreateFromTemplateModal'; import DocumentHistoryView from './components/PromptHistoryView'; -import FolderOverview, { FolderOverviewMetrics, FolderSearchResult, RecentDocumentSummary } from './components/FolderOverview'; +import FolderOverview, { type FolderOverviewMetrics, type FolderSearchResult, type RecentDocumentSummary, type DocTypeCount, type LanguageCount } from './components/FolderOverview'; import { PlusIcon, FolderPlusIcon, TrashIcon, GearIcon, InfoIcon, TerminalIcon, DocumentDuplicateIcon, PencilIcon, CopyIcon, CommandIcon, CodeIcon, FolderDownIcon, FormatIcon, SparklesIcon } from './components/Icons'; import AboutModal from './components/AboutModal'; import Header from './components/Header'; @@ -27,7 +27,7 @@ import ConfirmModal from './components/ConfirmModal'; import FatalError from './components/FatalError'; import ContextMenu, { MenuItem } from './components/ContextMenu'; import NewCodeFileModal from './components/NewCodeFileModal'; -import type { DocumentOrFolder, Command, LogMessage, DiscoveredLLMModel, DiscoveredLLMService, Settings, DocumentTemplate, ViewMode } from './types'; +import type { DocumentOrFolder, Command, LogMessage, DiscoveredLLMModel, DiscoveredLLMService, Settings, DocumentTemplate, ViewMode, DocType } from './types'; import { IconProvider } from './contexts/IconContext'; import { storageService } from './services/storageService'; import { llmDiscoveryService } from './services/llmDiscoveryService'; @@ -371,6 +371,55 @@ const MainApp: React.FC = () => { return node.type === 'folder' ? 'Untitled Folder' : 'Untitled Document'; }; + const toTitleCase = (value: string) => { + return value + .split(/[-_\s]+/) + .filter(Boolean) + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); + }; + + const addDocTypeCount = (map: Map, docType: DocType) => { + map.set(docType, (map.get(docType) ?? 0) + 1); + }; + + const addLanguageCount = ( + map: Map, + value?: string | null, + ) => { + const trimmed = (value ?? '').trim(); + const key = trimmed ? trimmed.toLowerCase() : 'unknown'; + const label = trimmed ? toTitleCase(trimmed) : 'Unknown'; + const existing = map.get(key); + if (existing) { + existing.count += 1; + } else { + map.set(key, { label, count: 1 }); + } + }; + + const finalizeDocTypeCounts = (map: Map): DocTypeCount[] => + Array.from(map.entries()) + .map(([type, count]) => ({ type, count })) + .sort((a, b) => { + if (a.count !== b.count) { + return b.count - a.count; + } + return a.type.localeCompare(b.type); + }); + + const finalizeLanguageCounts = ( + map: Map, + ): LanguageCount[] => + Array.from(map.values()) + .map(({ label, count }) => ({ label, count })) + .sort((a, b) => { + if (a.count !== b.count) { + return b.count - a.count; + } + return a.label.localeCompare(b.label); + }); + const computeFromTree = (folderNode: DocumentNode) => { const recordLatest = (() => { let latest: Date | null = null; @@ -400,17 +449,24 @@ const MainApp: React.FC = () => { parentPath: [], })); const allDocuments: RecentDocumentSummary[] = []; + const docTypeMap = new Map(); + const languageMap = new Map(); while (stack.length > 0) { const { node: current, parentPath } = stack.pop()!; recordLatest.update(current.updatedAt); if (current.type === 'document') { totalDocumentCount += 1; + const docType = (current.doc_type ?? 'prompt') as DocType; + addDocTypeCount(docTypeMap, docType); + addLanguageCount(languageMap, current.language_hint); allDocuments.push({ id: current.id, title: current.title, updatedAt: current.updatedAt, parentPath, + docType, + languageHint: current.language_hint ?? null, }); } else if (current.type === 'folder') { totalFolderCount += 1; @@ -438,6 +494,8 @@ const MainApp: React.FC = () => { totalItemCount: totalDocumentCount + totalFolderCount, lastUpdated: latestDate ? latestDate.toISOString() : null, recentDocuments, + docTypeCounts: finalizeDocTypeCounts(docTypeMap), + languageCounts: finalizeLanguageCounts(languageMap), }, documents: allDocuments, }; @@ -486,17 +544,24 @@ const MainApp: React.FC = () => { parentPath: [], })); const allDocuments: RecentDocumentSummary[] = []; + const docTypeMap = new Map(); + const languageMap = new Map(); while (stack.length > 0) { const { node: current, parentPath } = stack.pop()!; recordLatest.update(current.updatedAt); if (current.type === 'document') { totalDocumentCount += 1; + const docType = (current.doc_type ?? 'prompt') as DocType; + addDocTypeCount(docTypeMap, docType); + addLanguageCount(languageMap, current.language_hint); allDocuments.push({ id: current.id, title: current.title, updatedAt: current.updatedAt, parentPath, + docType, + languageHint: current.language_hint ?? null, }); } else { totalFolderCount += 1; @@ -524,6 +589,8 @@ const MainApp: React.FC = () => { totalItemCount: totalDocumentCount + totalFolderCount, lastUpdated: latestDate ? latestDate.toISOString() : null, recentDocuments, + docTypeCounts: finalizeDocTypeCounts(docTypeMap), + languageCounts: finalizeLanguageCounts(languageMap), }, documents: allDocuments, }; @@ -1493,6 +1560,8 @@ const MainApp: React.FC = () => { totalItemCount: 0, lastUpdated: activeNode.updatedAt, recentDocuments: [], + docTypeCounts: [], + languageCounts: [], }; return ( { }); }; +const DOC_TYPE_LABELS: Record = { + prompt: 'Prompts', + source_code: 'Source code', + pdf: 'PDFs', + image: 'Images', +}; + +const formatDocTypeLabel = (docType: DocType) => DOC_TYPE_LABELS[docType] ?? docType.replace(/_/g, ' '); + const StatCard: React.FC<{ label: string; value: number; icon: React.ReactNode }> = ({ label, value, icon }) => (
@@ -102,9 +125,13 @@ const FolderOverview: React.FC = ({ totalItemCount, lastUpdated, recentDocuments, + docTypeCounts, + languageCounts, } = metrics; const hasChildren = totalItemCount > 0; + const hasDocTypeSummary = docTypeCounts.some(({ count }) => count > 0); + const hasLanguageSummary = languageCounts.some(({ count }) => count > 0); const fileInputRef = useRef(null); const handleImportClick = () => { @@ -348,6 +375,52 @@ const FolderOverview: React.FC = ({
+
+

Contents at a glance

+
+
+

Document types

+ {hasDocTypeSummary ? ( +
+ {docTypeCounts.map(({ type, count }) => ( + + {formatDocTypeLabel(type)} + + {count} + + + ))} +
+ ) : ( +

No documents yet.

+ )} +
+
+

Languages

+ {hasLanguageSummary ? ( +
+ {languageCounts.map(({ label, count }) => ( + + {label} + + {count} + + + ))} +
+ ) : ( +

No language information yet.

+ )} +
+
+
+

Recently updated in this folder From a7586fdea7e7088ace0d856600a507d63b0c5c6b Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Sat, 4 Oct 2025 17:35:32 +0200 Subject: [PATCH 5/7] Refine folder overview layout --- components/FolderOverview.tsx | 298 +++++++++++++++------------------- 1 file changed, 128 insertions(+), 170 deletions(-) diff --git a/components/FolderOverview.tsx b/components/FolderOverview.tsx index f879f89..9c28d05 100644 --- a/components/FolderOverview.tsx +++ b/components/FolderOverview.tsx @@ -93,18 +93,6 @@ const DOC_TYPE_LABELS: Record = { const formatDocTypeLabel = (docType: DocType) => DOC_TYPE_LABELS[docType] ?? docType.replace(/_/g, ' '); -const StatCard: React.FC<{ label: string; value: number; icon: React.ReactNode }> = ({ label, value, icon }) => ( -
-
- {icon} -
-
-
{value}
-
{label}
-
-
-); - const FolderOverview: React.FC = ({ folder, metrics, @@ -157,27 +145,26 @@ const FolderOverview: React.FC = ({ const hasSearchTerm = folderSearchTerm.trim().length > 0; return ( -
-
-
-
-
-
- -
-
-

- {folder.title.trim() || 'Untitled Folder'} -

-

- Last updated {formatDateTime(lastUpdated ?? folder.updatedAt)} -

+
+
+
+
+
+
+ + Folder overview
+

+ {folder.title.trim() || 'Untitled Folder'} +

+

+ Updated {formatDateTime(lastUpdated ?? folder.updatedAt)} +

= ({ aria-label="Import files into this folder" />
-
+ -
-