Skip to content

Commit 0934327

Browse files
authored
Merge pull request #55 from beNative/codex/extend-repository-for-full-text-search
Add body text search snippets to document tree
2 parents 56b5636 + 53291e1 commit 0934327

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);
@@ -172,9 +173,20 @@ const MainApp: React.FC = () => {
172173
}, [theme, settings.markdownCodeBlockBackgroundLight, settings.markdownCodeBlockBackgroundDark, settingsLoaded]);
173174

174175

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

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

187199

200+
useEffect(() => {
201+
const term = searchTerm.trim();
202+
if (!term) {
203+
setBodySearchMatches(new Map());
204+
return;
205+
}
206+
207+
let isCancelled = false;
208+
209+
repository.searchDocumentsByBody(term, 200)
210+
.then(results => {
211+
if (!isCancelled) {
212+
setBodySearchMatches(new Map(results.map(result => [result.nodeId, result.snippet])));
213+
}
214+
})
215+
.catch(error => {
216+
if (!isCancelled) {
217+
console.error('Failed to search document bodies:', error);
218+
setBodySearchMatches(new Map());
219+
}
220+
});
221+
222+
return () => {
223+
isCancelled = true;
224+
};
225+
}, [searchTerm]);
226+
188227
const { documentTree, navigableItems } = useMemo(() => {
189-
let itemsToBuildFrom = items;
228+
let itemsToBuildFrom = itemsWithSearchMetadata;
190229
if (searchTerm.trim()) {
191230
const lowerCaseSearchTerm = searchTerm.toLowerCase();
192231
const visibleIds = new Set<string>();
193-
const originalItemsById: Map<string, DocumentOrFolder> = new Map(items.map(i => [i.id, i]));
232+
const originalItemsById: Map<string, DocumentOrFolder> = new Map(itemsWithSearchMetadata.map(i => [i.id, i]));
194233
const getAncestors = (itemId: string) => {
195234
let current = originalItemsById.get(itemId);
196235
while (current && current.parentId) {
197-
visibleIds.add(current.parentId);
198-
current = originalItemsById.get(current.parentId);
236+
visibleIds.add(current.parentId);
237+
current = originalItemsById.get(current.parentId);
199238
}
200239
};
201240
const getDescendantIdsRecursive = (itemId: string): Set<string> => {
202241
const descendantIds = new Set<string>();
203242
const findChildren = (parentId: string) => {
204-
items.forEach(p => { if (p.parentId === parentId) { descendantIds.add(p.id); if (p.type === 'folder') findChildren(p.id); } });
243+
itemsWithSearchMetadata.forEach(p => {
244+
if (p.parentId === parentId) {
245+
descendantIds.add(p.id);
246+
if (p.type === 'folder') findChildren(p.id);
247+
}
248+
});
205249
};
206250
findChildren(itemId);
207251
return descendantIds;
208252
};
209-
items.forEach(item => {
210-
if (item.title.toLowerCase().includes(lowerCaseSearchTerm)) {
253+
itemsWithSearchMetadata.forEach(item => {
254+
const titleMatch = item.title.toLowerCase().includes(lowerCaseSearchTerm);
255+
const bodyMatch = Boolean(item.searchSnippet);
256+
if (titleMatch || bodyMatch) {
211257
visibleIds.add(item.id);
212258
getAncestors(item.id);
213-
if (item.type === 'folder') getDescendantIdsRecursive(item.id).forEach(id => visibleIds.add(id));
259+
if (titleMatch && item.type === 'folder') {
260+
getDescendantIdsRecursive(item.id).forEach(id => visibleIds.add(id));
261+
}
214262
}
215263
});
216-
itemsToBuildFrom = items.filter(item => visibleIds.has(item.id));
264+
itemsToBuildFrom = itemsWithSearchMetadata.filter(item => visibleIds.has(item.id));
217265
}
218266
const itemsById = new Map<string, DocumentNode>(itemsToBuildFrom.map(p => [p.id, { ...p, children: [] }]));
219267
const rootNodes: DocumentNode[] = [];
@@ -225,27 +273,27 @@ const MainApp: React.FC = () => {
225273
rootNodes.push(node);
226274
}
227275
}
228-
276+
229277
const finalTree = rootNodes;
230278

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

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

247295
return { documentTree: finalTree, navigableItems: flatList };
248-
}, [items, templates, searchTerm, expandedFolderIds]);
296+
}, [itemsWithSearchMetadata, templates, searchTerm, expandedFolderIds]);
249297

250298
useEffect(() => {
251299
if (window.electronAPI?.getAppVersion) {
@@ -1120,8 +1168,8 @@ const MainApp: React.FC = () => {
11201168
style={{ width: `${sidebarWidth}px` }}
11211169
className="bg-secondary border-r border-border-color flex flex-col flex-shrink-0"
11221170
>
1123-
<Sidebar
1124-
documents={items}
1171+
<Sidebar
1172+
documents={itemsWithSearchMetadata}
11251173
documentTree={documentTree}
11261174
navigableItems={navigableItems}
11271175
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 whitespace-pre-wrap break-words 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
@@ -207,6 +207,43 @@ const mapNodeTree = (nodes: Node[], mapper: (node: Node) => void) => {
207207
}
208208
};
209209

210+
const escapeForLikePattern = (value: string): string => value.replace(/([\\%_])/g, '\\$1');
211+
212+
const createSnippetFromContent = (content: string, term: string, snippetLength: number = 160): string => {
213+
const normalizedContent = content.replace(/\s+/g, ' ').trim();
214+
if (!normalizedContent) {
215+
return '';
216+
}
217+
218+
const lowerContent = normalizedContent.toLowerCase();
219+
const lowerTerm = term.toLowerCase();
220+
const matchIndex = lowerContent.indexOf(lowerTerm);
221+
222+
if (matchIndex === -1) {
223+
if (normalizedContent.length <= snippetLength) {
224+
return normalizedContent;
225+
}
226+
return `${normalizedContent.slice(0, snippetLength).trim()}…`;
227+
}
228+
229+
const halfWindow = Math.max(0, Math.floor((snippetLength - lowerTerm.length) / 2));
230+
let start = Math.max(0, matchIndex - halfWindow);
231+
let end = Math.min(normalizedContent.length, matchIndex + lowerTerm.length + halfWindow);
232+
233+
if (end - start > snippetLength) {
234+
end = start + snippetLength;
235+
}
236+
237+
let snippet = normalizedContent.slice(start, end).trim();
238+
if (start > 0) {
239+
snippet = `…${snippet}`;
240+
}
241+
if (end < normalizedContent.length) {
242+
snippet = `${snippet}…`;
243+
}
244+
return snippet;
245+
};
246+
210247
const duplicateNodeRecursive = (state: BrowserState, node: Node, newParentId: string | null, sortOrder: number): Node => {
211248
const now = new Date().toISOString();
212249
const clonedNode: Node = {
@@ -495,6 +532,65 @@ export const repository = {
495532
return rootNodes;
496533
},
497534

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

types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ export interface DocumentOrFolder {
228228
doc_type?: DocType;
229229
language_hint?: string | null;
230230
default_view_mode?: ViewMode | null;
231+
searchSnippet?: string;
231232
}
232233

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

0 commit comments

Comments
 (0)