feat: Implement DeepWiki documentation generation tools

- Added `__init__.py` in `codexlens/tools` for documentation generation.
- Created `deepwiki_generator.py` to handle symbol extraction and markdown generation.
- Introduced `MockMarkdownGenerator` for testing purposes.
- Implemented `DeepWikiGenerator` class for managing documentation generation and file processing.
- Added unit tests for `DeepWikiStore` to ensure proper functionality and error handling.
- Created tests for DeepWiki TypeScript types matching.
This commit is contained in:
catlog22
2026-03-05 18:30:56 +08:00
parent 0bfae3fd1a
commit fb4f6e718e
62 changed files with 7500 additions and 68 deletions

View File

@@ -24,6 +24,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/Select';
import { useNotifications } from '@/hooks/useNotifications';
import type { CliEndpoint } from '@/lib/api';
export type CliEndpointFormMode = 'create' | 'edit';
@@ -84,6 +85,7 @@ export function CliEndpointFormDialog({
onSave,
}: CliEndpointFormDialogProps) {
const { formatMessage } = useIntl();
const { error: showError } = useNotifications();
const isEditing = mode === 'edit';
const [name, setName] = useState('');
@@ -147,6 +149,7 @@ export function CliEndpointFormDialog({
});
onClose();
} catch (err) {
showError(formatMessage({ id: 'cliEndpoints.messages.saveFailed' }));
console.error('Failed to save CLI endpoint:', err);
} finally {
setIsSubmitting(false);

View File

@@ -0,0 +1,329 @@
// ========================================
// DocumentViewer Component
// ========================================
// Displays DeepWiki documentation content with table of contents
import { useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import { FileText, Hash, Clock, Sparkles, AlertCircle, Link2, Check } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils';
import type { DeepWikiSymbol, DeepWikiDoc } from '@/hooks/useDeepWiki';
export interface DocumentViewerProps {
doc: DeepWikiDoc | null;
content: string;
symbols: DeepWikiSymbol[];
isLoading?: boolean;
error?: Error | null;
/** Current file path for generating deep links */
filePath?: string;
}
/**
* Simple markdown-to-HTML converter for basic formatting
*/
function markdownToHtml(markdown: string): string {
let html = markdown;
// Code blocks
html = html.replace(/```(\w+)?\n([\s\S]*?)```/g, '<pre class="code-block"><code class="language-$1">$2</code></pre>');
// Inline code
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>');
// Headers
html = html.replace(/^### (.*$)/gm, '<h3 class="doc-h3">$1</h3>');
html = html.replace(/^## (.*$)/gm, '<h2 class="doc-h2">$1</h2>');
html = html.replace(/^# (.*$)/gm, '<h1 class="doc-h1">$1</h1>');
// Bold and italic
html = html.replace(/\*\*\*(.*?)\*\*\*/g, '<strong><em>$1</em></strong>');
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
// Links
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="doc-link">$1</a>');
// Line breaks
html = html.replace(/\n\n/g, '</p><p class="doc-paragraph">');
html = html.replace(/\n/g, '<br />');
return `<div class="doc-content"><p class="doc-paragraph">${html}</p></div>`;
}
/**
* Get symbol type icon
*/
function getSymbolTypeIcon(type: string): string {
const icons: Record<string, string> = {
function: 'λ',
async_function: 'λ',
class: '◇',
method: '◈',
interface: '△',
variable: '•',
constant: '⬡',
};
return icons[type] || '•';
}
/**
* Get symbol type color
*/
function getSymbolTypeColor(type: string): string {
const colors: Record<string, string> = {
function: 'text-blue-500',
async_function: 'text-blue-400',
class: 'text-purple-500',
method: 'text-purple-400',
interface: 'text-teal-500',
variable: 'text-gray-500',
constant: 'text-amber-500',
};
return colors[type] || 'text-gray-500';
}
export function DocumentViewer({
doc,
content,
symbols,
isLoading = false,
error = null,
filePath,
}: DocumentViewerProps) {
const { formatMessage } = useIntl();
const [copiedSymbol, setCopiedSymbol] = useState<string | null>(null);
// Copy deep link to clipboard
const copyDeepLink = useCallback((symbolName: string, anchor: string) => {
const url = new URL(window.location.href);
if (filePath) {
url.searchParams.set('file', filePath);
}
url.hash = anchor.replace('#', '');
navigator.clipboard.writeText(url.toString()).then(() => {
setCopiedSymbol(symbolName);
setTimeout(() => setCopiedSymbol(null), 2000);
});
}, [filePath]);
// Parse HTML comments for symbol metadata
const symbolSections = useMemo(() => {
if (!content) return [];
// Extract sections marked with deepwiki-symbol-start/end comments
const regex = /<!-- deepwiki-symbol-start name="([^"]+)" type="([^"]+)" -->([\s\S]*?)<!-- deepwiki-symbol-end -->/g;
const sections: Array<{ name: string; type: string; content: string }> = [];
let match;
while ((match = regex.exec(content)) !== null) {
sections.push({
name: match[1],
type: match[2],
content: match[3].trim(),
});
}
// If no sections found, treat entire content as one section
if (sections.length === 0 && content.trim()) {
sections.push({
name: 'Documentation',
type: 'document',
content: content.trim(),
});
}
return sections;
}, [content]);
// Loading state
if (isLoading) {
return (
<Card className="flex-1 p-6">
<div className="space-y-4 animate-pulse">
<div className="h-8 bg-muted rounded w-1/3" />
<div className="h-4 bg-muted rounded w-2/3" />
<div className="h-4 bg-muted rounded w-1/2" />
<div className="h-32 bg-muted rounded mt-6" />
<div className="h-4 bg-muted rounded w-3/4" />
<div className="h-4 bg-muted rounded w-2/3" />
</div>
</Card>
);
}
// Error state
if (error) {
return (
<Card className="flex-1 p-6 flex flex-col items-center justify-center text-center">
<AlertCircle className="w-12 h-12 text-destructive/50 mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'deepwiki.viewer.error.title', defaultMessage: 'Error Loading Document' })}
</h3>
<p className="text-sm text-muted-foreground">{error.message}</p>
</Card>
);
}
// Empty state
if (!doc && !content) {
return (
<Card className="flex-1 p-6 flex flex-col items-center justify-center text-center">
<FileText className="w-12 h-12 text-muted-foreground/30 mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'deepwiki.viewer.empty.title', defaultMessage: 'Select a File' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'deepwiki.viewer.empty.message', defaultMessage: 'Choose a file from the list to view its documentation' })}
</p>
</Card>
);
}
return (
<Card className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<div className="p-4 border-b border-border">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
<FileText className="w-5 h-5 text-primary" />
<span className="font-mono text-sm" title={doc?.path}>
{doc?.path?.split('/').pop() || 'Documentation'}
</span>
</h2>
{doc && (
<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground">
{doc.generatedAt && (
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{new Date(doc.generatedAt).toLocaleString()}
</span>
)}
{doc.llmTool && (
<span className="flex items-center gap-1">
<Sparkles className="w-3 h-3" />
{doc.llmTool}
</span>
)}
</div>
)}
</div>
</div>
</div>
{/* Main content area with TOC sidebar */}
<div className="flex-1 flex overflow-hidden">
{/* Document content */}
<div className="flex-1 overflow-y-auto p-6">
{/* Symbols list */}
{symbols.length > 0 && (
<div className="mb-6 flex flex-wrap gap-2">
{symbols.map(symbol => (
<a
key={symbol.name}
href={`#${symbol.anchor.replace('#', '')}`}
className={cn(
'inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium',
'bg-muted/50 hover:bg-muted transition-colors',
getSymbolTypeColor(symbol.type)
)}
>
<span>{getSymbolTypeIcon(symbol.type)}</span>
<span>{symbol.name}</span>
<Badge variant="outline" className="text-[10px] px-1">
{symbol.type}
</Badge>
</a>
))}
</div>
)}
{/* Document sections */}
<div className="space-y-6">
{symbolSections.map((section, idx) => (
<section
key={`${section.name}-${idx}`}
id={section.name.toLowerCase().replace(/\s+/g, '-')}
className="scroll-mt-4"
>
<div
className="prose prose-sm dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: markdownToHtml(section.content) }}
/>
</section>
))}
{symbolSections.length === 0 && content && (
<div
className="prose prose-sm dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: markdownToHtml(content) }}
/>
)}
</div>
</div>
{/* Table of contents sidebar (if symbols exist) */}
{symbols.length > 0 && (
<div className="w-48 border-l border-border p-4 overflow-y-auto hidden lg:block">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3 flex items-center gap-2">
<Hash className="w-3 h-3" />
{formatMessage({ id: 'deepwiki.viewer.toc', defaultMessage: 'Symbols' })}
</h4>
<nav className="space-y-1">
{symbols.map(symbol => (
<a
key={symbol.name}
href={`#${symbol.anchor.replace('#', '')}`}
className={cn(
'block text-xs py-1.5 px-2 rounded transition-colors',
'text-muted-foreground hover:text-foreground hover:bg-muted/50',
'font-mono'
)}
>
<span className={cn('mr-1', getSymbolTypeColor(symbol.type))}>
{getSymbolTypeIcon(symbol.type)}
</span>
{symbol.name}
</a>
))}
</nav>
</div>
)}
</div>
{/* Styles for rendered markdown */}
<style>{`
.doc-content { line-height: 1.7; }
.doc-paragraph { margin-bottom: 1rem; }
.doc-h1 { font-size: 1.5rem; font-weight: 700; margin: 1.5rem 0 1rem; color: var(--foreground); }
.doc-h2 { font-size: 1.25rem; font-weight: 600; margin: 1.25rem 0 0.75rem; color: var(--foreground); }
.doc-h3 { font-size: 1.1rem; font-weight: 600; margin: 1rem 0 0.5rem; color: var(--foreground); }
.doc-link { color: var(--primary); text-decoration: underline; }
.inline-code {
background: var(--muted);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.85em;
font-family: ui-monospace, monospace;
}
.code-block {
background: var(--muted);
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin: 1rem 0;
font-family: ui-monospace, monospace;
font-size: 0.85rem;
line-height: 1.5;
}
`}</style>
</Card>
);
}
export default DocumentViewer;

View File

@@ -0,0 +1,210 @@
// ========================================
// FileList Component
// ========================================
// List of documented files for DeepWiki
import { useState, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { FileText, Search, CheckCircle, Clock, RefreshCw } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/utils';
import type { DeepWikiFile } from '@/hooks/useDeepWiki';
export interface FileListProps {
files: DeepWikiFile[];
selectedPath: string | null;
onSelectFile: (filePath: string) => void;
isLoading?: boolean;
isFetching?: boolean;
onRefresh?: () => void;
}
/**
* Get relative file path from full path
*/
function getRelativePath(fullPath: string): string {
// Try to extract a more readable path
const parts = fullPath.replace(/\\/g, '/').split('/');
const srcIndex = parts.findIndex(p => p === 'src');
if (srcIndex >= 0) {
return parts.slice(srcIndex).join('/');
}
// Return last 3 segments if path is long
if (parts.length > 3) {
return '.../' + parts.slice(-3).join('/');
}
return parts.join('/');
}
/**
* Get file extension for icon coloring
*/
function getFileExtension(path: string): string {
const parts = path.split('.');
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
}
/**
* Get color class by file extension
*/
function getExtensionColor(ext: string): string {
const colors: Record<string, string> = {
ts: 'text-blue-500',
tsx: 'text-blue-500',
js: 'text-yellow-500',
jsx: 'text-yellow-500',
py: 'text-green-500',
go: 'text-cyan-500',
rs: 'text-orange-500',
java: 'text-red-500',
swift: 'text-orange-500',
};
return colors[ext] || 'text-gray-500';
}
export function FileList({
files,
selectedPath,
onSelectFile,
isLoading = false,
isFetching = false,
onRefresh,
}: FileListProps) {
const { formatMessage } = useIntl();
const [searchQuery, setSearchQuery] = useState('');
// Filter files by search query
const filteredFiles = useMemo(() => {
if (!searchQuery.trim()) return files;
const query = searchQuery.toLowerCase();
return files.filter(f => f.path.toLowerCase().includes(query));
}, [files, searchQuery]);
// Group files by directory
const groupedFiles = useMemo(() => {
const groups: Record<string, DeepWikiFile[]> = {};
for (const file of filteredFiles) {
const parts = file.path.replace(/\\/g, '/').split('/');
const dir = parts.length > 1 ? parts.slice(0, -1).join('/') : 'root';
if (!groups[dir]) {
groups[dir] = [];
}
groups[dir].push(file);
}
return groups;
}, [filteredFiles]);
if (isLoading) {
return (
<Card className="p-4">
<div className="space-y-3">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="h-12 bg-muted animate-pulse rounded" />
))}
</div>
</Card>
);
}
return (
<Card className="flex flex-col h-full">
{/* Header */}
<div className="p-4 border-b border-border">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-foreground flex items-center gap-2">
<FileText className="w-4 h-4" />
{formatMessage({ id: 'deepwiki.files.title', defaultMessage: 'Documented Files' })}
<Badge variant="secondary" className="text-xs">
{files.length}
</Badge>
</h3>
{onRefresh && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => onRefresh()}
disabled={isFetching}
>
<RefreshCw className={cn('w-4 h-4', isFetching && 'animate-spin')} />
</Button>
)}
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder={formatMessage({ id: 'deepwiki.files.search', defaultMessage: 'Search files...' })}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-8 text-sm"
/>
</div>
</div>
{/* File List */}
<div className="flex-1 overflow-y-auto p-2">
{filteredFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<FileText className="w-10 h-10 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">
{searchQuery
? formatMessage({ id: 'deepwiki.files.noResults', defaultMessage: 'No files match your search' })
: formatMessage({ id: 'deepwiki.files.empty', defaultMessage: 'No documented files yet' })}
</p>
</div>
) : (
<div className="space-y-1">
{Object.entries(groupedFiles).map(([dir, dirFiles]) => (
<div key={dir}>
{/* Directory header (collapsed for brevity) */}
<div className="space-y-0.5">
{dirFiles.map(file => {
const ext = getFileExtension(file.path);
const extColor = getExtensionColor(ext);
const isSelected = selectedPath === file.path;
return (
<button
key={file.path}
onClick={() => onSelectFile(file.path)}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded-md text-left transition-colors',
'hover:bg-muted/70',
isSelected && 'bg-primary/10 border border-primary/30'
)}
>
<FileText className={cn('w-4 h-4 flex-shrink-0', extColor)} />
<span className="flex-1 text-sm truncate font-mono" title={file.path}>
{getRelativePath(file.path)}
</span>
<div className="flex items-center gap-1.5 flex-shrink-0">
{file.docsGenerated ? (
<CheckCircle className="w-3.5 h-3.5 text-green-500" />
) : (
<Clock className="w-3.5 h-3.5 text-yellow-500" />
)}
{file.symbolsCount > 0 && (
<Badge variant="outline" className="text-xs px-1.5 py-0">
{file.symbolsCount}
</Badge>
)}
</div>
</button>
);
})}
</div>
</div>
))}
</div>
)}
</div>
</Card>
);
}
export default FileList;

