create-interview-cockpit 0.1.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/README.md +62 -0
- package/index.js +302 -0
- package/package.json +44 -0
- package/template/.env.example +14 -0
- package/template/client/index.html +12 -0
- package/template/client/package-lock.json +6012 -0
- package/template/client/package.json +34 -0
- package/template/client/postcss.config.cjs +6 -0
- package/template/client/src/App.tsx +120 -0
- package/template/client/src/api.ts +132 -0
- package/template/client/src/components/AnnotationDialog.tsx +307 -0
- package/template/client/src/components/ChatMessage.tsx +89 -0
- package/template/client/src/components/ChatView.tsx +763 -0
- package/template/client/src/components/CodeContextPanel.tsx +470 -0
- package/template/client/src/components/FileAttachments.tsx +107 -0
- package/template/client/src/components/FileViewerModal.tsx +470 -0
- package/template/client/src/components/MarkdownRenderer.tsx +333 -0
- package/template/client/src/components/MermaidDiagram.tsx +157 -0
- package/template/client/src/components/Sidebar.tsx +419 -0
- package/template/client/src/components/TextAnnotator.tsx +476 -0
- package/template/client/src/index.css +61 -0
- package/template/client/src/main.tsx +10 -0
- package/template/client/src/store.ts +321 -0
- package/template/client/src/types.ts +65 -0
- package/template/client/src/vite-env.d.ts +1 -0
- package/template/client/tailwind.config.cjs +8 -0
- package/template/client/tsconfig.json +16 -0
- package/template/client/tsconfig.tsbuildinfo +1 -0
- package/template/client/vite.config.ts +12 -0
- package/template/cockpit.json +3 -0
- package/template/data/context-files/.gitkeep +0 -0
- package/template/data/questions/.gitkeep +0 -0
- package/template/data/topics.json +1 -0
- package/template/package.json +14 -0
- package/template/server/package-lock.json +2266 -0
- package/template/server/package.json +31 -0
- package/template/server/src/index.ts +758 -0
- package/template/server/src/storage.ts +303 -0
- package/template/server/tsconfig.json +14 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import { useEffect, useState, useCallback } from "react";
|
|
2
|
+
import { useStore } from "../store";
|
|
3
|
+
import FileViewerModal from "./FileViewerModal";
|
|
4
|
+
import {
|
|
5
|
+
File,
|
|
6
|
+
Search,
|
|
7
|
+
FolderOpen,
|
|
8
|
+
Folder,
|
|
9
|
+
Check,
|
|
10
|
+
X,
|
|
11
|
+
ChevronRight,
|
|
12
|
+
ChevronDown,
|
|
13
|
+
Square,
|
|
14
|
+
CheckSquare,
|
|
15
|
+
MinusSquare,
|
|
16
|
+
Eye,
|
|
17
|
+
Scissors,
|
|
18
|
+
} from "lucide-react";
|
|
19
|
+
|
|
20
|
+
// ─── Tree data structure ─────────────────────────────────
|
|
21
|
+
|
|
22
|
+
interface TreeNode {
|
|
23
|
+
name: string;
|
|
24
|
+
path: string; // full relative path for folders (e.g. "client/src/app")
|
|
25
|
+
children: TreeNode[];
|
|
26
|
+
files: string[]; // full file paths that are direct children
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function buildTree(paths: string[]): TreeNode {
|
|
30
|
+
const root: TreeNode = { name: "", path: "", children: [], files: [] };
|
|
31
|
+
|
|
32
|
+
for (const filePath of paths) {
|
|
33
|
+
const parts = filePath.split("/");
|
|
34
|
+
let node = root;
|
|
35
|
+
|
|
36
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
37
|
+
const folderName = parts[i];
|
|
38
|
+
const folderPath = parts.slice(0, i + 1).join("/");
|
|
39
|
+
let child = node.children.find((c) => c.name === folderName);
|
|
40
|
+
if (!child) {
|
|
41
|
+
child = { name: folderName, path: folderPath, children: [], files: [] };
|
|
42
|
+
node.children.push(child);
|
|
43
|
+
}
|
|
44
|
+
node = child;
|
|
45
|
+
}
|
|
46
|
+
node.files.push(filePath);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return root;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Get all file paths under a tree node recursively */
|
|
53
|
+
function getAllFiles(node: TreeNode): string[] {
|
|
54
|
+
const result = [...node.files];
|
|
55
|
+
for (const child of node.children) {
|
|
56
|
+
result.push(...getAllFiles(child));
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── Folder node component ──────────────────────────────
|
|
62
|
+
|
|
63
|
+
interface FolderNodeProps {
|
|
64
|
+
node: TreeNode;
|
|
65
|
+
selectedFiles: string[];
|
|
66
|
+
onToggleFile: (path: string) => void;
|
|
67
|
+
onToggleFolder: (paths: string[]) => void;
|
|
68
|
+
expandedFolders: Set<string>;
|
|
69
|
+
onToggleExpand: (path: string) => void;
|
|
70
|
+
depth: number;
|
|
71
|
+
filter: string;
|
|
72
|
+
onOpenFile: (path: string) => void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function FolderNode({
|
|
76
|
+
node,
|
|
77
|
+
selectedFiles,
|
|
78
|
+
onToggleFile,
|
|
79
|
+
onToggleFolder,
|
|
80
|
+
expandedFolders,
|
|
81
|
+
onToggleExpand,
|
|
82
|
+
depth,
|
|
83
|
+
filter,
|
|
84
|
+
onOpenFile,
|
|
85
|
+
}: FolderNodeProps) {
|
|
86
|
+
const allFiles = getAllFiles(node);
|
|
87
|
+
const selectedCount = allFiles.filter((f) =>
|
|
88
|
+
selectedFiles.includes(f),
|
|
89
|
+
).length;
|
|
90
|
+
const isExpanded = expandedFolders.has(node.path);
|
|
91
|
+
|
|
92
|
+
// Filter: if searching, only show nodes that have matching files
|
|
93
|
+
const matchingFiles = filter
|
|
94
|
+
? node.files.filter((f) => f.toLowerCase().includes(filter))
|
|
95
|
+
: node.files;
|
|
96
|
+
const hasMatchingDescendants = filter
|
|
97
|
+
? allFiles.some((f) => f.toLowerCase().includes(filter))
|
|
98
|
+
: true;
|
|
99
|
+
|
|
100
|
+
if (filter && !hasMatchingDescendants) return null;
|
|
101
|
+
|
|
102
|
+
const checkState: "none" | "some" | "all" =
|
|
103
|
+
selectedCount === 0
|
|
104
|
+
? "none"
|
|
105
|
+
: selectedCount === allFiles.length
|
|
106
|
+
? "all"
|
|
107
|
+
: "some";
|
|
108
|
+
|
|
109
|
+
const CheckIcon =
|
|
110
|
+
checkState === "all"
|
|
111
|
+
? CheckSquare
|
|
112
|
+
: checkState === "some"
|
|
113
|
+
? MinusSquare
|
|
114
|
+
: Square;
|
|
115
|
+
const checkColor = checkState === "none" ? "text-slate-600" : "text-cyan-400";
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div>
|
|
119
|
+
<div
|
|
120
|
+
className="flex items-center gap-0.5 py-0.5 cursor-pointer group"
|
|
121
|
+
style={{ paddingLeft: `${depth * 12 + 4}px` }}
|
|
122
|
+
>
|
|
123
|
+
<button
|
|
124
|
+
onClick={() => onToggleExpand(node.path)}
|
|
125
|
+
className="shrink-0 text-slate-500 hover:text-slate-300"
|
|
126
|
+
>
|
|
127
|
+
{isExpanded ? (
|
|
128
|
+
<ChevronDown className="w-3 h-3" />
|
|
129
|
+
) : (
|
|
130
|
+
<ChevronRight className="w-3 h-3" />
|
|
131
|
+
)}
|
|
132
|
+
</button>
|
|
133
|
+
<button
|
|
134
|
+
onClick={() => onToggleFolder(allFiles)}
|
|
135
|
+
className={`shrink-0 ${checkColor} hover:text-cyan-300`}
|
|
136
|
+
>
|
|
137
|
+
<CheckIcon className="w-3.5 h-3.5" />
|
|
138
|
+
</button>
|
|
139
|
+
{isExpanded ? (
|
|
140
|
+
<FolderOpen className="w-3 h-3 text-amber-500/70 shrink-0" />
|
|
141
|
+
) : (
|
|
142
|
+
<Folder className="w-3 h-3 text-amber-500/70 shrink-0" />
|
|
143
|
+
)}
|
|
144
|
+
<button
|
|
145
|
+
onClick={() => onToggleExpand(node.path)}
|
|
146
|
+
className="text-xs text-slate-400 hover:text-slate-200 truncate text-left flex-1"
|
|
147
|
+
>
|
|
148
|
+
{node.name}
|
|
149
|
+
</button>
|
|
150
|
+
{selectedCount > 0 && (
|
|
151
|
+
<span className="text-[10px] text-cyan-500/70 shrink-0 mr-1">
|
|
152
|
+
{selectedCount}
|
|
153
|
+
</span>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
{isExpanded && (
|
|
158
|
+
<div>
|
|
159
|
+
{node.children
|
|
160
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
161
|
+
.map((child) => (
|
|
162
|
+
<FolderNode
|
|
163
|
+
key={child.path}
|
|
164
|
+
node={child}
|
|
165
|
+
selectedFiles={selectedFiles}
|
|
166
|
+
onToggleFile={onToggleFile}
|
|
167
|
+
onToggleFolder={onToggleFolder}
|
|
168
|
+
expandedFolders={expandedFolders}
|
|
169
|
+
onToggleExpand={onToggleExpand}
|
|
170
|
+
depth={depth + 1}
|
|
171
|
+
filter={filter}
|
|
172
|
+
onOpenFile={onOpenFile}
|
|
173
|
+
/>
|
|
174
|
+
))}
|
|
175
|
+
{matchingFiles
|
|
176
|
+
.sort((a, b) => a.localeCompare(b))
|
|
177
|
+
.map((filePath) => {
|
|
178
|
+
const isSelected = selectedFiles.includes(filePath);
|
|
179
|
+
const fileName = filePath.split("/").pop()!;
|
|
180
|
+
return (
|
|
181
|
+
<div
|
|
182
|
+
key={filePath}
|
|
183
|
+
className={`flex items-center py-0.5 group ${
|
|
184
|
+
isSelected ? "text-cyan-400" : "text-slate-500"
|
|
185
|
+
}`}
|
|
186
|
+
style={{ paddingLeft: `${(depth + 1) * 12 + 4 + 14}px` }}
|
|
187
|
+
>
|
|
188
|
+
<button
|
|
189
|
+
onClick={() => onToggleFile(filePath)}
|
|
190
|
+
className="flex items-center gap-1 flex-1 min-w-0 hover:text-slate-300 transition-colors text-xs text-left"
|
|
191
|
+
>
|
|
192
|
+
<File className="w-3 h-3 shrink-0" />
|
|
193
|
+
<span className="truncate">{fileName}</span>
|
|
194
|
+
</button>
|
|
195
|
+
<button
|
|
196
|
+
onClick={() => onOpenFile(filePath)}
|
|
197
|
+
className="shrink-0 mr-1 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-cyan-400 transition-all"
|
|
198
|
+
title="View file"
|
|
199
|
+
>
|
|
200
|
+
<Eye className="w-3 h-3" />
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
);
|
|
204
|
+
})}
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ─── Main panel ─────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
export default function CodeContextPanel() {
|
|
214
|
+
const {
|
|
215
|
+
availableFiles,
|
|
216
|
+
currentQuestion,
|
|
217
|
+
fetchAvailableFiles,
|
|
218
|
+
updateCodeContext,
|
|
219
|
+
codeSnippets,
|
|
220
|
+
removeSnippet,
|
|
221
|
+
clearSnippets,
|
|
222
|
+
} = useStore();
|
|
223
|
+
|
|
224
|
+
const [search, setSearch] = useState("");
|
|
225
|
+
const [selectedFiles, setSelectedFiles] = useState<string[]>(
|
|
226
|
+
currentQuestion?.codeContextFiles || [],
|
|
227
|
+
);
|
|
228
|
+
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
|
|
229
|
+
new Set(),
|
|
230
|
+
);
|
|
231
|
+
const [viewingFile, setViewingFile] = useState<string | null>(null);
|
|
232
|
+
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
fetchAvailableFiles();
|
|
235
|
+
}, []);
|
|
236
|
+
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
setSelectedFiles(currentQuestion?.codeContextFiles || []);
|
|
239
|
+
}, [currentQuestion?.id]);
|
|
240
|
+
|
|
241
|
+
const tree = buildTree(availableFiles);
|
|
242
|
+
|
|
243
|
+
const toggleFile = useCallback(
|
|
244
|
+
(filePath: string) => {
|
|
245
|
+
setSelectedFiles((prev) => {
|
|
246
|
+
const next = prev.includes(filePath)
|
|
247
|
+
? prev.filter((f) => f !== filePath)
|
|
248
|
+
: [...prev, filePath];
|
|
249
|
+
if (currentQuestion) updateCodeContext(currentQuestion.id, next);
|
|
250
|
+
return next;
|
|
251
|
+
});
|
|
252
|
+
},
|
|
253
|
+
[currentQuestion, updateCodeContext],
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const toggleFolder = useCallback(
|
|
257
|
+
(folderFiles: string[]) => {
|
|
258
|
+
setSelectedFiles((prev) => {
|
|
259
|
+
const allSelected = folderFiles.every((f) => prev.includes(f));
|
|
260
|
+
const next = allSelected
|
|
261
|
+
? prev.filter((f) => !folderFiles.includes(f))
|
|
262
|
+
: [...new Set([...prev, ...folderFiles])];
|
|
263
|
+
if (currentQuestion) updateCodeContext(currentQuestion.id, next);
|
|
264
|
+
return next;
|
|
265
|
+
});
|
|
266
|
+
},
|
|
267
|
+
[currentQuestion, updateCodeContext],
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const toggleExpand = useCallback((path: string) => {
|
|
271
|
+
setExpandedFolders((prev) => {
|
|
272
|
+
const next = new Set(prev);
|
|
273
|
+
if (next.has(path)) {
|
|
274
|
+
next.delete(path);
|
|
275
|
+
} else {
|
|
276
|
+
next.add(path);
|
|
277
|
+
}
|
|
278
|
+
return next;
|
|
279
|
+
});
|
|
280
|
+
}, []);
|
|
281
|
+
|
|
282
|
+
const filter = search.toLowerCase();
|
|
283
|
+
|
|
284
|
+
return (
|
|
285
|
+
<div className="w-72 border-l border-slate-800 flex flex-col bg-slate-900/30 shrink-0">
|
|
286
|
+
{/* Header */}
|
|
287
|
+
<div className="border-b border-slate-800 px-3 py-2">
|
|
288
|
+
<div className="flex items-center justify-between mb-2">
|
|
289
|
+
<span className="text-xs font-bold uppercase tracking-wider text-slate-500">
|
|
290
|
+
Code Context
|
|
291
|
+
</span>
|
|
292
|
+
<FolderOpen className="w-3.5 h-3.5 text-slate-600" />
|
|
293
|
+
</div>
|
|
294
|
+
<div className="relative">
|
|
295
|
+
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-slate-600" />
|
|
296
|
+
<input
|
|
297
|
+
value={search}
|
|
298
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
299
|
+
placeholder="Filter files..."
|
|
300
|
+
className="w-full bg-slate-800 border border-slate-700 rounded pl-7 pr-2 py-1 text-xs text-slate-300 placeholder-slate-600 focus:outline-none focus:border-cyan-500"
|
|
301
|
+
/>
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
{/* Snippets section */}
|
|
306
|
+
{codeSnippets.length > 0 && (
|
|
307
|
+
<div className="border-b border-slate-800 px-3 py-2">
|
|
308
|
+
<div className="flex items-center justify-between mb-1">
|
|
309
|
+
<div className="flex items-center gap-1">
|
|
310
|
+
<Scissors className="w-3 h-3 text-amber-400/70" />
|
|
311
|
+
<span className="text-[10px] uppercase tracking-wider text-slate-600">
|
|
312
|
+
Snippets ({codeSnippets.length})
|
|
313
|
+
</span>
|
|
314
|
+
</div>
|
|
315
|
+
<button
|
|
316
|
+
onClick={clearSnippets}
|
|
317
|
+
className="text-[10px] text-red-400/60 hover:text-red-400"
|
|
318
|
+
>
|
|
319
|
+
Clear all
|
|
320
|
+
</button>
|
|
321
|
+
</div>
|
|
322
|
+
<div className="space-y-0.5 max-h-32 overflow-y-auto">
|
|
323
|
+
{codeSnippets.map((s) => (
|
|
324
|
+
<div
|
|
325
|
+
key={s.id}
|
|
326
|
+
className="flex items-start gap-1 text-xs bg-amber-500/10 border border-amber-500/20 rounded px-1.5 py-1 group"
|
|
327
|
+
>
|
|
328
|
+
<div className="flex-1 min-w-0">
|
|
329
|
+
<span className="text-amber-400 font-medium">
|
|
330
|
+
{s.fileName}
|
|
331
|
+
</span>
|
|
332
|
+
<span className="text-slate-500 mx-1">›</span>
|
|
333
|
+
<span className="text-slate-500">
|
|
334
|
+
line{s.startLine !== s.endLine ? "s" : ""}{" "}
|
|
335
|
+
{s.startLine === s.endLine
|
|
336
|
+
? s.startLine
|
|
337
|
+
: `${s.startLine}–${s.endLine}`}
|
|
338
|
+
</span>
|
|
339
|
+
<p className="text-[10px] font-mono text-slate-600 mt-0.5 truncate">
|
|
340
|
+
{s.code.split("\n")[0]}
|
|
341
|
+
</p>
|
|
342
|
+
</div>
|
|
343
|
+
<button
|
|
344
|
+
onClick={() => removeSnippet(s.id)}
|
|
345
|
+
className="shrink-0 mt-0.5 text-slate-600 hover:text-red-400 transition-colors"
|
|
346
|
+
>
|
|
347
|
+
<X className="w-2.5 h-2.5" />
|
|
348
|
+
</button>
|
|
349
|
+
</div>
|
|
350
|
+
))}
|
|
351
|
+
</div>
|
|
352
|
+
</div>
|
|
353
|
+
)}
|
|
354
|
+
|
|
355
|
+
{/* Selected summary */}
|
|
356
|
+
{selectedFiles.length > 0 && (
|
|
357
|
+
<div className="border-b border-slate-800 px-3 py-2">
|
|
358
|
+
<div className="flex items-center justify-between">
|
|
359
|
+
<span className="text-[10px] uppercase tracking-wider text-slate-600">
|
|
360
|
+
Selected ({selectedFiles.length})
|
|
361
|
+
</span>
|
|
362
|
+
<button
|
|
363
|
+
onClick={() => {
|
|
364
|
+
setSelectedFiles([]);
|
|
365
|
+
if (currentQuestion) updateCodeContext(currentQuestion.id, []);
|
|
366
|
+
}}
|
|
367
|
+
className="text-[10px] text-red-400/60 hover:text-red-400"
|
|
368
|
+
>
|
|
369
|
+
Clear all
|
|
370
|
+
</button>
|
|
371
|
+
</div>
|
|
372
|
+
<div className="mt-1 space-y-0.5 max-h-24 overflow-y-auto">
|
|
373
|
+
{selectedFiles.map((f) => (
|
|
374
|
+
<div
|
|
375
|
+
key={f}
|
|
376
|
+
className="flex items-center gap-1 text-xs text-cyan-400 bg-cyan-500/10 rounded px-1.5 py-0.5 group"
|
|
377
|
+
>
|
|
378
|
+
<Check className="w-2.5 h-2.5 shrink-0" />
|
|
379
|
+
<button
|
|
380
|
+
onClick={() => setViewingFile(f)}
|
|
381
|
+
className="truncate flex-1 text-left hover:underline"
|
|
382
|
+
title="View file"
|
|
383
|
+
>
|
|
384
|
+
{f.split("/").pop()}
|
|
385
|
+
</button>
|
|
386
|
+
<button
|
|
387
|
+
onClick={() => toggleFile(f)}
|
|
388
|
+
className="shrink-0 hover:text-red-400"
|
|
389
|
+
>
|
|
390
|
+
<X className="w-2.5 h-2.5" />
|
|
391
|
+
</button>
|
|
392
|
+
</div>
|
|
393
|
+
))}
|
|
394
|
+
</div>
|
|
395
|
+
</div>
|
|
396
|
+
)}
|
|
397
|
+
|
|
398
|
+
{/* Tree browser */}
|
|
399
|
+
<div className="flex-1 overflow-y-auto py-1">
|
|
400
|
+
{!currentQuestion ? (
|
|
401
|
+
<div className="p-3 text-center">
|
|
402
|
+
<p className="text-xs text-slate-600">Select a question first</p>
|
|
403
|
+
</div>
|
|
404
|
+
) : availableFiles.length === 0 ? (
|
|
405
|
+
<div className="p-3 text-center">
|
|
406
|
+
<p className="text-xs text-slate-600">
|
|
407
|
+
Set CODE_CONTEXT_DIR in .env
|
|
408
|
+
</p>
|
|
409
|
+
<p className="text-xs text-slate-700 mt-1">
|
|
410
|
+
to browse project files
|
|
411
|
+
</p>
|
|
412
|
+
</div>
|
|
413
|
+
) : (
|
|
414
|
+
tree.children
|
|
415
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
416
|
+
.map((child) => (
|
|
417
|
+
<FolderNode
|
|
418
|
+
key={child.path}
|
|
419
|
+
node={child}
|
|
420
|
+
selectedFiles={selectedFiles}
|
|
421
|
+
onToggleFile={toggleFile}
|
|
422
|
+
onToggleFolder={toggleFolder}
|
|
423
|
+
expandedFolders={expandedFolders}
|
|
424
|
+
onToggleExpand={toggleExpand}
|
|
425
|
+
depth={0}
|
|
426
|
+
filter={filter}
|
|
427
|
+
onOpenFile={setViewingFile}
|
|
428
|
+
/>
|
|
429
|
+
))
|
|
430
|
+
)}
|
|
431
|
+
{/* Root-level files (if any) */}
|
|
432
|
+
{tree.files
|
|
433
|
+
.filter((f) => !filter || f.toLowerCase().includes(filter))
|
|
434
|
+
.map((filePath) => {
|
|
435
|
+
const isSelected = selectedFiles.includes(filePath);
|
|
436
|
+
return (
|
|
437
|
+
<div
|
|
438
|
+
key={filePath}
|
|
439
|
+
className={`flex items-center px-4 py-0.5 group ${
|
|
440
|
+
isSelected ? "text-cyan-400" : "text-slate-500"
|
|
441
|
+
}`}
|
|
442
|
+
>
|
|
443
|
+
<button
|
|
444
|
+
onClick={() => toggleFile(filePath)}
|
|
445
|
+
className="flex items-center gap-1.5 flex-1 min-w-0 hover:text-slate-300 transition-colors text-xs text-left"
|
|
446
|
+
>
|
|
447
|
+
<File className="w-3 h-3 shrink-0" />
|
|
448
|
+
<span className="truncate">{filePath}</span>
|
|
449
|
+
</button>
|
|
450
|
+
<button
|
|
451
|
+
onClick={() => setViewingFile(filePath)}
|
|
452
|
+
className="shrink-0 mr-1 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-cyan-400 transition-all"
|
|
453
|
+
title="View file"
|
|
454
|
+
>
|
|
455
|
+
<Eye className="w-3 h-3" />
|
|
456
|
+
</button>
|
|
457
|
+
</div>
|
|
458
|
+
);
|
|
459
|
+
})}
|
|
460
|
+
</div>
|
|
461
|
+
|
|
462
|
+
{viewingFile && (
|
|
463
|
+
<FileViewerModal
|
|
464
|
+
filePath={viewingFile}
|
|
465
|
+
onClose={() => setViewingFile(null)}
|
|
466
|
+
/>
|
|
467
|
+
)}
|
|
468
|
+
</div>
|
|
469
|
+
);
|
|
470
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { useRef } from "react";
|
|
2
|
+
import type { ContextFile } from "../types";
|
|
3
|
+
import { Paperclip, X, FileText } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
files: ContextFile[];
|
|
7
|
+
onUpload: (files: FileList) => Promise<void>;
|
|
8
|
+
onRemove: (fileId: string) => Promise<void>;
|
|
9
|
+
label: string;
|
|
10
|
+
compact?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function FileAttachments({
|
|
14
|
+
files,
|
|
15
|
+
onUpload,
|
|
16
|
+
onRemove,
|
|
17
|
+
label,
|
|
18
|
+
compact,
|
|
19
|
+
}: Props) {
|
|
20
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
21
|
+
|
|
22
|
+
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
23
|
+
if (e.target.files?.length) {
|
|
24
|
+
await onUpload(e.target.files);
|
|
25
|
+
e.target.value = "";
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
if (compact) {
|
|
30
|
+
return (
|
|
31
|
+
<div className="flex items-center gap-1 flex-wrap">
|
|
32
|
+
<input
|
|
33
|
+
ref={inputRef}
|
|
34
|
+
type="file"
|
|
35
|
+
multiple
|
|
36
|
+
onChange={handleChange}
|
|
37
|
+
className="hidden"
|
|
38
|
+
accept=".txt,.md,.ts,.tsx,.js,.jsx,.json,.css,.scss,.html,.xml,.yaml,.yml,.csv,.py,.java,.cs,.go,.rs,.sql,.sh,.toml,.ini,.log,.cfg,.conf,.env,.pdf,.docx"
|
|
39
|
+
/>
|
|
40
|
+
{files.map((f) => (
|
|
41
|
+
<span
|
|
42
|
+
key={f.id}
|
|
43
|
+
className="inline-flex items-center gap-1 bg-violet-500/10 text-violet-400 px-1.5 py-0.5 rounded text-[10px]"
|
|
44
|
+
>
|
|
45
|
+
<FileText className="w-2.5 h-2.5" />
|
|
46
|
+
{f.originalName}
|
|
47
|
+
<button
|
|
48
|
+
onClick={() => onRemove(f.id)}
|
|
49
|
+
className="hover:text-red-400 transition-colors"
|
|
50
|
+
>
|
|
51
|
+
<X className="w-2.5 h-2.5" />
|
|
52
|
+
</button>
|
|
53
|
+
</span>
|
|
54
|
+
))}
|
|
55
|
+
<button
|
|
56
|
+
onClick={() => inputRef.current?.click()}
|
|
57
|
+
className="inline-flex items-center gap-0.5 text-[10px] text-slate-600 hover:text-violet-400 transition-colors"
|
|
58
|
+
title={`Attach files to ${label}`}
|
|
59
|
+
>
|
|
60
|
+
<Paperclip className="w-2.5 h-2.5" />
|
|
61
|
+
Attach
|
|
62
|
+
</button>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div>
|
|
69
|
+
<input
|
|
70
|
+
ref={inputRef}
|
|
71
|
+
type="file"
|
|
72
|
+
multiple
|
|
73
|
+
onChange={handleChange}
|
|
74
|
+
className="hidden"
|
|
75
|
+
accept=".txt,.md,.ts,.tsx,.js,.jsx,.json,.css,.scss,.html,.xml,.yaml,.yml,.csv,.py,.java,.cs,.go,.rs,.sql,.sh,.toml,.ini,.log,.cfg,.conf,.env,.pdf,.docx"
|
|
76
|
+
/>
|
|
77
|
+
|
|
78
|
+
{files.length > 0 && (
|
|
79
|
+
<div className="space-y-0.5 mb-1.5">
|
|
80
|
+
{files.map((f) => (
|
|
81
|
+
<div
|
|
82
|
+
key={f.id}
|
|
83
|
+
className="flex items-center gap-1.5 bg-violet-500/10 rounded px-2 py-0.5 text-xs text-violet-400"
|
|
84
|
+
>
|
|
85
|
+
<FileText className="w-3 h-3 shrink-0" />
|
|
86
|
+
<span className="truncate flex-1">{f.originalName}</span>
|
|
87
|
+
<button
|
|
88
|
+
onClick={() => onRemove(f.id)}
|
|
89
|
+
className="shrink-0 hover:text-red-400 transition-colors"
|
|
90
|
+
>
|
|
91
|
+
<X className="w-3 h-3" />
|
|
92
|
+
</button>
|
|
93
|
+
</div>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
|
|
98
|
+
<button
|
|
99
|
+
onClick={() => inputRef.current?.click()}
|
|
100
|
+
className="flex items-center gap-1 text-xs text-slate-600 hover:text-violet-400 transition-colors w-full"
|
|
101
|
+
>
|
|
102
|
+
<Paperclip className="w-3 h-3" />
|
|
103
|
+
Attach files to {label}
|
|
104
|
+
</button>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|