Skip to content

Commit b0ff60c

Browse files
committed
Add body text search with snippets in sidebar
1 parent f3cab99 commit b0ff60c

4 files changed

Lines changed: 202 additions & 25 deletions

File tree

App.tsx

Lines changed: 70 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ const MainApp: React.FC = () => {
120120
const [contextMenu, setContextMenu] = useState<{ isOpen: boolean; position: { x: number, y: number }, items: MenuItem[] }>({ isOpen: false, position: { x: 0, y: 0 }, items: [] });
121121
const [isDraggingFile, setIsDraggingFile] = useState(false);
122122
const [formatTrigger, setFormatTrigger] = useState(0);
123+
const [bodySearchMatches, setBodySearchMatches] = useState<Map<string, string>>(new Map());
123124

124125

125126
const isSidebarResizing = useRef(false);
@@ -171,9 +172,20 @@ const MainApp: React.FC = () => {
171172
}, [theme, settings.markdownCodeBlockBackgroundLight, settings.markdownCodeBlockBackgroundDark, settingsLoaded]);
172173

173174

175+
const itemsWithSearchMetadata = useMemo(() => {
176+
const trimmed = searchTerm.trim();
177+
if (!trimmed || bodySearchMatches.size === 0) {
178+
return items;
179+
}
180+
return items.map(item => {
181+
const snippet = bodySearchMatches.get(item.id);
182+
return snippet ? { ...item, searchSnippet: snippet } : item;
183+
});
184+
}, [items, bodySearchMatches, searchTerm]);
185+
174186
const activeNode = useMemo(() => {
175-
return items.find(p => p.id === activeNodeId) || null;
176-
}, [items, activeNodeId]);
187+
return itemsWithSearchMetadata.find(p => p.id === activeNodeId) || null;
188+
}, [itemsWithSearchMetadata, activeNodeId]);
177189