View File

@@ -884,6 +884,13 @@ export function HookWizard({
</DialogTitle>
</DialogHeader>
<div className="w-full bg-muted h-1 rounded-full my-4">
<div
className="bg-primary h-1 rounded-full transition-all duration-300"
style={{ width: `${(currentStep / 3) * 100}%` }}
/>
</div>
{renderStepIndicator()}
<div className="min-h-[300px]">

View File

@@ -51,19 +51,34 @@ function applyDrag(columns: KanbanColumn<QueueBoardItem>[], result: DropResult):
if (!result.destination) return columns;
const { source, destination, draggableId } = result;
const next = columns.map((c) => ({ ...c, items: [...c.items] }));
const src = next.find((c) => c.id === source.droppableId);
const dst = next.find((c) => c.id === destination.droppableId);
if (!src || !dst) return columns;
const startCol = columns.find(c => c.id === source.droppableId);
const endCol = columns.find(c => c.id === destination.droppableId);
if (!startCol || !endCol) return columns;
const srcIndex = src.items.findIndex((i) => i.id === draggableId);
if (srcIndex === -1) return columns;
const itemToMove = startCol.items.find(item => item.id === draggableId);
if (!itemToMove) return columns;
const [moved] = src.items.splice(srcIndex, 1);
if (!moved) return columns;
// 如果在同一列中移动
if (startCol.id === endCol.id) {
const items = [...startCol.items];
const [reorderedItem] = items.splice(source.index, 1);
items.splice(destination.index, 0, reorderedItem);
return columns.map(c => c.id === startCol.id ? { ...c, items } : c);
}
dst.items.splice(destination.index, 0, moved);
return next;
// 如果跨列移动
const newStartItems = startCol.items.filter(item => item.id !== draggableId);
const newEndItems = [
...endCol.items.slice(0, destination.index),
itemToMove,
...endCol.items.slice(destination.index),
];
return columns.map(c => {
if (c.id === startCol.id) return { ...c, items: newStartItems };
if (c.id === endCol.id) return { ...c, items: newEndItems };
return c;
});
}
export function QueueBoard({

View File

@@ -0,0 +1,166 @@
// ========================================
// UX Tests: Immutable Array Operations
// ========================================
// Tests for UX feedback patterns: immutable array updates in drag-drop
import { describe, it, expect } from 'vitest';
describe('UX Pattern: Immutable Array Operations (QueueBoard)', () => {
describe('applyDrag function - immutable array patterns', () => {
it('should use filter() for removing items from source (immutable)', () => {
// This test verifies the QueueBoard.tsx pattern at lines 50-82
const sourceItems = [{ id: '1', content: 'Task 1' }, { id: '2', content: 'Task 2' }, { id: '3', content: 'Task 3' }];
const destItems = [{ id: '4', content: 'Task 4' }];
// Immutable removal using filter (not splice)
const removeIndex = 1;
const newSourceItems = sourceItems.filter((_, i) => i !== removeIndex);
// Verify original array unchanged
expect(sourceItems).toHaveLength(3);
expect(sourceItems[1].id).toBe('2');
// Verify new array has item removed
expect(newSourceItems).toHaveLength(2);
expect(newSourceItems[0].id).toBe('1');
expect(newSourceItems[1].id).toBe('3');
});
it('should use slice() for inserting items into destination (immutable)', () => {
// This test verifies the QueueBoard.tsx pattern at lines 50-82
const destItems = [{ id: '4', content: 'Task 4' }, { id: '5', content: 'Task 5' }];
const itemToMove = { id: '2', content: 'Task 2' };
const insertIndex = 1;
// Immutable insertion using slice (not splice)
const newDestItems = [
...destItems.slice(0, insertIndex),
itemToMove,
...destItems.slice(insertIndex),
];
// Verify original array unchanged
expect(destItems).toHaveLength(2);
expect(destItems[0].id).toBe('4');
// Verify new array has item inserted
expect(newDestItems).toHaveLength(3);
expect(newDestItems[0].id).toBe('4');
expect(newDestItems[1].id).toBe('2'); // Inserted item
expect(newDestItems[2].id).toBe('5');
});
it('should not mutate source arrays when copying columns', () => {
const columns = [
{ id: 'col1', items: [{ id: '1' }, { id: '2' }] },
{ id: 'col2', items: [{ id: '3' }] },
];
// Immutable column copy using spread
const next = columns.map((c) => ({ ...c, items: [...c.items] }));
// Modify copied data
next[0].items.push({ id: 'new' });
// Original should be unchanged
expect(columns[0].items).toHaveLength(2);
expect(next[0].items).toHaveLength(3);
});
it('should handle same-column drag-drop correctly', () => {
const sourceItems = [{ id: '1' }, { id: '2' }, { id: '3' }];
const sourceIndex = 0;
const destIndex = 2;
// Remove from source
const item = sourceItems[sourceIndex];
const newSrcItems = sourceItems.filter((_, i) => i !== sourceIndex);
// Insert back at different position
const newDstItems = [
...newSrcItems.slice(0, destIndex - 1),
item,
...newSrcItems.slice(destIndex - 1),
];
expect(newDstItems).toEqual([{ id: '2' }, { id: '1' }, { id: '3' }]);
});
it('should handle cross-column drag-drop correctly', () => {
const srcItems = [{ id: '1' }, { id: '2' }];
const dstItems = [{ id: '3' }, { id: '4' }];
const sourceIndex = 1;
const destIndex = 1;
const item = srcItems[sourceIndex];
const newSrcItems = srcItems.filter((_, i) => i !== sourceIndex);
const newDstItems = [
...dstItems.slice(0, destIndex),
item,
...dstItems.slice(destIndex),
];
expect(newSrcItems).toEqual([{ id: '1' }]);
expect(newDstItems).toEqual([{ id: '3' }, { id: '2' }, { id: '4' }]);
});
});
describe('React state update patterns', () => {
it('should demonstrate setItems with filter for removal', () => {
// Pattern: setItems(prev => prev.filter((_, i) => i !== index))
const items = [{ id: '1' }, { id: '2' }, { id: '3' }];
const indexToRemove = 1;
const newItems = items.filter((_, i) => i !== indexToRemove);
expect(newItems).toEqual([{ id: '1' }, { id: '3' }]);
expect(items).toHaveLength(3); // Original unchanged
});
it('should demonstrate setItems with slice for insertion', () => {
// Pattern: setItems(prev => [...prev.slice(0, index), newItem, ...prev.slice(index)])
const items = [{ id: '1' }, { id: '2' }];
const newItem = { id: 'new' };
const insertIndex = 1;
const newItems = [...items.slice(0, insertIndex), newItem, ...items.slice(insertIndex)];
expect(newItems).toEqual([{ id: '1' }, { id: 'new' }, { id: '2' }]);
expect(items).toHaveLength(2); // Original unchanged
});
it('should demonstrate setItems with map for update', () => {
// Pattern: setItems(prev => prev.map((item, i) => i === index ? { ...item, ...updates } : item))
const items = [{ id: '1', status: 'pending' }, { id: '2', status: 'pending' }];
const indexToUpdate = 1;
const updates = { status: 'completed' };
const newItems = items.map((item, i) =>
i === indexToUpdate ? { ...item, ...updates } : item
);
expect(newItems).toEqual([
{ id: '1', status: 'pending' },
{ id: '2', status: 'completed' },
]);
expect(items[1].status).toBe('pending'); // Original unchanged
});
it('should demonstrate ES2023 toSpliced alternative', () => {
// Pattern: items.toSpliced(index, 1) for removal
const items = [{ id: '1' }, { id: '2' }, { id: '3' }];
const indexToRemove = 1;
const newItems = items.toSpliced(indexToRemove, 1);
expect(newItems).toEqual([{ id: '1' }, { id: '3' }]);
expect(items).toHaveLength(3); // Original unchanged
// toSpliced for insertion
const newItem = { id: 'new' };
const insertedItems = items.toSpliced(1, 0, newItem);
expect(insertedItems).toEqual([{ id: '1' }, { id: 'new' }, { id: '2' }, { id: '3' }]);
});
});
});

View File

@@ -35,7 +35,7 @@ import {
isStdioMcpServer,
isHttpMcpServer,
} from '@/lib/api';
import { mcpServersKeys, useMcpTemplates } from '@/hooks';
import { mcpServersKeys, useMcpTemplates, useNotifications } from '@/hooks';
import { cn } from '@/lib/utils';
import { ConfigTypeToggle, type McpConfigType } from './ConfigTypeToggle';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
@@ -212,6 +212,7 @@ export function McpServerDialog({
const { formatMessage } = useIntl();
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
const { error: showError } = useNotifications();
// Fetch templates from backend
const { templates, isLoading: templatesLoading } = useMcpTemplates();
@@ -544,7 +545,8 @@ export function McpServerDialog({
env: Object.keys(formData.env).length > 0 ? formData.env : undefined,
},
});
} catch {
} catch (err) {
showError(formatMessage({ id: 'mcp.templates.feedback.saveError' }), err instanceof Error ? err.message : String(err));
// Template save failure should not block server creation
}
}

View File

@@ -0,0 +1,80 @@
// ========================================
// UX Tests: Error Handling in Hooks
// ========================================
// Tests for UX feedback patterns: error handling with toast notifications in hooks
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useCommands } from '../useCommands';
import { useNotificationStore } from '../../stores/notificationStore';
// Mock the API
vi.mock('../../lib/api', () => ({
executeCommand: vi.fn(),
deleteCommand: vi.fn(),
createCommand: vi.fn(),
updateCommand: vi.fn(),
}));
describe('UX Pattern: Error Handling in useCommands Hook', () => {
beforeEach(() => {
// Reset store state before each test
useNotificationStore.setState({
toasts: [],
a2uiSurfaces: new Map(),
currentQuestion: null,
persistentNotifications: [],
isPanelVisible: false,
});
localStorage.removeItem('ccw_notifications');
vi.clearAllMocks();
});
describe('Error notification on command execution failure', () => {
it('should show error toast when command execution fails', async () => {
const { executeCommand } = await import('../../lib/api');
vi.mocked(executeCommand).mockRejectedValueOnce(new Error('Command failed'));
const { result } = renderHook(() => useCommands());
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await act(async () => {
try {
await result.current.executeCommand('test-command', {});
} catch {
// Expected to throw
}
});
// Console error should be logged
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
it('should sanitize error messages before showing to user', async () => {
const { executeCommand } = await import('../../lib/api');
const nastyError = new Error('Internal: Database connection failed at postgres://localhost:5432 with password=admin123');
vi.mocked(executeCommand).mockRejectedValueOnce(nastyError);
const { result } = renderHook(() => useCommands());
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await act(async () => {
try {
await result.current.executeCommand('test-command', {});
} catch {
// Expected to throw
}
});
// Full error logged to console
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Database connection failed'),
nastyError
);
consoleSpy.mockRestore();
});
});
});

View File

@@ -0,0 +1,250 @@
// ========================================
// UX Tests: useNotifications Hook
// ========================================
// Tests for UX feedback patterns: error/success/warning toast notifications
import { describe, it, expect, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useNotifications } from '../useNotifications';
import { useNotificationStore } from '../../stores/notificationStore';
describe('UX Pattern: Toast Notifications (useNotifications)', () => {
beforeEach(() => {
// Reset store state before each test
useNotificationStore.setState({
toasts: [],
a2uiSurfaces: new Map(),
currentQuestion: null,
persistentNotifications: [],
isPanelVisible: false,
});
localStorage.removeItem('ccw_notifications');
});
describe('Error Notifications', () => {
it('should add error toast with default persistent duration (0)', () => {
const { result } = renderHook(() => useNotifications());
act(() => {
result.current.error('Operation Failed', 'Something went wrong');
});
expect(result.current.toasts).toHaveLength(1);
expect(result.current.toasts[0]).toMatchObject({
type: 'error',
title: 'Operation Failed',
message: 'Something went wrong',
duration: 0, // Persistent by default for errors
dismissible: true,
});
});
it('should add error toast without message', () => {
const { result } = renderHook(() => useNotifications());
act(() => {
result.current.error('Error Title');
});
expect(result.current.toasts[0].title).toBe('Error Title');
expect(result.current.toasts[0].message).toBeUndefined();
});
it('should return toast ID for error notification', () => {
const { result } = renderHook(() => useNotifications());
let toastId: string = '';
act(() => {
toastId = result.current.error('Error');
});
expect(toastId).toBeDefined();
expect(typeof toastId).toBe('string');
expect(result.current.toasts[0].id).toBe(toastId);
});
it('should preserve console logging alongside toast notifications', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useNotifications());
act(() => {
result.current.error('Sync failed', 'Network error occurred');
});
// Toast notification added
expect(result.current.toasts).toHaveLength(1);
expect(result.current.toasts[0].type).toBe('error');
// Console logging should also be called (handled by caller)
// This test verifies the hook doesn't interfere with console logging
consoleSpy.mockRestore();
});
});
describe('Success Notifications', () => {
it('should add success toast', () => {
const { result } = renderHook(() => useNotifications());
act(() => {
result.current.success('Success', 'Operation completed');
});
expect(result.current.toasts).toHaveLength(1);
expect(result.current.toasts[0]).toMatchObject({
type: 'success',
title: 'Success',
message: 'Operation completed',
});
});
it('should add success toast without message', () => {
const { result } = renderHook(() => useNotifications());
act(() => {
result.current.success('Created');
});
expect(result.current.toasts[0]).toMatchObject({
type: 'success',
title: 'Created',
message: undefined,
});
});
});
describe('Warning Notifications', () => {
it('should add warning toast for partial success scenarios', () => {
const { result } = renderHook(() => useNotifications());
act(() => {
result.current.warning('Partial Success', 'Issue created but attachments failed');
});
expect(result.current.toasts).toHaveLength(1);
expect(result.current.toasts[0]).toMatchObject({
type: 'warning',
title: 'Partial Success',
message: 'Issue created but attachments failed',
});
});
});
describe('Info Notifications', () => {
it('should add info toast', () => {
const { result } = renderHook(() => useNotifications());
act(() => {
result.current.info('Information', 'Here is some info');
});
expect(result.current.toasts).toHaveLength(1);
expect(result.current.toasts[0]).toMatchObject({
type: 'info',
title: 'Information',
message: 'Here is some info',
});
});
});
describe('Toast Removal', () => {
it('should remove toast by ID', () => {
const { result } = renderHook(() => useNotifications());
let toastId: string = '';
act(() => {
toastId = result.current.error('Error');
});
expect(result.current.toasts).toHaveLength(1);
act(() => {
result.current.removeToast(toastId);
});
expect(result.current.toasts).toHaveLength(0);
});
it('should clear all toasts', () => {
const { result } = renderHook(() => useNotifications());
act(() => {
result.current.success('Success 1');
result.current.error('Error 1');
result.current.warning('Warning 1');
});
expect(result.current.toasts).toHaveLength(3);
act(() => {
result.current.clearAllToasts();
});
expect(result.current.toasts).toHaveLength(0);
});
});
describe('UX Pattern: Multiple toast types in sequence', () => {
it('should handle issue creation workflow with success and partial success', () => {
const { result } = renderHook(() => useNotifications());
// Simulate: Issue created successfully
act(() => {
result.current.success('Created', 'Issue created successfully');
});
expect(result.current.toasts[0].type).toBe('success');
// Simulate: Attachment upload warning
act(() => {
result.current.warning('Partial Success', 'Issue created but attachments failed to upload');
});
expect(result.current.toasts[0].type).toBe('warning');
// Simulate: Error case
act(() => {
result.current.error('Failed', 'Failed to create issue');
});
expect(result.current.toasts[0].type).toBe('error');
});
});
describe('UX Pattern: Toast options', () => {
it('should support custom duration via addToast', () => {
const { result } = renderHook(() => useNotifications());
act(() => {
result.current.addToast('info', 'Temporary', 'Will auto-dismiss', { duration: 3000 });
});
expect(result.current.toasts[0].duration).toBe(3000);
});
it('should support dismissible option', () => {
const { result } = renderHook(() => useNotifications());
act(() => {
result.current.addToast('info', 'Info', 'Message', { dismissible: false });
});
expect(result.current.toasts[0].dismissible).toBe(false);
});
it('should support action button', () => {
const mockAction = vi.fn();
const { result } = renderHook(() => useNotifications());
act(() => {
result.current.addToast('info', 'Info', 'Message', {
action: { label: 'Retry', onClick: mockAction },
});
});
expect(result.current.toasts[0].action).toEqual({
label: 'Retry',
onClick: mockAction,
});
});
});
});

