mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-06 16:31:12 +08:00
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:
@@ -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);
|
||||
|
||||
329
ccw/frontend/src/components/deepwiki/DocumentViewer.tsx
Normal file
329
ccw/frontend/src/components/deepwiki/DocumentViewer.tsx
Normal 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;
|
||||
210
ccw/frontend/src/components/deepwiki/FileList.tsx
Normal file
210
ccw/frontend/src/components/deepwiki/FileList.tsx
Normal 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;
|
||||
@@ -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]">
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
80
ccw/frontend/src/hooks/__tests__/useCommands.ux.test.ts
Normal file
80
ccw/frontend/src/hooks/__tests__/useCommands.ux.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
250
ccw/frontend/src/hooks/__tests__/useNotifications.ux.test.ts
Normal file
250
ccw/frontend/src/hooks/__tests__/useNotifications.ux.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
// ========================================
|
||||
|
||||
272
ccw/frontend/src/hooks/useDeepWiki.ts
Normal file
272
ccw/frontend/src/hooks/useDeepWiki.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
334
ccw/frontend/src/pages/DeepWikiPage.tsx
Normal file
334
ccw/frontend/src/pages/DeepWikiPage.tsx
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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];
|
||||
|
||||
143
ccw/src/core/routes/deepwiki-routes.ts
Normal file
143
ccw/src/core/routes/deepwiki-routes.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
266
ccw/src/services/deepwiki-service.ts
Normal file
266
ccw/src/services/deepwiki-service.ts
Normal 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
103
ccw/src/types/deepwiki.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user