@karmaniverous/jeeves-server 3.0.0-0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.local +13 -0
- package/.env.local.template +13 -0
- package/.tsbuildinfo +1 -0
- package/CHANGELOG.md +450 -0
- package/about.md +82 -0
- package/client/README.md +73 -0
- package/client/eslint.config.js +23 -0
- package/client/index.html +14 -0
- package/client/package-lock.json +5181 -0
- package/client/package.json +60 -0
- package/client/public/vite.svg +1 -0
- package/client/src/App.tsx +22 -0
- package/client/src/components/AccountMenu.tsx +167 -0
- package/client/src/components/ActionDropdown.tsx +120 -0
- package/client/src/components/CodeEditor.tsx +143 -0
- package/client/src/components/CodeViewer.tsx +113 -0
- package/client/src/components/ConfirmDialog.tsx +32 -0
- package/client/src/components/DirectoryRow.tsx +62 -0
- package/client/src/components/DirectoryTable.tsx +42 -0
- package/client/src/components/DownloadDropdown.tsx +116 -0
- package/client/src/components/DriveList.tsx +54 -0
- package/client/src/components/EmbeddedDiagramPanzoom.ts +28 -0
- package/client/src/components/FileContentView.tsx +155 -0
- package/client/src/components/InlineSvgPanzoom.ts +60 -0
- package/client/src/components/LazyDiagram.ts +93 -0
- package/client/src/components/LinkDropdown.tsx +134 -0
- package/client/src/components/MarkdownView.tsx +115 -0
- package/client/src/components/MermaidViewer.tsx +21 -0
- package/client/src/components/PlantUmlViewer.tsx +21 -0
- package/client/src/components/SearchModal.tsx +424 -0
- package/client/src/components/SvgViewer.tsx +107 -0
- package/client/src/components/TabBar.tsx +96 -0
- package/client/src/components/layout/Header.tsx +270 -0
- package/client/src/components/panzoom.ts +203 -0
- package/client/src/components/renderableUtils.ts +15 -0
- package/client/src/components/runner/JobTable.tsx +153 -0
- package/client/src/components/runner/RunHistory.tsx +140 -0
- package/client/src/components/runner/StatsBar.tsx +43 -0
- package/client/src/components/runner/StatusPill.tsx +27 -0
- package/client/src/components/runner/jobTableUtils.ts +65 -0
- package/client/src/components/scrollUtils.ts +39 -0
- package/client/src/components/ui/alert-dialog.tsx +107 -0
- package/client/src/components/ui/button.tsx +40 -0
- package/client/src/components/ui/dropdown-menu.tsx +79 -0
- package/client/src/components/ui/input.tsx +26 -0
- package/client/src/components/useActionState.ts +43 -0
- package/client/src/hooks/useFileBrowser.ts +102 -0
- package/client/src/hooks/useFileData.ts +78 -0
- package/client/src/hooks/useScrollAnchor.ts +70 -0
- package/client/src/hooks/useShareSettings.ts +22 -0
- package/client/src/hooks/useTopBar.ts +27 -0
- package/client/src/index.css +281 -0
- package/client/src/lib/AuthContext.ts +27 -0
- package/client/src/lib/api.ts +239 -0
- package/client/src/lib/auth.tsx +50 -0
- package/client/src/lib/codeBlockCm6.ts +129 -0
- package/client/src/lib/codeBlockCopy.ts +43 -0
- package/client/src/lib/codemirror.ts +77 -0
- package/client/src/lib/runner-api.ts +172 -0
- package/client/src/lib/svg.ts +50 -0
- package/client/src/lib/theme.ts +34 -0
- package/client/src/lib/utils.ts +6 -0
- package/client/src/main.tsx +11 -0
- package/client/src/pages/FileBrowser.tsx +135 -0
- package/client/src/pages/Home.tsx +46 -0
- package/client/src/pages/Runner.tsx +151 -0
- package/client/src/pages/RunnerJob.tsx +170 -0
- package/client/tsconfig.app.json +32 -0
- package/client/tsconfig.json +7 -0
- package/client/tsconfig.node.json +26 -0
- package/client/vite.config.ts +35 -0
- package/content/privacy.md +61 -0
- package/content/terms.md +41 -0
- package/dist/client/assets/CodeEditor-0XHVI8Nu.js +1 -0
- package/dist/client/assets/CodeViewer-CykMVsfX.js +1 -0
- package/dist/client/assets/index--MBieNJA.js +1 -0
- package/dist/client/assets/index-BENeXQI_.js +1 -0
- package/dist/client/assets/index-BbBpoOxz.js +1 -0
- package/dist/client/assets/index-BdV9g5AM.js +6 -0
- package/dist/client/assets/index-BjAilRri.js +2 -0
- package/dist/client/assets/index-BqbhWo2I.js +3 -0
- package/dist/client/assets/index-CVbycZ0H.js +1 -0
- package/dist/client/assets/index-Cs5oz2oJ.js +5 -0
- package/dist/client/assets/index-D8KZVveX.js +1 -0
- package/dist/client/assets/index-DC4HMHxY.js +13 -0
- package/dist/client/assets/index-DbMebkkd.css +1 -0
- package/dist/client/assets/index-DcY2RXqX.js +1 -0
- package/dist/client/assets/index-Duy-tZYV.js +1 -0
- package/dist/client/assets/index-Dw7rDFmE.js +7 -0
- package/dist/client/assets/index-FlCUvrjv.js +2 -0
- package/dist/client/assets/index-K6OVmfhg.js +1 -0
- package/dist/client/assets/index-LjwgzZ7F.js +62 -0
- package/dist/client/assets/index-MLwyFRN0.js +1 -0
- package/dist/client/assets/index-OpqBpSjn.js +1 -0
- package/dist/client/assets/index-SsHei0HE.js +1 -0
- package/dist/client/assets/index-uQa2yckk.js +1 -0
- package/dist/client/assets/index-udkXoIER.js +1 -0
- package/dist/client/index.html +15 -0
- package/dist/client/vite.svg +1 -0
- package/dist/src/auth/google.js +57 -0
- package/dist/src/auth/keys.js +185 -0
- package/dist/src/auth/resolve.js +102 -0
- package/dist/src/auth/session.js +57 -0
- package/dist/src/cli/commands/config.js +100 -0
- package/dist/src/cli/commands/config.test.js +84 -0
- package/dist/src/cli/commands/service.js +93 -0
- package/dist/src/cli/commands/start.js +24 -0
- package/dist/src/cli/index.js +20 -0
- package/dist/src/config/index.js +90 -0
- package/dist/src/config/loadConfig.test.js +127 -0
- package/dist/src/config/resolve.js +134 -0
- package/dist/src/config/resolve.test.js +148 -0
- package/dist/src/config/schema.js +159 -0
- package/dist/src/config/substituteEnvVars.js +45 -0
- package/dist/src/config/substituteEnvVars.test.js +51 -0
- package/dist/src/config/types.js +5 -0
- package/dist/src/routes/api/auth-status.js +56 -0
- package/dist/src/routes/api/diagrams.js +35 -0
- package/dist/src/routes/api/directory.js +93 -0
- package/dist/src/routes/api/drives.js +15 -0
- package/dist/src/routes/api/export.js +218 -0
- package/dist/src/routes/api/fileContent.js +286 -0
- package/dist/src/routes/api/index.js +33 -0
- package/dist/src/routes/api/linkInfo.js +71 -0
- package/dist/src/routes/api/linkInfo.test.js +104 -0
- package/dist/src/routes/api/middleware.js +117 -0
- package/dist/src/routes/api/raw.js +38 -0
- package/dist/src/routes/api/runner.js +59 -0
- package/dist/src/routes/api/search.js +236 -0
- package/dist/src/routes/api/sharing.js +203 -0
- package/dist/src/routes/api/status.js +68 -0
- package/dist/src/routes/api/status.test.js +62 -0
- package/dist/src/routes/auth.js +99 -0
- package/dist/src/routes/event.js +77 -0
- package/dist/src/routes/event.test.js +206 -0
- package/dist/src/routes/health.js +10 -0
- package/dist/src/routes/keys.js +129 -0
- package/dist/src/routes/path/index.js +17 -0
- package/dist/src/routes/static.js +30 -0
- package/dist/src/server.js +90 -0
- package/dist/src/services/deepShareLinks.js +163 -0
- package/dist/src/services/diagramCache.js +104 -0
- package/dist/src/services/embeddedDiagrams.js +136 -0
- package/dist/src/services/eventLog.js +55 -0
- package/dist/src/services/eventLog.test.js +113 -0
- package/dist/src/services/eventQueue.js +154 -0
- package/dist/src/services/eventQueue.test.js +104 -0
- package/dist/src/services/export.js +220 -0
- package/dist/src/services/exportCache.js +196 -0
- package/dist/src/services/markdown.js +147 -0
- package/dist/src/services/mermaid.js +97 -0
- package/dist/src/services/plantuml.js +145 -0
- package/dist/src/services/puppeteer.js +156 -0
- package/dist/src/util/breadcrumbs.js +22 -0
- package/dist/src/util/crypto.js +56 -0
- package/dist/src/util/crypto.test.js +99 -0
- package/dist/src/util/fileDetection.js +66 -0
- package/dist/src/util/fileDetection.test.js +89 -0
- package/dist/src/util/formatters.js +43 -0
- package/dist/src/util/formatters.test.js +83 -0
- package/dist/src/util/packageVersion.js +25 -0
- package/dist/src/util/platform.js +148 -0
- package/dist/src/util/state.js +46 -0
- package/dist/vitest.config.js +12 -0
- package/favicon.svg +3 -0
- package/guides/access-decision-flow.mmd +24 -0
- package/guides/access-decision-flow.svg +1 -0
- package/guides/api-integration.md +236 -0
- package/guides/deployment.md +287 -0
- package/guides/event-gateway.md +204 -0
- package/guides/event-gateway.mmd +17 -0
- package/guides/event-gateway.svg +1 -0
- package/guides/exports.md +239 -0
- package/guides/setup.md +313 -0
- package/guides/sharing.md +204 -0
- package/jeeves-server.config.template.json +25 -0
- package/package.json +124 -0
- package/scripts/download-plantuml.js +70 -0
- package/src/auth/google.ts +93 -0
- package/src/auth/keys.ts +252 -0
- package/src/auth/resolve.ts +157 -0
- package/src/auth/session.ts +77 -0
- package/src/cli/commands/config.test.ts +107 -0
- package/src/cli/commands/config.ts +113 -0
- package/src/cli/commands/service.ts +129 -0
- package/src/cli/commands/start.ts +27 -0
- package/src/cli/index.ts +25 -0
- package/src/config/index.ts +113 -0
- package/src/config/loadConfig.test.ts +155 -0
- package/src/config/resolve.test.ts +192 -0
- package/src/config/resolve.ts +173 -0
- package/src/config/schema.ts +179 -0
- package/src/config/substituteEnvVars.test.ts +64 -0
- package/src/config/substituteEnvVars.ts +52 -0
- package/src/config/types.ts +129 -0
- package/src/routes/api/auth-status.ts +85 -0
- package/src/routes/api/diagrams.ts +53 -0
- package/src/routes/api/directory.ts +123 -0
- package/src/routes/api/drives.ts +23 -0
- package/src/routes/api/export.ts +314 -0
- package/src/routes/api/fileContent.ts +414 -0
- package/src/routes/api/index.ts +37 -0
- package/src/routes/api/linkInfo.test.ts +132 -0
- package/src/routes/api/linkInfo.ts +83 -0
- package/src/routes/api/middleware.ts +156 -0
- package/src/routes/api/raw.ts +54 -0
- package/src/routes/api/runner.ts +107 -0
- package/src/routes/api/search.ts +321 -0
- package/src/routes/api/sharing.ts +259 -0
- package/src/routes/api/status.test.ts +72 -0
- package/src/routes/api/status.ts +82 -0
- package/src/routes/auth.ts +143 -0
- package/src/routes/event.test.ts +248 -0
- package/src/routes/event.ts +109 -0
- package/src/routes/health.ts +13 -0
- package/src/routes/keys.ts +192 -0
- package/src/routes/path/index.ts +24 -0
- package/src/routes/static.ts +54 -0
- package/src/server.ts +104 -0
- package/src/services/deepShareLinks.ts +203 -0
- package/src/services/diagramCache.ts +128 -0
- package/src/services/embeddedDiagrams.ts +168 -0
- package/src/services/eventLog.test.ts +144 -0
- package/src/services/eventLog.ts +68 -0
- package/src/services/eventQueue.test.ts +127 -0
- package/src/services/eventQueue.ts +196 -0
- package/src/services/export.ts +267 -0
- package/src/services/exportCache.ts +216 -0
- package/src/services/markdown.ts +189 -0
- package/src/services/mermaid.ts +113 -0
- package/src/services/plantuml.ts +172 -0
- package/src/services/puppeteer.ts +188 -0
- package/src/types/fastify.d.ts +13 -0
- package/src/types/jsonmap.d.ts +10 -0
- package/src/types/plantuml-encoder.d.ts +4 -0
- package/src/util/breadcrumbs.ts +33 -0
- package/src/util/crypto.test.ts +132 -0
- package/src/util/crypto.ts +79 -0
- package/src/util/fileDetection.test.ts +115 -0
- package/src/util/fileDetection.ts +70 -0
- package/src/util/formatters.test.ts +105 -0
- package/src/util/formatters.ts +44 -0
- package/src/util/packageVersion.ts +30 -0
- package/src/util/platform.ts +178 -0
- package/src/util/state.ts +55 -0
- package/test-docs/diagram-retry-test.md +18 -0
- package/test-docs/embedded-diagrams.md +52 -0
- package/test-docs/lazy-diagrams-test.md +333 -0
- package/test-docs/page-a.md +7 -0
- package/test-docs/page-b.md +7 -0
- package/test-docs/page-c.md +7 -0
- package/test-docs/sub/page-d.md +7 -0
- package/test-docs/test-diagram.puml +13 -0
- package/test-docs/validate-deep-share.js +318 -0
- package/tsconfig.json +37 -0
- package/tsdoc.json +13 -0
- package/vendor/.plantuml-version +1 -0
- package/vendor/plantuml.jar +0 -0
- package/vitest.config.js +12 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useNavigate } from 'react-router-dom';
|
|
3
|
+
import { ChevronDown, ChevronRight, FileText, RotateCcw, Search, X } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
import { fetchFacets, searchDocuments, type SearchFacet, type SearchResult, type SearchMetadata } from '@/lib/api';
|
|
6
|
+
|
|
7
|
+
type DatePreset = '24h' | '7d' | '30d' | 'custom' | null;
|
|
8
|
+
|
|
9
|
+
const DATE_PRESETS: { label: string; value: DatePreset }[] = [
|
|
10
|
+
{ label: '24h', value: '24h' },
|
|
11
|
+
{ label: '7 days', value: '7d' },
|
|
12
|
+
{ label: '30 days', value: '30d' },
|
|
13
|
+
{ label: 'Custom', value: 'custom' },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function getPresetDate(preset: DatePreset): Date | null {
|
|
17
|
+
if (!preset || preset === 'custom') return null;
|
|
18
|
+
const now = new Date();
|
|
19
|
+
if (preset === '24h') return new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
20
|
+
if (preset === '7d') return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
21
|
+
if (preset === '30d') return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface SearchModalProps {
|
|
26
|
+
open: boolean;
|
|
27
|
+
onClose: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function FilterChips({
|
|
31
|
+
label,
|
|
32
|
+
values,
|
|
33
|
+
selected,
|
|
34
|
+
onToggle,
|
|
35
|
+
}: {
|
|
36
|
+
label: string;
|
|
37
|
+
values: string[];
|
|
38
|
+
selected: Set<string>;
|
|
39
|
+
onToggle: (value: string) => void;
|
|
40
|
+
}) {
|
|
41
|
+
if (values.length === 0) return null;
|
|
42
|
+
return (
|
|
43
|
+
<div className="flex items-center gap-1.5 flex-wrap">
|
|
44
|
+
<span className="text-xs text-muted-foreground font-medium">{label}:</span>
|
|
45
|
+
{values.map((v) => (
|
|
46
|
+
<button
|
|
47
|
+
key={v}
|
|
48
|
+
onClick={() => onToggle(v)}
|
|
49
|
+
className={`text-xs px-2 py-0.5 rounded-full border transition-colors ${
|
|
50
|
+
selected.has(v)
|
|
51
|
+
? 'bg-primary text-primary-foreground border-primary'
|
|
52
|
+
: 'bg-muted text-muted-foreground border-border hover:bg-accent'
|
|
53
|
+
}`}
|
|
54
|
+
>
|
|
55
|
+
{v}
|
|
56
|
+
</button>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function ResultRow({ result, onNavigate }: { result: SearchResult; onNavigate: (path: string) => void }) {
|
|
63
|
+
const [expanded, setExpanded] = useState(false);
|
|
64
|
+
const preview = result.chunks[0]?.text ?? '';
|
|
65
|
+
const truncatedPreview = preview.length > 150 ? preview.slice(0, 150) + '…' : preview;
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="border-b border-border last:border-0 px-4 py-2">
|
|
69
|
+
<div className="flex items-start gap-2">
|
|
70
|
+
<FileText className="h-4 w-4 text-zinc-400 shrink-0 mt-1" />
|
|
71
|
+
<div className="min-w-0 flex-1">
|
|
72
|
+
{/* Header row: file name, domain, score, expand toggle */}
|
|
73
|
+
<div className="flex items-center gap-2">
|
|
74
|
+
<button
|
|
75
|
+
onClick={() => onNavigate(`/browse/${result.browsePath}`)}
|
|
76
|
+
className="text-blue-500 hover:underline text-sm font-medium truncate"
|
|
77
|
+
>
|
|
78
|
+
{result.fileName}
|
|
79
|
+
</button>
|
|
80
|
+
{result.domains && result.domains.length > 0 && (
|
|
81
|
+
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground border border-border">
|
|
82
|
+
{result.domains.join(', ')}
|
|
83
|
+
</span>
|
|
84
|
+
)}
|
|
85
|
+
<span className="text-[10px] text-muted-foreground shrink-0">
|
|
86
|
+
{(result.bestScore * 100).toFixed(0)}%
|
|
87
|
+
</span>
|
|
88
|
+
<span className="text-[10px] text-muted-foreground shrink-0">
|
|
89
|
+
{result.chunks.length} chunk{result.chunks.length !== 1 ? 's' : ''}
|
|
90
|
+
</span>
|
|
91
|
+
{result.mtime && (
|
|
92
|
+
<span className="text-[10px] text-muted-foreground shrink-0">
|
|
93
|
+
{new Date(result.mtime).toLocaleDateString()}
|
|
94
|
+
</span>
|
|
95
|
+
)}
|
|
96
|
+
<button
|
|
97
|
+
onClick={() => setExpanded(!expanded)}
|
|
98
|
+
className="ml-auto text-muted-foreground hover:text-foreground shrink-0"
|
|
99
|
+
title={expanded ? 'Collapse chunks' : 'Expand chunks'}
|
|
100
|
+
>
|
|
101
|
+
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
|
102
|
+
</button>
|
|
103
|
+
</div>
|
|
104
|
+
<div className="text-xs text-muted-foreground mt-0.5 break-words leading-relaxed">
|
|
105
|
+
{result.browsePath}
|
|
106
|
+
</div>
|
|
107
|
+
{/* Collapsed: one-line preview */}
|
|
108
|
+
{!expanded && (
|
|
109
|
+
<div className="text-sm text-foreground/70 mt-1 truncate">
|
|
110
|
+
{truncatedPreview}
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
{/* Expanded: scrollable chunk accordion */}
|
|
114
|
+
{expanded && (
|
|
115
|
+
<div className="mt-2 max-h-48 overflow-y-auto border border-border rounded bg-muted/30 divide-y divide-border">
|
|
116
|
+
{result.chunks.map((chunk, i) => (
|
|
117
|
+
<div key={i} className="px-3 py-2 text-sm text-foreground/80 leading-relaxed">
|
|
118
|
+
<span className="text-[10px] text-muted-foreground mr-2">#{chunk.index}</span>
|
|
119
|
+
{chunk.text}
|
|
120
|
+
</div>
|
|
121
|
+
))}
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function SearchModal({ open, onClose }: SearchModalProps) {
|
|
131
|
+
const [query, setQuery] = useState('');
|
|
132
|
+
const [results, setResults] = useState<SearchResult[]>([]);
|
|
133
|
+
const [metadata, setMetadata] = useState<SearchMetadata>({ domains: [], authors: [], participants: [] });
|
|
134
|
+
const [loading, setLoading] = useState(false);
|
|
135
|
+
const [error, setError] = useState<string | null>(null);
|
|
136
|
+
const [domainFilter, setDomainFilter] = useState<Set<string>>(new Set());
|
|
137
|
+
const [authorFilter, setAuthorFilter] = useState<Set<string>>(new Set());
|
|
138
|
+
const [extFilter, setExtFilter] = useState<Set<string>>(new Set());
|
|
139
|
+
const [datePreset, setDatePreset] = useState<DatePreset>(null);
|
|
140
|
+
const [dateFrom, setDateFrom] = useState('');
|
|
141
|
+
const [dateTo, setDateTo] = useState('');
|
|
142
|
+
const [facets, setFacets] = useState<SearchFacet[]>([]);
|
|
143
|
+
const [facetSelections, setFacetSelections] = useState<Record<string, Set<string>>>({});
|
|
144
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
145
|
+
const navigate = useNavigate();
|
|
146
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
147
|
+
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
if (open) {
|
|
150
|
+
setTimeout(() => inputRef.current?.focus(), 50);
|
|
151
|
+
// Fetch facets on modal open
|
|
152
|
+
void fetchFacets()
|
|
153
|
+
.then((res) => setFacets(res.facets))
|
|
154
|
+
.catch(() => setFacets([]));
|
|
155
|
+
}
|
|
156
|
+
}, [open]);
|
|
157
|
+
|
|
158
|
+
const resetSearch = useCallback(() => {
|
|
159
|
+
setQuery('');
|
|
160
|
+
setResults([]);
|
|
161
|
+
setMetadata({ domains: [], authors: [], participants: [] });
|
|
162
|
+
setError(null);
|
|
163
|
+
setDomainFilter(new Set());
|
|
164
|
+
setAuthorFilter(new Set());
|
|
165
|
+
setExtFilter(new Set());
|
|
166
|
+
setDatePreset(null);
|
|
167
|
+
setDateFrom('');
|
|
168
|
+
setDateTo('');
|
|
169
|
+
setFacetSelections({});
|
|
170
|
+
inputRef.current?.focus();
|
|
171
|
+
}, []);
|
|
172
|
+
|
|
173
|
+
const doSearch = useCallback(async (q: string) => {
|
|
174
|
+
if (!q.trim()) {
|
|
175
|
+
setResults([]);
|
|
176
|
+
setMetadata({ domains: [], authors: [], participants: [] });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
setLoading(true);
|
|
180
|
+
setError(null);
|
|
181
|
+
try {
|
|
182
|
+
// Build Qdrant filter from facet selections
|
|
183
|
+
const mustClauses: Record<string, unknown>[] = [];
|
|
184
|
+
for (const [field, selected] of Object.entries(facetSelections)) {
|
|
185
|
+
if (selected.size > 0) {
|
|
186
|
+
mustClauses.push({
|
|
187
|
+
key: field,
|
|
188
|
+
match: { any: [...selected] },
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const filter = mustClauses.length > 0
|
|
193
|
+
? { must: mustClauses }
|
|
194
|
+
: undefined;
|
|
195
|
+
const res = await searchDocuments(q, 30, filter);
|
|
196
|
+
setResults(res.results);
|
|
197
|
+
setMetadata(res.metadata);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
setError(String(err));
|
|
200
|
+
} finally {
|
|
201
|
+
setLoading(false);
|
|
202
|
+
}
|
|
203
|
+
}, [facetSelections]);
|
|
204
|
+
|
|
205
|
+
const handleInputChange = useCallback(
|
|
206
|
+
(value: string) => {
|
|
207
|
+
setQuery(value);
|
|
208
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
209
|
+
debounceRef.current = setTimeout(() => void doSearch(value), 400);
|
|
210
|
+
},
|
|
211
|
+
[doSearch],
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const handleNavigate = useCallback(
|
|
215
|
+
(path: string) => {
|
|
216
|
+
onClose();
|
|
217
|
+
navigate(path);
|
|
218
|
+
},
|
|
219
|
+
[navigate, onClose],
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
const toggleFilter = useCallback(
|
|
223
|
+
(set: Set<string>, setFn: React.Dispatch<React.SetStateAction<Set<string>>>, value: string) => {
|
|
224
|
+
const next = new Set(set);
|
|
225
|
+
if (next.has(value)) next.delete(value);
|
|
226
|
+
else next.add(value);
|
|
227
|
+
setFn(next);
|
|
228
|
+
},
|
|
229
|
+
[],
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const toggleFacet = useCallback(
|
|
233
|
+
(field: string, value: string) => {
|
|
234
|
+
setFacetSelections((prev) => {
|
|
235
|
+
const current = prev[field] ?? new Set<string>();
|
|
236
|
+
const next = new Set(current);
|
|
237
|
+
if (next.has(value)) next.delete(value);
|
|
238
|
+
else next.add(value);
|
|
239
|
+
return { ...prev, [field]: next };
|
|
240
|
+
});
|
|
241
|
+
},
|
|
242
|
+
[],
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
// Re-search when facet selections change
|
|
246
|
+
useEffect(() => {
|
|
247
|
+
if (query.trim()) {
|
|
248
|
+
void doSearch(query);
|
|
249
|
+
}
|
|
250
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
251
|
+
}, [facetSelections]);
|
|
252
|
+
|
|
253
|
+
// Extract extensions from results
|
|
254
|
+
const extensions = [...new Set(results.map((r) => {
|
|
255
|
+
const dot = r.fileName.lastIndexOf('.');
|
|
256
|
+
return dot > 0 ? r.fileName.slice(dot).toLowerCase() : '(none)';
|
|
257
|
+
}))].sort();
|
|
258
|
+
|
|
259
|
+
// Compute effective date range
|
|
260
|
+
const effectiveDateFrom = datePreset && datePreset !== 'custom'
|
|
261
|
+
? getPresetDate(datePreset)
|
|
262
|
+
: dateFrom ? new Date(dateFrom) : null;
|
|
263
|
+
const effectiveDateTo = datePreset === 'custom' && dateTo
|
|
264
|
+
? new Date(dateTo + 'T23:59:59.999Z') : null;
|
|
265
|
+
|
|
266
|
+
// Apply client-side filters
|
|
267
|
+
const filtered = results.filter((r) => {
|
|
268
|
+
if (domainFilter.size > 0 && (!r.domains || !r.domains.some(d => domainFilter.has(d)))) return false;
|
|
269
|
+
if (authorFilter.size > 0 && (!r.author || !authorFilter.has(r.author))) return false;
|
|
270
|
+
if (extFilter.size > 0) {
|
|
271
|
+
const dot = r.fileName.lastIndexOf('.');
|
|
272
|
+
const ext = dot > 0 ? r.fileName.slice(dot).toLowerCase() : '(none)';
|
|
273
|
+
if (!extFilter.has(ext)) return false;
|
|
274
|
+
}
|
|
275
|
+
if (effectiveDateFrom && r.mtime) {
|
|
276
|
+
if (new Date(r.mtime) < effectiveDateFrom) return false;
|
|
277
|
+
}
|
|
278
|
+
if (effectiveDateTo && r.mtime) {
|
|
279
|
+
if (new Date(r.mtime) > effectiveDateTo) return false;
|
|
280
|
+
}
|
|
281
|
+
return true;
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
if (!open) return null;
|
|
285
|
+
|
|
286
|
+
return (
|
|
287
|
+
<div className="fixed inset-0 z-[100] flex items-start justify-center pt-[5vh]" onClick={onClose}>
|
|
288
|
+
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
|
|
289
|
+
<div
|
|
290
|
+
className="relative bg-background border border-border rounded-lg shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col"
|
|
291
|
+
onClick={(e) => e.stopPropagation()}
|
|
292
|
+
>
|
|
293
|
+
{/* Search input */}
|
|
294
|
+
<div className="flex items-center gap-3 px-4 py-3 border-b border-border">
|
|
295
|
+
<Search className="h-5 w-5 text-muted-foreground shrink-0" />
|
|
296
|
+
<input
|
|
297
|
+
ref={inputRef}
|
|
298
|
+
type="text"
|
|
299
|
+
value={query}
|
|
300
|
+
onChange={(e) => handleInputChange(e.target.value)}
|
|
301
|
+
onKeyDown={(e) => { if (e.key === 'Escape') onClose(); }}
|
|
302
|
+
placeholder="Search documents..."
|
|
303
|
+
className="flex-1 bg-transparent outline-none text-foreground placeholder:text-muted-foreground"
|
|
304
|
+
/>
|
|
305
|
+
{loading && <div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />}
|
|
306
|
+
{(query || results.length > 0) && (
|
|
307
|
+
<button onClick={resetSearch} className="text-muted-foreground hover:text-foreground" title="Reset search">
|
|
308
|
+
<RotateCcw className="h-4 w-4" />
|
|
309
|
+
</button>
|
|
310
|
+
)}
|
|
311
|
+
<button onClick={onClose} className="text-muted-foreground hover:text-foreground" title="Close (Esc)">
|
|
312
|
+
<X className="h-5 w-5" />
|
|
313
|
+
</button>
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
{/* Schema-driven facet filters */}
|
|
317
|
+
{facets.length > 0 && (
|
|
318
|
+
<div className="px-4 py-2 border-b border-border flex flex-col gap-1.5">
|
|
319
|
+
{facets
|
|
320
|
+
.filter((f) => f.values.length > 0 && f.uiHint !== 'hidden')
|
|
321
|
+
.map((f) => (
|
|
322
|
+
<FilterChips
|
|
323
|
+
key={f.field}
|
|
324
|
+
label={f.field.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())}
|
|
325
|
+
values={f.values}
|
|
326
|
+
selected={facetSelections[f.field] ?? new Set()}
|
|
327
|
+
onToggle={(v) => toggleFacet(f.field, v)}
|
|
328
|
+
/>
|
|
329
|
+
))}
|
|
330
|
+
</div>
|
|
331
|
+
)}
|
|
332
|
+
|
|
333
|
+
{/* Post-hoc filter chips — Type always shown when results exist */}
|
|
334
|
+
{results.length > 0 && (
|
|
335
|
+
<div className="px-4 py-2 border-b border-border flex flex-col gap-1.5">
|
|
336
|
+
<FilterChips
|
|
337
|
+
label="Type"
|
|
338
|
+
values={extensions}
|
|
339
|
+
selected={extFilter}
|
|
340
|
+
onToggle={(v) => toggleFilter(extFilter, setExtFilter, v)}
|
|
341
|
+
/>
|
|
342
|
+
<FilterChips
|
|
343
|
+
label="Domain"
|
|
344
|
+
values={metadata.domains}
|
|
345
|
+
selected={domainFilter}
|
|
346
|
+
onToggle={(v) => toggleFilter(domainFilter, setDomainFilter, v)}
|
|
347
|
+
/>
|
|
348
|
+
<FilterChips
|
|
349
|
+
label="Author"
|
|
350
|
+
values={metadata.authors}
|
|
351
|
+
selected={authorFilter}
|
|
352
|
+
onToggle={(v) => toggleFilter(authorFilter, setAuthorFilter, v)}
|
|
353
|
+
/>
|
|
354
|
+
{/* Date range filter */}
|
|
355
|
+
<div className="flex items-center gap-1.5 flex-wrap">
|
|
356
|
+
<span className="text-xs text-muted-foreground font-medium">Date:</span>
|
|
357
|
+
{DATE_PRESETS.map((p) => (
|
|
358
|
+
<button
|
|
359
|
+
key={p.value}
|
|
360
|
+
onClick={() => setDatePreset(datePreset === p.value ? null : p.value)}
|
|
361
|
+
className={`text-xs px-2 py-0.5 rounded-full border transition-colors ${
|
|
362
|
+
datePreset === p.value
|
|
363
|
+
? 'bg-primary text-primary-foreground border-primary'
|
|
364
|
+
: 'bg-muted text-muted-foreground border-border hover:bg-accent'
|
|
365
|
+
}`}
|
|
366
|
+
>
|
|
367
|
+
{p.label}
|
|
368
|
+
</button>
|
|
369
|
+
))}
|
|
370
|
+
{datePreset === 'custom' && (
|
|
371
|
+
<>
|
|
372
|
+
<input
|
|
373
|
+
type="month"
|
|
374
|
+
value={dateFrom}
|
|
375
|
+
onChange={(e) => setDateFrom(e.target.value ? e.target.value + '-01' : '')}
|
|
376
|
+
className="text-xs px-1.5 py-0.5 rounded border border-border bg-muted text-foreground w-28"
|
|
377
|
+
placeholder="From"
|
|
378
|
+
/>
|
|
379
|
+
<span className="text-xs text-muted-foreground">to</span>
|
|
380
|
+
<input
|
|
381
|
+
type="month"
|
|
382
|
+
value={dateTo}
|
|
383
|
+
onChange={(e) => setDateTo(e.target.value ? e.target.value + '-28' : '')}
|
|
384
|
+
className="text-xs px-1.5 py-0.5 rounded border border-border bg-muted text-foreground w-28"
|
|
385
|
+
placeholder="To"
|
|
386
|
+
/>
|
|
387
|
+
</>
|
|
388
|
+
)}
|
|
389
|
+
</div>
|
|
390
|
+
</div>
|
|
391
|
+
)}
|
|
392
|
+
|
|
393
|
+
{/* Results */}
|
|
394
|
+
<div className="overflow-y-auto flex-1">
|
|
395
|
+
{error && (
|
|
396
|
+
<div className="px-4 py-3 text-sm text-red-500">{error}</div>
|
|
397
|
+
)}
|
|
398
|
+
{!error && filtered.length === 0 && query.trim() && !loading && (
|
|
399
|
+
<div className="px-4 py-8 text-center text-muted-foreground text-sm">
|
|
400
|
+
No results found
|
|
401
|
+
</div>
|
|
402
|
+
)}
|
|
403
|
+
{!error && !query.trim() && !loading && (
|
|
404
|
+
<div className="px-4 py-8 text-center text-muted-foreground text-sm">
|
|
405
|
+
Type a query to search across all documents
|
|
406
|
+
</div>
|
|
407
|
+
)}
|
|
408
|
+
{filtered.map((r) => (
|
|
409
|
+
<ResultRow key={r.browsePath} result={r} onNavigate={handleNavigate} />
|
|
410
|
+
))}
|
|
411
|
+
</div>
|
|
412
|
+
|
|
413
|
+
{/* Footer */}
|
|
414
|
+
{filtered.length > 0 && (
|
|
415
|
+
<div className="px-4 py-2 border-t border-border text-xs text-muted-foreground">
|
|
416
|
+
{filtered.length} result{filtered.length !== 1 ? 's' : ''}
|
|
417
|
+
{filtered.length < results.length && ` (${results.length} total, ${results.length - filtered.length} filtered)`}
|
|
418
|
+
</div>
|
|
419
|
+
)}
|
|
420
|
+
</div>
|
|
421
|
+
</div>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import Panzoom from '@panzoom/panzoom';
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { normalizeSvg } from '@/lib/svg';
|
|
4
|
+
|
|
5
|
+
interface SvgViewerProps {
|
|
6
|
+
content: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Pre-process SVG for the full-page viewer.
|
|
11
|
+
* Uses shared normalization then adds viewer-specific overrides.
|
|
12
|
+
*/
|
|
13
|
+
function prepareSvgContent(raw: string): string {
|
|
14
|
+
// Use shared normalization (viewBox, preserveAspectRatio, strip sizing)
|
|
15
|
+
const normalized = normalizeSvg(raw);
|
|
16
|
+
const parser = new DOMParser();
|
|
17
|
+
const doc = parser.parseFromString(normalized, 'image/svg+xml');
|
|
18
|
+
const svg = doc.querySelector('svg');
|
|
19
|
+
if (!svg) return normalized;
|
|
20
|
+
|
|
21
|
+
// Viewer-specific: fill container in both dimensions
|
|
22
|
+
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
|
23
|
+
svg.setAttribute('height', '100%');
|
|
24
|
+
svg.style.display = 'block';
|
|
25
|
+
|
|
26
|
+
return new XMLSerializer().serializeToString(doc);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function SvgViewer({ content }: SvgViewerProps) {
|
|
30
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
31
|
+
const innerRef = useRef<HTMLDivElement>(null);
|
|
32
|
+
const panzoomRef = useRef<ReturnType<typeof Panzoom> | null>(null);
|
|
33
|
+
const wheelCleanupRef = useRef<(() => void) | null>(null);
|
|
34
|
+
const [ready, setReady] = useState(false);
|
|
35
|
+
|
|
36
|
+
const html = useMemo(() => prepareSvgContent(content), [content]);
|
|
37
|
+
|
|
38
|
+
const initPanzoom = useCallback(() => {
|
|
39
|
+
// Clean up previous instance
|
|
40
|
+
wheelCleanupRef.current?.();
|
|
41
|
+
wheelCleanupRef.current = null;
|
|
42
|
+
if (panzoomRef.current) {
|
|
43
|
+
panzoomRef.current.destroy();
|
|
44
|
+
panzoomRef.current = null;
|
|
45
|
+
}
|
|
46
|
+
if (!innerRef.current || !containerRef.current) return;
|
|
47
|
+
|
|
48
|
+
const container = containerRef.current;
|
|
49
|
+
const inner = innerRef.current;
|
|
50
|
+
const cw = container.clientWidth;
|
|
51
|
+
const ch = container.clientHeight;
|
|
52
|
+
|
|
53
|
+
if (cw === 0 || ch === 0) return;
|
|
54
|
+
|
|
55
|
+
// Inner div fills the container; the SVG inside zoom-to-fits via
|
|
56
|
+
// viewBox + preserveAspectRatio. Panzoom then handles user zoom/pan
|
|
57
|
+
// starting from this natural fit.
|
|
58
|
+
inner.style.width = `${cw}px`;
|
|
59
|
+
inner.style.height = `${ch}px`;
|
|
60
|
+
|
|
61
|
+
const pz = Panzoom(inner, {
|
|
62
|
+
maxScale: 20,
|
|
63
|
+
minScale: 0.1,
|
|
64
|
+
startScale: 1,
|
|
65
|
+
});
|
|
66
|
+
panzoomRef.current = pz;
|
|
67
|
+
|
|
68
|
+
const wheelHandler = (e: WheelEvent) => {
|
|
69
|
+
pz.zoomWithWheel(e);
|
|
70
|
+
};
|
|
71
|
+
container.addEventListener('wheel', wheelHandler, { passive: false });
|
|
72
|
+
wheelCleanupRef.current = () => container.removeEventListener('wheel', wheelHandler);
|
|
73
|
+
|
|
74
|
+
setReady(true);
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const container = containerRef.current;
|
|
79
|
+
if (!container) return;
|
|
80
|
+
|
|
81
|
+
const observer = new ResizeObserver(() => {
|
|
82
|
+
initPanzoom();
|
|
83
|
+
});
|
|
84
|
+
observer.observe(container);
|
|
85
|
+
|
|
86
|
+
return () => {
|
|
87
|
+
observer.disconnect();
|
|
88
|
+
wheelCleanupRef.current?.();
|
|
89
|
+
panzoomRef.current?.destroy();
|
|
90
|
+
};
|
|
91
|
+
}, [html, initPanzoom]);
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="relative bg-white rounded-lg border border-border overflow-hidden">
|
|
95
|
+
<div
|
|
96
|
+
ref={containerRef}
|
|
97
|
+
className="overflow-hidden cursor-grab active:cursor-grabbing w-full h-[calc(100vh-8rem)]"
|
|
98
|
+
>
|
|
99
|
+
<div
|
|
100
|
+
ref={innerRef}
|
|
101
|
+
className={ready ? '' : 'invisible'}
|
|
102
|
+
dangerouslySetInnerHTML={{ __html: html }}
|
|
103
|
+
/>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tab bar for file views — Rendered/Raw tabs, prose width toggle, and edit button.
|
|
3
|
+
*/
|
|
4
|
+
import { Menu, Minus, Minimize2, Maximize2, Pencil, X } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
import type { FileContent } from '@/lib/api';
|
|
7
|
+
import { isRenderable, isRenderableExt } from './renderableUtils';
|
|
8
|
+
|
|
9
|
+
interface TabBarProps {
|
|
10
|
+
reqPath: string;
|
|
11
|
+
file: FileContent | null;
|
|
12
|
+
fileRendered: FileContent | null;
|
|
13
|
+
viewTab: 'rendered' | 'raw';
|
|
14
|
+
setViewTab: (tab: 'rendered' | 'raw') => void;
|
|
15
|
+
proseWidth: 'narrow' | 'medium' | 'wide';
|
|
16
|
+
toggleProseWidth: (w: 'narrow' | 'medium' | 'wide') => void;
|
|
17
|
+
isInsider: boolean;
|
|
18
|
+
editing: boolean;
|
|
19
|
+
setEditing: (editing: boolean) => void;
|
|
20
|
+
mobileTocOpen: boolean;
|
|
21
|
+
setMobileTocOpen: (open: boolean) => void;
|
|
22
|
+
loading: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function TabBar({
|
|
26
|
+
reqPath, file, fileRendered, viewTab, setViewTab,
|
|
27
|
+
proseWidth, toggleProseWidth,
|
|
28
|
+
isInsider, editing, setEditing,
|
|
29
|
+
mobileTocOpen, setMobileTocOpen, loading,
|
|
30
|
+
}: TabBarProps) {
|
|
31
|
+
if (!file && !loading) return null;
|
|
32
|
+
|
|
33
|
+
const renderable = file ? isRenderable(file) : isRenderableExt(reqPath);
|
|
34
|
+
const activeTab = renderable ? viewTab : 'raw';
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="flex items-center gap-1 border-b border-border bg-background px-4 md:px-6">
|
|
38
|
+
{fileRendered?.headings && fileRendered.headings.length > 2 && (
|
|
39
|
+
<button
|
|
40
|
+
onClick={() => setMobileTocOpen(!mobileTocOpen)}
|
|
41
|
+
className="lg:hidden p-1.5 mr-1 text-muted-foreground hover:text-foreground transition-colors"
|
|
42
|
+
title="Table of contents"
|
|
43
|
+
>
|
|
44
|
+
{mobileTocOpen ? <X className="h-4 w-4" /> : <Menu className="h-4 w-4" />}
|
|
45
|
+
</button>
|
|
46
|
+
)}
|
|
47
|
+
{renderable && (
|
|
48
|
+
<button
|
|
49
|
+
onClick={() => setViewTab('rendered')}
|
|
50
|
+
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
|
51
|
+
activeTab === 'rendered'
|
|
52
|
+
? 'border-blue-500 text-blue-500'
|
|
53
|
+
: 'border-transparent text-muted-foreground hover:text-foreground'
|
|
54
|
+
}`}
|
|
55
|
+
>
|
|
56
|
+
Rendered
|
|
57
|
+
</button>
|
|
58
|
+
)}
|
|
59
|
+
<button
|
|
60
|
+
onClick={() => setViewTab('raw')}
|
|
61
|
+
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
|
62
|
+
activeTab === 'raw'
|
|
63
|
+
? 'border-blue-500 text-blue-500'
|
|
64
|
+
: 'border-transparent text-muted-foreground hover:text-foreground'
|
|
65
|
+
}`}
|
|
66
|
+
>
|
|
67
|
+
Raw
|
|
68
|
+
</button>
|
|
69
|
+
{file?.type === 'markdown' && activeTab === 'rendered' && (
|
|
70
|
+
<div className="hidden md:flex items-center ml-2 border border-border rounded-md overflow-hidden">
|
|
71
|
+
{(['narrow', 'medium', 'wide'] as const).map((w) => (
|
|
72
|
+
<button
|
|
73
|
+
key={w}
|
|
74
|
+
onClick={() => toggleProseWidth(w)}
|
|
75
|
+
className={`p-1.5 transition-colors ${
|
|
76
|
+
proseWidth === w ? 'bg-muted text-foreground' : 'text-muted-foreground hover:text-foreground'
|
|
77
|
+
}`}
|
|
78
|
+
title={`${w.charAt(0).toUpperCase() + w.slice(1)} width`}
|
|
79
|
+
>
|
|
80
|
+
{w === 'narrow' ? <Minimize2 className="h-3.5 w-3.5" /> : w === 'medium' ? <Minus className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
|
81
|
+
</button>
|
|
82
|
+
))}
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
85
|
+
{isInsider && activeTab === 'raw' && file?.content != null && !editing && (
|
|
86
|
+
<button
|
|
87
|
+
onClick={() => setEditing(true)}
|
|
88
|
+
className="ml-2 flex items-center gap-1 px-2 py-1 text-sm text-muted-foreground hover:text-foreground border border-border rounded transition-colors"
|
|
89
|
+
title="Edit file"
|
|
90
|
+
>
|
|
91
|
+
<Pencil className="h-3.5 w-3.5" /> Edit
|
|
92
|
+
</button>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|