View File

@@ -222,6 +222,7 @@ export {
cliInstallationsKeys,
useHooks,
useToggleHook,
useDeleteHook,
hooksKeys,
useRules,
useToggleRule,
@@ -396,3 +397,27 @@ export type {
SkillCacheResponse,
SkillHubStats,
} from './useSkillHub';
// ========== DeepWiki ==========
export {
useDeepWikiFiles,
useDeepWikiDoc,
useDeepWikiStats,
useDeepWikiSearch,
deepWikiKeys,
} from './useDeepWiki';
export type {
DeepWikiFile,
DeepWikiSymbol,
DeepWikiDoc,
DeepWikiStats,
DocumentResponse,
UseDeepWikiFilesOptions,
UseDeepWikiFilesReturn,
UseDeepWikiDocOptions,
UseDeepWikiDocReturn,
UseDeepWikiStatsOptions,
UseDeepWikiStatsReturn,
UseDeepWikiSearchOptions,
UseDeepWikiSearchReturn,
} from './useDeepWiki';

View File

@@ -410,6 +410,7 @@ export function useUpgradeCliTool() {
import {
fetchHooks,
toggleHook,
deleteHook,
type Hook,
type HooksResponse,
} from '../lib/api';
@@ -511,6 +512,41 @@ export function useToggleHook() {
};
}
export function useDeleteHook() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (hookName: string) => deleteHook(hookName),
onMutate: async (hookName) => {
await queryClient.cancelQueries({ queryKey: hooksKeys.all });
const previousHooks = queryClient.getQueryData<HooksResponse>(hooksKeys.lists());
queryClient.setQueryData<HooksResponse>(hooksKeys.lists(), (old) => {
if (!old) return old;
return {
hooks: old.hooks.filter((h) => h.name !== hookName),
};
});
return { previousHooks };
},
onError: (_error, _hookName, context) => {
if (context?.previousHooks) {
queryClient.setQueryData(hooksKeys.lists(), context.previousHooks);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: hooksKeys.all });
},
});
return {
deleteHook: mutation.mutateAsync,
isDeleting: mutation.isPending,
error: mutation.error,
};
}
// ========================================
// useRules Hook
// ========================================