178190
const activeTemplate = useMemo(() => {
179191
return templates.find(t => t.template_id === activeTemplateId) || null;
@@ -184,35 +196,71 @@ const MainApp: React.FC = () => {
184196
}, [activeNode]);
185197

186198

199+
useEffect(() => {
200+
const term = searchTerm.trim();
201+
if (!term) {
202+
setBodySearchMatches(new Map());
203+
return;
204+
}
205+
206+
let isCancelled = false;
207+
208+
repository.searchDocumentsByBody(term, 200)
209+
.then(results => {
210+
if (!isCancelled) {
211+
setBodySearchMatches(new Map(results.map(result => [result.nodeId, result.snippet])));
212+
}
213+
})
214+
.catch(error => {
215+
if (!isCancelled) {
216+
console.error('Failed to search document bodies:', error);
217+
setBodySearchMatches(new Map());
218+
}
219+
});
220+
221+
return () => {
222+
isCancelled = true;
223+
};
224+
}, [searchTerm]);
225+
187226
const { documentTree, navigableItems } = useMemo(() => {
188-
let itemsToBuildFrom = items;
227+
let itemsToBuildFrom = itemsWithSearchMetadata;
189228
if (searchTerm.trim()) {
190229
const lowerCaseSearchTerm = searchTerm.toLowerCase();
191230
const visibleIds = new Set<string>();
192-
const originalItemsById: Map<string, DocumentOrFolder> = new Map(items.map(i => [i.id, i]));
231+
const originalItemsById: Map<string, DocumentOrFolder> = new Map(itemsWithSearchMetadata.map(i => [i.id, i]));
193232
const getAncestors = (itemId: string) => {
194233
let current = originalItemsById.get(itemId);
195234
while (current && current.parentId) {
196-
visibleIds.add(current.parentId);
197-
current = originalItemsById.get(current.parentId);
235+
visibleIds.add(current.parentId);
236+
current = originalItemsById.get(current.parentId);
198237
}
199238
};
200239
const getDescendantIdsRecursive = (itemId: string): Set<string> => {
201240
const descendantIds = new Set<string>();
202241
const findChildren = (parentId: string) => {
203-
items.forEach(p => { if (p.parentId === parentId) { descendantIds.add(p.id); if (p.type === 'folder') findChildren(p.id); } });
242+
itemsWithSearchMetadata.forEach(p => {
243+
if (p.parentId === parentId) {
244+
descendantIds.add(p.id);
245+
if (p.type === 'folder') findChildren(p.id);
246+
}
247+
});
204248
};
205249
findChildren(itemId);
206250
return descendantIds;
207251
};
208-
items.forEach(item => {
209-
if (item.title.toLowerCase().includes(lowerCaseSearchTerm)) {
252+
itemsWithSearchMetadata.forEach(item => {
253+
const titleMatch = item.title.toLowerCase().includes(lowerCaseSearchTerm);
254+
const bodyMatch = Boolean(item.searchSnippet);
255+
if (titleMatch || bodyMatch) {
210256
visibleIds.add(item.id);
211257
getAncestors(item.id);
212-
if (item.type === 'folder') getDescendantIdsRecursive(item.id).forEach(id => visibleIds.add(id));
258+
if (titleMatch && item.type === 'folder') {
259+
getDescendantIdsRecursive(item.id).forEach(id => visibleIds.add(id));
260+
}
213261
}
214262
});
215-
itemsToBuildFrom = items.filter(item => visibleIds.has(item.id));
263+
itemsToBuildFrom = itemsWithSearchMetadata.filter(item => visibleIds.has(item.id));
216264
}
217265
const itemsById = new Map<string, DocumentNode>(itemsToBuildFrom.map(p => [p.id, { ...p, children: [] }]));
218266
const rootNodes: DocumentNode[] = [];
@@ -224,27 +272,27 @@ const MainApp: React.FC = () => {
224272
rootNodes.push(node);
225273
}
226274
}
227-
275+
228276
const finalTree = rootNodes;
229277

230-
const displayExpandedIds = searchTerm.trim()
231-
? new Set(itemsToBuildFrom.filter(i => i.type === 'folder').map(i => i.id))
278+
const displayExpandedIds = searchTerm.trim()
279+
? new Set(itemsToBuildFrom.filter(i => i.type === 'folder').map(i => i.id))
232280
: expandedFolderIds;
233281

234282
const flatList: NavigableItem[] = [];
235283
const flatten = (nodes: DocumentNode[]) => {
236-
for (const node of nodes) {
237-
flatList.push({ id: node.id, type: node.type, parentId: node.parentId });
238-
if (node.type === 'folder' && displayExpandedIds.has(node.id)) {
239-
flatten(node.children);
284+
for (const node of nodes) {
285+
flatList.push({ id: node.id, type: node.type, parentId: node.parentId });
286+
if (node.type === 'folder' && displayExpandedIds.has(node.id)) {
287+
flatten(node.children);
288+
}
240289
}
241-
}
242290
};
243291
flatten(finalTree);
244292
templates.forEach(t => flatList.push({ id: t.template_id, type: 'template', parentId: null }));
245293

246294
return { documentTree: finalTree, navigableItems: flatList };
247-
}, [items, templates, searchTerm, expandedFolderIds]);
295+
}, [itemsWithSearchMetadata, templates, searchTerm, expandedFolderIds]);
248296

249297
useEffect(() => {
250298
if (window.electronAPI?.getAppVersion) {
@@ -1100,8 +1148,8 @@ const MainApp: React.FC = () => {
11001148
style={{ width: `${sidebarWidth}px` }}
11011149
className="bg-secondary border-r border-border-color flex flex-col flex-shrink-0"
11021150
>
1103-
<Sidebar
1104-
documents={items}
1151+
<Sidebar
1152+
documents={itemsWithSearchMetadata}
11051153
documentTree={documentTree}
11061154
navigableItems={navigableItems}
11071155
selectedIds={selectedIds}

components/PromptTreeItem.tsx

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,27 @@ const getDropPosition = (
5757
}
5858
};
5959

60+
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
61+
62+
const highlightMatches = (text: string, term: string): React.ReactNode => {
63+
if (!term.trim()) {
64+
return text;
65+
}
66+
const escaped = escapeRegExp(term.trim());
67+
const regex = new RegExp(`(${escaped})`, 'ig');
68+
const parts = text.split(regex);
69+
return parts.map((part, index) => {
70+
if (index % 2 === 1) {
71+
return (
72+
<span key={index} className="bg-primary/20 text-text-main rounded-sm px-0.5">
73+
{part}
74+
</span>
75+
);
76+
}
77+
return <React.Fragment key={index}>{part}</React.Fragment>;
78+
});
79+
};
80+
6081

6182
const DocumentTreeItem: React.FC<DocumentTreeItemProps> = (props) => {
6283
const {
@@ -80,7 +101,8 @@ const DocumentTreeItem: React.FC<DocumentTreeItemProps> = (props) => {
80101
renamingNodeId,
81102
onRenameComplete,
82103
indentPerLevel,
83-
verticalSpacing
104+
verticalSpacing,
105+
searchTerm,
84106
} = props;
85107

86108
const [isRenaming, setIsRenaming] = useState(false);
@@ -192,6 +214,7 @@ const DocumentTreeItem: React.FC<DocumentTreeItemProps> = (props) => {
192214
const paddingTopBottom = Math.max(verticalSpacing, 0);
193215
const basePaddingLeft = 4; // matches Tailwind px-1 for consistent baseline spacing
194216
const rowPaddingLeft = basePaddingLeft + Math.max(level, 0) * safeIndent;
217+
const snippetPaddingLeft = rowPaddingLeft + 28;
195218

196219
return (
197220
<li
@@ -240,7 +263,7 @@ const DocumentTreeItem: React.FC<DocumentTreeItemProps> = (props) => {
240263
className="w-full text-left text-xs px-1.5 py-1 rounded-md bg-background text-text-main border border-border-color focus:outline-none focus:ring-1 focus:ring-primary"
241264
/>
242265
) : (
243-
<span className="truncate flex-1 px-1">{node.title}</span>
266+
<span className="truncate flex-1 px-1">{highlightMatches(node.title, searchTerm)}</span>
244267
)}
245268
</div>
246269

@@ -263,7 +286,16 @@ const DocumentTreeItem: React.FC<DocumentTreeItemProps> = (props) => {
263286
</div>
264287
)}
265288
</div>
266-
289+
290+
{!isFolder && searchTerm.trim() && node.searchSnippet && (
291+
<div
292+
className="text-[11px] text-text-secondary leading-snug truncate pr-3"
293+
style={{ paddingLeft: `${snippetPaddingLeft}px` }}
294+
>
295+
{highlightMatches(node.searchSnippet, searchTerm)}
296+
</div>
297+
)}
298+
267299
{dropPosition && <div className={`absolute left-0 right-0 h-0.5 bg-primary pointer-events-none ${
268300
dropPosition === 'before' ? 'top-0' : dropPosition === 'after' ? 'bottom-0' : ''
269301
}`} />}

services/repository.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,43 @@ const mapNodeTree = (nodes: Node[], mapper: (node: Node) => void) => {
196196
}
197197
};
198198

199+
const escapeForLikePattern = (value: string): string => value.replace(/([\\%_])/g, '\\$1');
200+
201+
const createSnippetFromContent = (content: string, term: string, snippetLength: number = 160): string => {
202+
const normalizedContent = content.replace(/\s+/g, ' ').trim();
203+
if (!normalizedContent) {
204+
return '';
205+
}
206+
207+
const lowerContent = normalizedContent.toLowerCase();
208+
const lowerTerm = term.toLowerCase();
209+
const matchIndex = lowerContent.indexOf(lowerTerm);
210+
211+
if (matchIndex === -1) {
212+
if (normalizedContent.length <= snippetLength) {
213+
return normalizedContent;
214+
}
215+
return `${normalizedContent.slice(0, snippetLength).trim()}…`;
216+
}
217+
218+
const halfWindow = Math.max(0, Math.floor((snippetLength - lowerTerm.length) / 2));
219+
let start = Math.max(0, matchIndex - halfWindow);
220+
let end = Math.min(normalizedContent.length, matchIndex + lowerTerm.length + halfWindow);
221+
222+
if (end - start > snippetLength) {
223+
end = start + snippetLength;
224+
}
225+
226+
let snippet = normalizedContent.slice(start, end).trim();
227+
if (start > 0) {
228+
snippet = `…${snippet}`;
229+
}
230+
if (end < normalizedContent.length) {
231+
snippet = `${snippet}…`;
232+
}
233+
return snippet;
234+
};
235+
199236
const duplicateNodeRecursive = (state: BrowserState, node: Node, newParentId: string | null, sortOrder: number): Node => {
200237
const now = new Date().toISOString();
201238
const clonedNode: Node = {
@@ -484,6 +521,65 @@ export const repository = {
484521
return rootNodes;
485522
},
486523

524+
async searchDocumentsByBody(searchTerm: string, limit: number = 50): Promise<{ nodeId: string; snippet: string }[]> {
525+
const term = searchTerm.trim();
526+
if (!term) {
527+
return [];
528+
}
529+
530+
if (limit <= 0) {
531+
return [];
532+
}
533+
534+
if (!isElectron) {
535+
const state = ensureBrowserState();
536+
const lowerTerm = term.toLowerCase();
537+
const results: { nodeId: string; snippet: string }[] = [];
538+
mapNodeTree(state.nodes, node => {
539+
if (node.node_type !== 'document' || !node.document?.content) {
540+
return;
541+
}
542+
const content = node.document.content;
543+
if (content.toLowerCase().includes(lowerTerm)) {
544+
results.push({
545+
nodeId: node.node_id,
546+
snippet: createSnippetFromContent(content, term),
547+
});
548+
}
549+
});
550+
return results.slice(0, Math.max(limit, 0));
551+
}
552+
553+
if (!window.electronAPI) {
554+
return [];
555+
}
556+
557+
const lowerTerm = term.toLowerCase();
558+
const pattern = `%${escapeForLikePattern(lowerTerm)}%`;
559+
const maxResults = Math.max(limit, 0) || 50;
560+
561+
const rows = await window.electronAPI.dbQuery(
562+
`
563+
SELECT d.node_id as node_id, cs.text_content as content
564+
FROM documents d
565+
JOIN doc_versions dv ON d.current_version_id = dv.version_id
566+
JOIN content_store cs ON dv.content_id = cs.content_id
567+
WHERE cs.text_content IS NOT NULL
568+
AND cs.text_content != ''
569+
AND LOWER(cs.text_content) LIKE ? ESCAPE '\\'
570+
LIMIT ?
571+
`,
572+
[pattern, maxResults],
573+
);
574+
575+
return rows
576+
.filter(row => typeof row.content === 'string' && row.content.length > 0)
577+
.map(row => ({
578+
nodeId: row.node_id,
579+
snippet: createSnippetFromContent(row.content, term),
580+
}));
581+
},
582+
487583
async addNode(nodeData: Omit<Node, 'node_id' | 'sort_order' | 'created_at' | 'updated_at'>): Promise<Node> {
488584
if (!isElectron) {
489585
const state = ensureBrowserState();

types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ export interface DocumentOrFolder {
217217
doc_type?: DocType;
218218
language_hint?: string | null;
219219
default_view_mode?: ViewMode | null;
220+
searchSnippet?: string;
220221
}
221222

222223
// Fix: Renamed LegacyPromptVersion to DocumentVersion and aliased it to the new DocVersion type

0 commit comments

Comments
 (0)