View File

@@ -0,0 +1,272 @@
// ========================================
// useDeepWiki Hook
// ========================================
// TanStack Query hooks for DeepWiki documentation system
import { useQuery } from '@tanstack/react-query';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
// Types
export interface DeepWikiFile {
id?: number;
path: string;
contentHash: string;
lastIndexed: string;
symbolsCount: number;
docsGenerated: boolean;
}
export interface DeepWikiSymbol {
id?: number;
name: string;
type: string;
sourceFile: string;
docFile: string;
anchor: string;
lineRange: [number, number];
createdAt?: string;
updatedAt?: string;
}
export interface DeepWikiDoc {
id?: number;
path: string;
contentHash: string;
symbols: string[];
generatedAt: string;
llmTool?: string;
}
export interface DeepWikiStats {
available: boolean;
files: number;
symbols: number;
docs: number;
filesNeedingDocs?: number;
dbPath?: string;
}
export interface DocumentResponse {
doc: DeepWikiDoc | null;
content: string;
symbols: DeepWikiSymbol[];
}
// Query key factory
export const deepWikiKeys = {
all: ['deepWiki'] as const,
files: () => [...deepWikiKeys.all, 'files'] as const,
doc: (path: string) => [...deepWikiKeys.all, 'doc', path] as const,
stats: () => [...deepWikiKeys.all, 'stats'] as const,
search: (query: string) => [...deepWikiKeys.all, 'search', query] as const,
};
// Default stale time: 2 minutes
const STALE_TIME = 2 * 60 * 1000;
/**
* Fetch list of documented files
*/
async function fetchDeepWikiFiles(): Promise<DeepWikiFile[]> {
const response = await fetch('/api/deepwiki/files');
if (!response.ok) {
throw new Error(`Failed to fetch files: ${response.statusText}`);
}
return response.json();
}
/**
* Fetch document by source file path
*/
async function fetchDeepWikiDoc(filePath: string): Promise<DocumentResponse> {
const response = await fetch(`/api/deepwiki/doc?path=${encodeURIComponent(filePath)}`);
if (!response.ok) {
if (response.status === 404) {
return { doc: null, content: '', symbols: [] };
}
throw new Error(`Failed to fetch document: ${response.statusText}`);
}
return response.json();
}
/**
* Fetch DeepWiki statistics
*/
async function fetchDeepWikiStats(): Promise<DeepWikiStats> {
const response = await fetch('/api/deepwiki/stats');
if (!response.ok) {
throw new Error(`Failed to fetch stats: ${response.statusText}`);
}
return response.json();
}
/**
* Search symbols by query
*/
async function searchDeepWikiSymbols(query: string, limit = 50): Promise<DeepWikiSymbol[]> {
const response = await fetch(`/api/deepwiki/search?q=${encodeURIComponent(query)}&limit=${limit}`);
if (!response.ok) {
throw new Error(`Failed to search symbols: ${response.statusText}`);
}
return response.json();
}
// ========== Hooks ==========
export interface UseDeepWikiFilesOptions {
staleTime?: number;
enabled?: boolean;
}
export interface UseDeepWikiFilesReturn {
files: DeepWikiFile[];
isLoading: boolean;
isFetching: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
/**
* Hook for fetching list of documented files
*/
export function useDeepWikiFiles(options: UseDeepWikiFilesOptions = {}): UseDeepWikiFilesReturn {
const { staleTime = STALE_TIME, enabled = true } = options;
const projectPath = useWorkflowStore(selectProjectPath);
const query = useQuery({
queryKey: deepWikiKeys.files(),
queryFn: fetchDeepWikiFiles,
staleTime,
enabled: enabled && !!projectPath,
retry: 2,
});
return {
files: query.data ?? [],
isLoading: query.isLoading,
isFetching: query.isFetching,
error: query.error,
refetch: async () => {
await query.refetch();
},
};
}
export interface UseDeepWikiDocOptions {
staleTime?: number;
enabled?: boolean;
}
export interface UseDeepWikiDocReturn {
doc: DeepWikiDoc | null;
content: string;
symbols: DeepWikiSymbol[];
isLoading: boolean;
isFetching: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
/**
* Hook for fetching a document by source file path
*/
export function useDeepWikiDoc(filePath: string | null, options: UseDeepWikiDocOptions = {}): UseDeepWikiDocReturn {
const { staleTime = STALE_TIME, enabled = true } = options;
const query = useQuery({
queryKey: deepWikiKeys.doc(filePath ?? ''),
queryFn: () => fetchDeepWikiDoc(filePath!),
staleTime,
enabled: enabled && !!filePath,
retry: 2,
});
return {
doc: query.data?.doc ?? null,
content: query.data?.content ?? '',
symbols: query.data?.symbols ?? [],
isLoading: query.isLoading,
isFetching: query.isFetching,
error: query.error,
refetch: async () => {
await query.refetch();
},
};
}
export interface UseDeepWikiStatsOptions {
staleTime?: number;
enabled?: boolean;
}
export interface UseDeepWikiStatsReturn {
stats: DeepWikiStats | null;
isLoading: boolean;
isFetching: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
/**
* Hook for fetching DeepWiki statistics
*/
export function useDeepWikiStats(options: UseDeepWikiStatsOptions = {}): UseDeepWikiStatsReturn {
const { staleTime = STALE_TIME, enabled = true } = options;
const query = useQuery({
queryKey: deepWikiKeys.stats(),
queryFn: fetchDeepWikiStats,
staleTime,
enabled,
retry: 2,
});
return {
stats: query.data ?? null,
isLoading: query.isLoading,
isFetching: query.isFetching,
error: query.error,
refetch: async () => {
await query.refetch();
},
};
}
export interface UseDeepWikiSearchOptions {
limit?: number;
staleTime?: number;
enabled?: boolean;
}
export interface UseDeepWikiSearchReturn {
symbols: DeepWikiSymbol[];
isLoading: boolean;
isFetching: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
/**
* Hook for searching symbols
*/
export function useDeepWikiSearch(query: string, options: UseDeepWikiSearchOptions = {}): UseDeepWikiSearchReturn {
const { limit = 50, staleTime = STALE_TIME, enabled = true } = options;
const queryResult = useQuery({
queryKey: deepWikiKeys.search(query),
queryFn: () => searchDeepWikiSymbols(query, limit),
staleTime,
enabled: enabled && query.length > 0,
retry: 2,
});
return {
symbols: queryResult.data ?? [],
isLoading: queryResult.isLoading,
isFetching: queryResult.isFetching,
error: queryResult.error,
refetch: async () => {
await queryResult.refetch();
},
};
}

View File

@@ -0,0 +1,334 @@
// ========================================
// DeepWiki Page
// ========================================
// Documentation deep-linking page with file browser and document viewer
import { useState, useEffect, useRef, useCallback } from 'react';
import { useIntl } from 'react-intl';
import { useSearchParams } from 'react-router-dom';
import { BookOpen, RefreshCw, FileText, Hash, BarChart3 } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { TabsNavigation } from '@/components/ui/TabsNavigation';
import { FileList } from '@/components/deepwiki/FileList';
import { DocumentViewer } from '@/components/deepwiki/DocumentViewer';
import {
useDeepWikiFiles,
useDeepWikiDoc,
useDeepWikiStats,
useDeepWikiSearch,
} from '@/hooks/useDeepWiki';
import { cn } from '@/lib/utils';
type ActiveTab = 'documents' | 'index' | 'stats';
/**
* Stats card component
*/
function StatsCard({
label,
value,
icon: Icon,
color,
}: {
label: string;
value: number;
icon: React.ComponentType<{ className?: string }>;
color: string;
}) {
return (
<Card className="p-4">
<div className="flex items-center gap-3">
<div className={cn('p-2 rounded-lg', color)}>
<Icon className="w-5 h-5" />
</div>
<div>
<p className="text-2xl font-bold text-foreground">{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
</div>
</Card>
);
}
export function DeepWikiPage() {
const { formatMessage } = useIntl();
const [searchParams, setSearchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState<ActiveTab>('documents');
const [searchQuery, setSearchQuery] = useState('');
const scrollAttemptedRef = useRef(false);
// Get file from URL query parameter
const fileParam = searchParams.get('file');
const hashParam = window.location.hash.slice(1); // Remove leading #
const [selectedFile, setSelectedFile] = useState<string | null>(fileParam);
// Data hooks
const {
files,
isLoading: filesLoading,
isFetching: filesFetching,
refetch: refetchFiles,
} = useDeepWikiFiles();
const {
stats,
isLoading: statsLoading,
refetch: refetchStats,
} = useDeepWikiStats();
const {
doc,
content,
symbols,
isLoading: docLoading,
error: docError,
} = useDeepWikiDoc(selectedFile);
const {
symbols: searchResults,
isLoading: searchLoading,
} = useDeepWikiSearch(searchQuery, { enabled: activeTab === 'index' });
// Handle file selection with URL sync
const handleSelectFile = useCallback((filePath: string) => {
setSelectedFile(filePath);
setSearchParams({ file: filePath });
scrollAttemptedRef.current = false; // Reset scroll flag for new file
}, [setSearchParams]);
// Scroll to symbol anchor when content loads
useEffect(() => {
if (hashParam && content && !docLoading && !scrollAttemptedRef.current) {
scrollAttemptedRef.current = true;
// Small delay to ensure DOM is rendered
setTimeout(() => {
const element = document.getElementById(hashParam);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, 100);
}
}, [hashParam, content, docLoading]);
// Refresh all data
const handleRefresh = () => {
refetchFiles();
refetchStats();
};
return (
<div className="h-full flex flex-col space-y-4">
{/* Page Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<BookOpen className="w-6 h-6 text-primary" />
{formatMessage({ id: 'deepwiki.title', defaultMessage: 'DeepWiki' })}
</h1>
<p className="text-muted-foreground mt-1">
{formatMessage({ id: 'deepwiki.description', defaultMessage: 'Code documentation with deep-linking to source symbols' })}
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleRefresh}
disabled={filesFetching}
>
<RefreshCw className={cn('w-4 h-4 mr-2', filesFetching && 'animate-spin')} />
{formatMessage({ id: 'common.actions.refresh', defaultMessage: 'Refresh' })}
</Button>
</div>
</div>
{/* Tabbed Interface */}
<TabsNavigation
value={activeTab}
onValueChange={(value) => setActiveTab(value as ActiveTab)}
tabs={[
{ value: 'documents', label: formatMessage({ id: 'deepwiki.tabs.documents', defaultMessage: 'Documents' }) },
{ value: 'index', label: formatMessage({ id: 'deepwiki.tabs.index', defaultMessage: 'Symbol Index' }) },
{ value: 'stats', label: formatMessage({ id: 'deepwiki.tabs.stats', defaultMessage: 'Statistics' }) },
]}
/>
{/* Tab Content: Documents */}
{activeTab === 'documents' && (
<div className="flex-1 flex gap-4 min-h-0">
{/* File List Sidebar */}
<div className="w-80 flex-shrink-0">
<FileList
files={files}
selectedPath={selectedFile}
onSelectFile={handleSelectFile}
isLoading={filesLoading}
isFetching={filesFetching}
onRefresh={() => refetchFiles()}
/>
</div>
{/* Document Viewer */}
<DocumentViewer
doc={doc}
content={content}
symbols={symbols}
isLoading={docLoading}
error={docError}
/>
</div>
)}
{/* Tab Content: Symbol Index */}
{activeTab === 'index' && (
<div className="flex-1 overflow-y-auto">
<Card className="p-4 h-full flex flex-col">
{/* Search input */}
<div className="mb-4">
<input
type="text"
placeholder={formatMessage({ id: 'deepwiki.index.search', defaultMessage: 'Search symbols...' })}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full px-4 py-2 rounded-md border border-input bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
{/* Search results */}
{searchLoading ? (
<div className="flex-1 flex items-center justify-center">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : searchResults.length > 0 ? (
<div className="flex-1 overflow-y-auto space-y-2">
{searchResults.map(symbol => (
<button
key={`${symbol.name}-${symbol.sourceFile}`}
onClick={() => {
setSelectedFile(symbol.sourceFile);
setActiveTab('documents');
}}
className="w-full p-3 rounded-md border border-border hover:bg-muted/50 transition-colors text-left"
>
<div className="flex items-center gap-2">
<Hash className="w-4 h-4 text-primary" />
<span className="font-medium text-foreground">{symbol.name}</span>
<Badge variant="outline" className="text-xs">{symbol.type}</Badge>
</div>
<p className="text-xs text-muted-foreground mt-1 font-mono truncate">
{symbol.sourceFile}:{symbol.lineRange[0]}-{symbol.lineRange[1]}
</p>
</button>
))}
</div>
) : searchQuery ? (
<div className="flex-1 flex flex-col items-center justify-center text-center">
<Hash className="w-10 h-10 text-muted-foreground/30 mb-3" />
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'deepwiki.index.noResults', defaultMessage: 'No symbols found' })}
</p>
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-center">
<Hash className="w-10 h-10 text-muted-foreground/30 mb-3" />
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'deepwiki.index.placeholder', defaultMessage: 'Enter a search query to find symbols' })}
</p>
</div>
)}
</Card>
</div>
)}
{/* Tab Content: Statistics */}
{activeTab === 'stats' && (
<div className="flex-1 overflow-y-auto space-y-4">
{statsLoading ? (
<Card className="p-6">
<div className="space-y-4 animate-pulse">
<div className="h-20 bg-muted rounded" />
<div className="grid grid-cols-2 gap-4">
<div className="h-24 bg-muted rounded" />
<div className="h-24 bg-muted rounded" />
<div className="h-24 bg-muted rounded" />
<div className="h-24 bg-muted rounded" />
</div>
</div>
</Card>
) : (
<>
{/* Status card */}
<Card className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className={cn(
'w-3 h-3 rounded-full',
stats?.available ? 'bg-green-500' : 'bg-red-500'
)}
/>
<span className="text-sm font-medium text-foreground">
{stats?.available
? formatMessage({ id: 'deepwiki.stats.available', defaultMessage: 'Database Connected' })
: formatMessage({ id: 'deepwiki.stats.unavailable', defaultMessage: 'Database Not Available' })}
</span>
</div>
{stats?.dbPath && (
<code className="text-xs text-muted-foreground bg-muted px-2 py-1 rounded">
{stats.dbPath}
</code>
)}
</div>
</Card>
{/* Stats grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatsCard
label={formatMessage({ id: 'deepwiki.stats.files', defaultMessage: 'Files' })}
value={stats?.files ?? 0}
icon={FileText}
color="bg-blue-500/10 text-blue-500"
/>
<StatsCard
label={formatMessage({ id: 'deepwiki.stats.symbols', defaultMessage: 'Symbols' })}
value={stats?.symbols ?? 0}
icon={Hash}
color="bg-purple-500/10 text-purple-500"
/>
<StatsCard
label={formatMessage({ id: 'deepwiki.stats.docs', defaultMessage: 'Documents' })}
value={stats?.docs ?? 0}
icon={BookOpen}
color="bg-green-500/10 text-green-500"
/>
<StatsCard
label={formatMessage({ id: 'deepwiki.stats.needingDocs', defaultMessage: 'Need Docs' })}
value={stats?.filesNeedingDocs ?? 0}
icon={BarChart3}
color="bg-amber-500/10 text-amber-500"
/>
</div>
{/* Help text */}
<Card className="p-4">
<h3 className="text-sm font-medium text-foreground mb-2">
{formatMessage({ id: 'deepwiki.stats.howTo.title', defaultMessage: 'How to Generate Documentation' })}
</h3>
<p className="text-sm text-muted-foreground mb-3">
{formatMessage({ id: 'deepwiki.stats.howTo.description', defaultMessage: 'Run the DeepWiki generator from the command line:' })}
</p>
<code className="block p-3 bg-muted rounded-md text-sm font-mono">
codexlens deepwiki generate --path ./src
</code>
</Card>
</>
)}
</div>
)}
</div>
);
}
export default DeepWikiPage;

View File

@@ -24,6 +24,7 @@ import {
import { useAppStore, selectIsImmersiveMode } from '@/stores/appStore';
import { cn } from '@/lib/utils';
import { useHistory } from '@/hooks/useHistory';
import { useNotifications } from '@/hooks/useNotifications';
import { useNativeSessionsInfinite } from '@/hooks/useNativeSessions';
import { ConversationCard } from '@/components/shared/ConversationCard';
import { CliStreamPanel } from '@/components/shared/CliStreamPanel';
@@ -58,6 +59,7 @@ type HistoryTab = 'executions' | 'observability' | 'native-sessions';
*/
export function HistoryPage() {
const { formatMessage } = useIntl();
const { error: showError } = useNotifications();
const [currentTab, setCurrentTab] = React.useState<HistoryTab>('executions');
const [searchQuery, setSearchQuery] = React.useState('');
const [toolFilter, setToolFilter] = React.useState<string | undefined>(undefined);
@@ -184,6 +186,7 @@ export function HistoryPage() {
setDeleteType(null);
setDeleteTarget(null);
} catch (err) {
showError(formatMessage({ id: 'history.deleteFailed' }), err instanceof Error ? err.message : String(err));
console.error('Failed to delete:', err);
}
};

View File

@@ -27,7 +27,7 @@ import { Input } from '@/components/ui/Input';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { HookCard, HookFormDialog, HookQuickTemplates, HookWizard, type HookCardData, type HookFormData, type HookTriggerType, HOOK_TEMPLATES, type WizardType } from '@/components/hook';
import { useHooks, useToggleHook } from '@/hooks';
import { useHooks, useToggleHook, useDeleteHook } from '@/hooks';
import { cn } from '@/lib/utils';
// ========== Types ==========
@@ -154,6 +154,7 @@ export function HookManagerPage() {
const { hooks, enabledCount, totalCount, isLoading, refetch } = useHooks();
const { toggleHook } = useToggleHook();
const { deleteHook } = useDeleteHook();
// Convert hooks to HookCardData and filter by search query and trigger type
const filteredHooks = useMemo(() => {
@@ -199,8 +200,11 @@ export function HookManagerPage() {
};
const handleDeleteClick = async (hookName: string) => {
// This will be implemented when delete API is added
console.log('Delete hook:', hookName);
try {
await deleteHook(hookName);
} catch (error) {
console.error('Failed to delete hook:', error);
}
};
const handleSave = async (data: HookFormData) => {

View File

@@ -27,7 +27,7 @@ import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select';
import { useIssues, useIssueMutations, useIssueQueue } from '@/hooks';
import { useIssues, useIssueMutations, useIssueQueue, useNotifications } from '@/hooks';
import { pullIssuesFromGitHub, uploadAttachments } from '@/lib/api';
import type { Issue } from '@/lib/api';
import { cn } from '@/lib/utils';
@@ -287,6 +287,7 @@ export function IssueHubPage() {
const [searchParams, setSearchParams] = useSearchParams();
const rawTab = searchParams.get('tab') as IssueTab;
const currentTab = VALID_TABS.includes(rawTab) ? rawTab : 'issues';
const { error: showError, success } = useNotifications();
// Redirect invalid tabs to 'issues'
useEffect(() => {
@@ -297,6 +298,7 @@ export function IssueHubPage() {
const [isNewIssueOpen, setIsNewIssueOpen] = useState(false);
const [isGithubSyncing, setIsGithubSyncing] = useState(false);
const [isUploadingAttachments, setIsUploadingAttachments] = useState(false);
// Immersive mode (fullscreen) - hide app chrome
const isImmersiveMode = useAppStore(selectIsImmersiveMode);
@@ -322,14 +324,15 @@ export function IssueHubPage() {
setIsGithubSyncing(true);
try {
const result = await pullIssuesFromGitHub({ state: 'open', limit: 100 });
console.log('GitHub sync result:', result);
success(formatMessage({ id: 'issues.notifications.githubSyncSuccess' }, { count: result.length }));
await refetchIssues();
} catch (error) {
showError(formatMessage({ id: 'issues.notifications.githubSyncFailed' }), error instanceof Error ? error.message : String(error));
console.error('GitHub sync failed:', error);
} finally {
setIsGithubSyncing(false);
}
}, [refetchIssues]);
}, [refetchIssues, success, showError, formatMessage]);
const handleCreateIssue = async (data: { title: string; context?: string; priority?: Issue['priority']; type?: IssueType; attachments?: File[] }) => {
try {
@@ -339,19 +342,26 @@ export function IssueHubPage() {
context: data.context,
priority: data.priority,
});
success(formatMessage({ id: 'issues.notifications.createSuccess' }), newIssue.id);
// Upload attachments if any
if (data.attachments && data.attachments.length > 0 && newIssue.id) {
setIsUploadingAttachments(true);
try {
await uploadAttachments(newIssue.id, data.attachments);
success(formatMessage({ id: 'issues.notifications.attachmentSuccess' }));
} catch (uploadError) {
showError(formatMessage({ id: 'issues.notifications.attachmentFailed' }), uploadError instanceof Error ? uploadError.message : String(uploadError));
console.error('Failed to upload attachments:', uploadError);
// Don't fail the whole operation, just log the error
} finally {
setIsUploadingAttachments(false);
}
}
setIsNewIssueOpen(false);
} catch (error) {
showError(formatMessage({ id: 'issues.notifications.createFailed' }), error instanceof Error ? error.message : String(error));
console.error('Failed to create issue:', error);
}
};
@@ -438,7 +448,7 @@ export function IssueHubPage() {
{currentTab === 'queue' && <QueuePanel />}
{currentTab === 'discovery' && <DiscoveryPanel />}
<NewIssueDialog open={isNewIssueOpen} onOpenChange={setIsNewIssueOpen} onSubmit={handleCreateIssue} isCreating={isCreating} />
<NewIssueDialog open={isNewIssueOpen} onOpenChange={setIsNewIssueOpen} onSubmit={handleCreateIssue} isCreating={isCreating || isUploadingAttachments} />
</div>
);
}

View File

@@ -533,6 +533,7 @@ export function McpManagerPage() {
try {
await updateCcwConfig({ ...currentConfig, enabledTools: updatedTools });
} catch (error) {
notifications.error(formatMessage({ id: 'mcp.actions.toggle.error' }), error instanceof Error ? error.message : String(error));
console.error('Failed to toggle CCW tool:', error);
queryClient.setQueryData(ccwMcpQueryKey, previousConfig);
}
@@ -561,6 +562,7 @@ export function McpManagerPage() {
enableSandbox: currentConfig.enableSandbox,
});
} catch (error) {
notifications.error(formatMessage({ id: 'mcp.actions.update.error' }), error instanceof Error ? error.message : String(error));
console.error('Failed to update CCW config:', error);
queryClient.setQueryData(ccwMcpQueryKey, previousConfig);
}
@@ -584,6 +586,7 @@ export function McpManagerPage() {
await installCcwMcp(scope, scope === 'project' ? projectPath ?? undefined : undefined);
ccwMcpQuery.refetch();
} catch (error) {
notifications.error(formatMessage({ id: 'mcp.actions.install.error' }), error instanceof Error ? error.message : String(error));
console.error('Failed to install CCW MCP to scope:', error);
}
};
@@ -594,6 +597,7 @@ export function McpManagerPage() {
ccwMcpQuery.refetch();
queryClient.invalidateQueries({ queryKey: ['mcpServers'] });
} catch (error) {
notifications.error(formatMessage({ id: 'mcp.actions.uninstall.error' }), error instanceof Error ? error.message : String(error));
console.error('Failed to uninstall CCW MCP from scope:', error);
}
};
@@ -625,6 +629,7 @@ export function McpManagerPage() {
try {
await updateCcwConfigForCodex({ ...currentConfig, enabledTools: updatedTools });
} catch (error) {
notifications.error(formatMessage({ id: 'mcp.actions.toggle.error' }), error instanceof Error ? error.message : String(error));
console.error('Failed to toggle CCW tool (Codex):', error);
queryClient.setQueryData(['ccwMcpConfigCodex'], previousConfig);
}
@@ -643,6 +648,7 @@ export function McpManagerPage() {
try {
await updateCcwConfigForCodex({ ...currentConfig, ...config });
} catch (error) {
notifications.error(formatMessage({ id: 'mcp.actions.update.error' }), error instanceof Error ? error.message : String(error));
console.error('Failed to update CCW config (Codex):', error);
queryClient.setQueryData(['ccwMcpConfigCodex'], previousConfig);
}
@@ -749,6 +755,7 @@ export function McpManagerPage() {
await codexToggleServer(serverName, enabled);
codexQuery.refetch();
} catch (error) {
notifications.error(formatMessage({ id: 'mcp.actions.toggle.error' }), error instanceof Error ? error.message : String(error));
console.error('Failed to toggle Codex MCP server:', error);
}
};

View File

@@ -43,6 +43,7 @@ const TeamPage = lazy(() => import('@/pages/TeamPage').then(m => ({ default: m.T
const TerminalDashboardPage = lazy(() => import('@/pages/TerminalDashboardPage').then(m => ({ default: m.TerminalDashboardPage })));
const AnalysisPage = lazy(() => import('@/pages/AnalysisPage').then(m => ({ default: m.AnalysisPage })));
const SpecsSettingsPage = lazy(() => import('@/pages/SpecsSettingsPage').then(m => ({ default: m.SpecsSettingsPage })));
const DeepWikiPage = lazy(() => import('@/pages/DeepWikiPage').then(m => ({ default: m.DeepWikiPage })));
/**
* Helper to wrap lazy-loaded components with error boundary and suspense
@@ -197,6 +198,10 @@ const routes: RouteObject[] = [
path: 'analysis',
element: withErrorHandling(<AnalysisPage />),
},
{
path: 'deepwiki',
element: withErrorHandling(<DeepWikiPage />),
},
{
path: 'terminal-dashboard',
element: withErrorHandling(<TerminalDashboardPage />),
@@ -263,6 +268,7 @@ export const ROUTES = {
TERMINAL_DASHBOARD: '/terminal-dashboard',
SKILL_HUB: '/skill-hub',
ANALYSIS: '/analysis',
DEEPWIKI: '/deepwiki',
} as const;
export type RoutePath = (typeof ROUTES)[keyof typeof ROUTES];

View File

@@ -0,0 +1,143 @@
/**
* DeepWiki Routes Module
* Handles all DeepWiki documentation API endpoints.
*
* Endpoints:
* - GET /api/deepwiki/files - List all documented files
* - GET /api/deepwiki/doc?path=<filePath> - Get document with symbols
* - GET /api/deepwiki/stats - Get storage statistics
* - GET /api/deepwiki/search?q=<query> - Search symbols
*/
import type { RouteContext } from './types.js';
import { getDeepWikiService } from '../../services/deepwiki-service.js';
/**
* Handle DeepWiki routes
* @returns true if route was handled, false otherwise
*/
export async function handleDeepWikiRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, res } = ctx;
// GET /api/deepwiki/files - List all documented files
if (pathname === '/api/deepwiki/files') {
try {
const service = getDeepWikiService();
// Return empty array if database not available (not an error)
if (!service.isAvailable()) {
console.log('[DeepWiki] Database not available, returning empty files list');
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify([]));
return true;
}
const files = service.listDocumentedFiles();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(files));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('[DeepWiki] Error listing files:', message);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: message }));
}
return true;
}
// GET /api/deepwiki/doc?path=<filePath> - Get document with symbols
if (pathname === '/api/deepwiki/doc') {
const filePath = url.searchParams.get('path');
if (!filePath) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'path parameter is required' }));
return true;
}
try {
const service = getDeepWikiService();
// Return 404 if database not available
if (!service.isAvailable()) {
console.log('[DeepWiki] Database not available');
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'DeepWiki database not available' }));
return true;
}
const doc = service.getDocumentByPath(filePath);
if (!doc) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Document not found', path: filePath }));
return true;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(doc));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('[DeepWiki] Error getting document:', message);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: message }));
}
return true;
}
// GET /api/deepwiki/stats - Get storage statistics
if (pathname === '/api/deepwiki/stats') {
try {
const service = getDeepWikiService();
if (!service.isAvailable()) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ available: false, files: 0, symbols: 0, docs: 0 }));
return true;
}
const stats = service.getStats();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ available: true, ...stats }));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('[DeepWiki] Error getting stats:', message);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: message }));
}
return true;
}
// GET /api/deepwiki/search?q=<query> - Search symbols
if (pathname === '/api/deepwiki/search') {
const query = url.searchParams.get('q');
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
if (!query) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'q parameter is required' }));
return true;
}
try {
const service = getDeepWikiService();
if (!service.isAvailable()) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify([]));
return true;
}
const symbols = service.searchSymbols(query, limit);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(symbols));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('[DeepWiki] Error searching symbols:', message);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: message }));
}
return true;
}
return false;
}

View File

@@ -46,6 +46,7 @@ import { handleTeamRoutes } from './routes/team-routes.js';
import { handleNotificationRoutes } from './routes/notification-routes.js';
import { handleAnalysisRoutes } from './routes/analysis-routes.js';
import { handleSpecRoutes } from './routes/spec-routes.js';
import { handleDeepWikiRoutes } from './routes/deepwiki-routes.js';
// Import WebSocket handling
import { handleWebSocketUpgrade, broadcastToClients, extractSessionIdFromPath } from './websocket.js';

View File

@@ -0,0 +1,266 @@
/**
* DeepWiki Service
* Read-only SQLite service for DeepWiki documentation index.
*
* Connects to codex-lens database at ~/.codexlens/deepwiki_index.db
*/
import { homedir } from 'os';
import { join } from 'path';
import { existsSync } from 'fs';
import Database from 'better-sqlite3';
// Default database path (same as Python DeepWikiStore)
const DEFAULT_DB_PATH = join(homedir(), '.codexlens', 'deepwiki_index.db');
/**
* Symbol information from deepwiki_symbols table
*/
export interface DeepWikiSymbol {
id: number;
name: string;
type: string;
source_file: string;
doc_file: string;
anchor: string;
start_line: number;
end_line: number;
created_at: number | null;
updated_at: number | null;
}
/**
* Document information from deepwiki_docs table
*/
export interface DeepWikiDoc {
id: number;
path: string;
content_hash: string;
symbols: string[];
generated_at: number;
llm_tool: string | null;
}
/**
* File information from deepwiki_files table
*/
export interface DeepWikiFile {
id: number;
path: string;
content_hash: string;
last_indexed: number;
symbols_count: number;
docs_generated: boolean;
}
/**
* Document with symbols for API response
*/
export interface DocumentWithSymbols {
path: string;
symbols: Array<{
name: string;
type: string;
anchor: string;
start_line: number;
end_line: number;
}>;
generated_at: string | null;
llm_tool: string | null;
}
/**
* DeepWiki Service - Read-only SQLite access
*/
export class DeepWikiService {
private dbPath: string;
private db: Database.Database | null = null;
constructor(dbPath: string = DEFAULT_DB_PATH) {
this.dbPath = dbPath;
}
/**
* Get or create database connection
*/
private getConnection(): Database.Database | null {
if (this.db) {
return this.db;
}
// Check if database exists
if (!existsSync(this.dbPath)) {
console.log(`[DeepWiki] Database not found at ${this.dbPath}`);
return null;
}
try {
// Open in read-only mode
this.db = new Database(this.dbPath, { readonly: true, fileMustExist: true });
console.log(`[DeepWiki] Connected to database at ${this.dbPath}`);
return this.db;
} catch (error) {
console.error(`[DeepWiki] Failed to connect to database:`, error);
return null;
}
}
/**
* Close database connection
*/
public close(): void {
if (this.db) {
this.db.close();
this.db = null;
}
}
/**
* Check if database is available
*/
public isAvailable(): boolean {
return existsSync(this.dbPath);
}
/**
* List all documented files (source files with symbols)
* @returns Array of file paths that have documentation
*/
public listDocumentedFiles(): string[] {
const db = this.getConnection();
if (!db) {
return [];
}
try {
// Get distinct source files that have symbols documented
const rows = db.prepare(`
SELECT DISTINCT source_file
FROM deepwiki_symbols
ORDER BY source_file
`).all() as Array<{ source_file: string }>;
return rows.map(row => row.source_file);
} catch (error) {
console.error('[DeepWiki] Error listing documented files:', error);
return [];
}
}
/**
* Get document information by source file path
* @param filePath - Source file path
* @returns Document with symbols or null if not found
*/
public getDocumentByPath(filePath: string): DocumentWithSymbols | null {
const db = this.getConnection();
if (!db) {
return null;
}
try {
// Normalize path (forward slashes)
const normalizedPath = filePath.replace(/\\/g, '/');
// Get symbols for this source file
const symbols = db.prepare(`
SELECT name, type, anchor, start_line, end_line
FROM deepwiki_symbols
WHERE source_file = ?
ORDER BY start_line
`).all(normalizedPath) as Array<{
name: string;
type: string;
anchor: string;
start_line: number;
end_line: number;
}>;
if (symbols.length === 0) {
return null;
}
// Get the doc file path (from first symbol)
const docFile = db.prepare(`
SELECT doc_file FROM deepwiki_symbols WHERE source_file = ? LIMIT 1
`).get(normalizedPath) as { doc_file: string } | undefined;
// Get document metadata if available
let generatedAt: string | null = null;
let llmTool: string | null = null;
if (docFile) {
const doc = db.prepare(`
SELECT generated_at, llm_tool
FROM deepwiki_docs
WHERE path = ?
`).get(docFile.doc_file) as { generated_at: number; llm_tool: string | null } | undefined;
if (doc) {
generatedAt = doc.generated_at ? new Date(doc.generated_at * 1000).toISOString() : null;
llmTool = doc.llm_tool;
}
}
return {
path: normalizedPath,
symbols: symbols.map(s => ({
name: s.name,
type: s.type,
anchor: s.anchor,
start_line: s.start_line,
end_line: s.end_line
})),
generated_at: generatedAt,
llm_tool: llmTool
};
} catch (error) {
console.error('[DeepWiki] Error getting document by path:', error);
return null;
}
}
/**
* Search symbols by name pattern
* @param query - Search query (supports LIKE pattern)
* @param limit - Maximum results
* @returns Array of matching symbols
*/
public searchSymbols(query: string, limit: number = 50): DeepWikiSymbol[] {
const db = this.getConnection();
if (!db) {
return [];
}
try {
const pattern = `%${query}%`;
const rows = db.prepare(`
SELECT id, name, type, source_file, doc_file, anchor, start_line, end_line, created_at, updated_at
FROM deepwiki_symbols
WHERE name LIKE ?
ORDER BY name
LIMIT ?
`).all(pattern, limit) as DeepWikiSymbol[];
return rows;
} catch (error) {
console.error('[DeepWiki] Error searching symbols:', error);
return [];
}
}
}
// Singleton instance
let deepWikiService: DeepWikiService | null = null;
/**
* Get the singleton DeepWiki service instance
*/
export function getDeepWikiService(): DeepWikiService {
if (!deepWikiService) {
deepWikiService = new DeepWikiService();
}
return deepWikiService;
}

103
ccw/src/types/deepwiki.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* DeepWiki Type Definitions
*
* Types for DeepWiki documentation index storage.
* These types mirror the Python Pydantic models in codex-lens.
*/
/**
* A symbol record in the DeepWiki index.
* Maps a code symbol to its generated documentation file and anchor.
*/
export interface DeepWikiSymbol {
/** Database row ID */
id?: number;
/** Symbol name (function, class, etc.) */
name: string;
/** Symbol type (function, class, method, variable) */
type: string;
/** Path to source file containing the symbol */
sourceFile: string;
/** Path to generated documentation file */
docFile: string;
/** HTML anchor ID for linking to specific section */
anchor: string;
/** (start_line, end_line) in source file, 1-based inclusive */
lineRange: [number, number];
/** Record creation timestamp */
createdAt?: string;
/** Record update timestamp */
updatedAt?: string;
}
/**
* A documentation file record in the DeepWiki index.
* Tracks generated documentation files and their associated symbols.
*/
export interface DeepWikiDoc {
/** Database row ID */
id?: number;
/** Path to documentation file */
path: string;
/** SHA256 hash of file content for change detection */
contentHash: string;
/** List of symbol names documented in this file */
symbols: string[];
/** Timestamp when documentation was generated (ISO string) */
generatedAt: string;
/** LLM tool used to generate documentation (gemini/qwen) */
llmTool?: string;
}
/**
* A source file record in the DeepWiki index.
* Tracks indexed source files and their content hashes for incremental updates.
*/
export interface DeepWikiFile {
/** Database row ID */
id?: number;
/** Path to source file */
path: string;
/** SHA256 hash of file content */
contentHash: string;
/** Timestamp when file was last indexed (ISO string) */
lastIndexed: string;
/** Number of symbols indexed from this file */
symbolsCount: number;
/** Whether documentation has been generated */
docsGenerated: boolean;
}
/**
* Storage statistics for DeepWiki index.
*/
export interface DeepWikiStats {
/** Total number of tracked source files */
files: number;
/** Total number of indexed symbols */
symbols: number;
/** Total number of documentation files */
docs: number;
/** Files that need documentation generated */
filesNeedingDocs: number;
/** Path to the database file */
dbPath: string;
}
/**
* Options for listing files in DeepWiki index.
*/
export interface ListFilesOptions {
/** Only return files that need documentation generated */
needsDocs?: boolean;
/** Maximum number of files to return */
limit?: number;
}
/**
* Options for searching symbols.
*/
export interface SearchSymbolsOptions {
/** Maximum number of results to return */
limit?: number;
}