create-interview-cockpit 0.26.1 → 0.27.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/package.json
CHANGED
|
@@ -150,7 +150,7 @@ function FolderNode({
|
|
|
150
150
|
selectedFiles.includes(codeContextFileId(rootId, f)),
|
|
151
151
|
).length;
|
|
152
152
|
const folderKey = codeContextFileId(rootId, node.path);
|
|
153
|
-
const isExpanded = expandedFolders.has(folderKey);
|
|
153
|
+
const isExpanded = Boolean(filter) || expandedFolders.has(folderKey);
|
|
154
154
|
|
|
155
155
|
// Filter: if searching, only show nodes that have matching files
|
|
156
156
|
const matchingFiles = filter
|
|
@@ -310,7 +310,8 @@ function CodeContextRootNode({
|
|
|
310
310
|
: true;
|
|
311
311
|
if (filter && !hasMatchingDescendants) return null;
|
|
312
312
|
|
|
313
|
-
const isExpanded =
|
|
313
|
+
const isExpanded =
|
|
314
|
+
Boolean(filter) || rootMatches || expandedFolders.has(rootKey);
|
|
314
315
|
const checkState: "none" | "some" | "all" =
|
|
315
316
|
selectedCount === 0 || rootFileIds.length === 0
|
|
316
317
|
? "none"
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
memo,
|
|
3
|
+
useDeferredValue,
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
useCallback,
|
|
9
|
+
} from "react";
|
|
2
10
|
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
|
3
11
|
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
|
4
12
|
import {
|
|
@@ -12,9 +20,12 @@ import {
|
|
|
12
20
|
MessageCircle,
|
|
13
21
|
Send,
|
|
14
22
|
Play,
|
|
23
|
+
Search,
|
|
24
|
+
ChevronUp,
|
|
25
|
+
ChevronDown,
|
|
15
26
|
} from "lucide-react";
|
|
16
27
|
import { useStore } from "../store";
|
|
17
|
-
import type { CodeSnippet } from "../types";
|
|
28
|
+
import type { CodeContextRoot, CodeSnippet } from "../types";
|
|
18
29
|
import CodeLineAnnotationPopup, {
|
|
19
30
|
type CodeAnnotation,
|
|
20
31
|
} from "./CodeLineAnnotationPopup";
|
|
@@ -76,15 +87,141 @@ const LARGE_FILE_LINE_THRESHOLD = 2_500;
|
|
|
76
87
|
const LARGE_FILE_CHAR_THRESHOLD = 220_000;
|
|
77
88
|
const VIRTUAL_ROW_HEIGHT = 20;
|
|
78
89
|
const VIRTUAL_OVERSCAN = 24;
|
|
90
|
+
const CODE_CONTEXT_ROOT_SEPARATOR = "::";
|
|
79
91
|
|
|
80
92
|
type ResizeDir = "e" | "s" | "se" | "sw" | "w" | "ne" | "nw" | "n" | null;
|
|
81
93
|
|
|
94
|
+
interface CodeFileMeta {
|
|
95
|
+
rootId?: string;
|
|
96
|
+
rootName?: string;
|
|
97
|
+
relativePath?: string;
|
|
98
|
+
path?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface CodeFileBreadcrumbInfo {
|
|
102
|
+
rootName?: string;
|
|
103
|
+
parts: string[];
|
|
104
|
+
fileName: string;
|
|
105
|
+
fullPath: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseCodeContextFileId(fileId: string) {
|
|
109
|
+
const idx = fileId.indexOf(CODE_CONTEXT_ROOT_SEPARATOR);
|
|
110
|
+
if (idx === -1) return { rootId: null, relativePath: fileId };
|
|
111
|
+
return {
|
|
112
|
+
rootId: fileId.slice(0, idx),
|
|
113
|
+
relativePath: fileId.slice(idx + CODE_CONTEXT_ROOT_SEPARATOR.length),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function resolveCodeFileBreadcrumb(
|
|
118
|
+
filePath: string,
|
|
119
|
+
roots: CodeContextRoot[],
|
|
120
|
+
fetchedMeta: CodeFileMeta | null,
|
|
121
|
+
inlineLabel?: string,
|
|
122
|
+
): CodeFileBreadcrumbInfo {
|
|
123
|
+
if (filePath.startsWith("inline:")) {
|
|
124
|
+
const label = inlineLabel ?? filePath.slice("inline:".length);
|
|
125
|
+
return {
|
|
126
|
+
rootName: "Inline",
|
|
127
|
+
parts: [label],
|
|
128
|
+
fileName: label,
|
|
129
|
+
fullPath: `Inline/${label}`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const parsed = parseCodeContextFileId(filePath);
|
|
134
|
+
const root = parsed.rootId
|
|
135
|
+
? roots.find((candidate) => candidate.id === parsed.rootId)
|
|
136
|
+
: (roots.find((candidate) =>
|
|
137
|
+
candidate.files.includes(parsed.relativePath),
|
|
138
|
+
) ?? (roots.length === 1 ? roots[0] : undefined));
|
|
139
|
+
const relativePath = fetchedMeta?.relativePath ?? parsed.relativePath;
|
|
140
|
+
const rootName =
|
|
141
|
+
fetchedMeta?.rootName ?? root?.name ?? parsed.rootId ?? undefined;
|
|
142
|
+
const parts = relativePath.split("/").filter(Boolean);
|
|
143
|
+
const fileName = parts.at(-1) ?? relativePath;
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
rootName,
|
|
147
|
+
parts: parts.length > 0 ? parts : [relativePath],
|
|
148
|
+
fileName,
|
|
149
|
+
fullPath: rootName ? `${rootName}/${relativePath}` : relativePath,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function FilePathBreadcrumb({ info }: { info: CodeFileBreadcrumbInfo }) {
|
|
154
|
+
const [expanded, setExpanded] = useState(false);
|
|
155
|
+
const shouldCollapse = Boolean(info.rootName) && info.parts.length > 2;
|
|
156
|
+
const visibleParts =
|
|
157
|
+
shouldCollapse && !expanded
|
|
158
|
+
? [info.parts.at(-1) ?? info.fileName]
|
|
159
|
+
: info.parts;
|
|
160
|
+
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
setExpanded(false);
|
|
163
|
+
}, [info.fullPath]);
|
|
164
|
+
|
|
165
|
+
const separator = <span className="text-slate-600 shrink-0">›</span>;
|
|
166
|
+
const toggle = (
|
|
167
|
+
<button
|
|
168
|
+
type="button"
|
|
169
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
170
|
+
onClick={() => setExpanded((value) => !value)}
|
|
171
|
+
className="px-1 rounded text-slate-500 hover:text-cyan-300 hover:bg-slate-700/70 shrink-0"
|
|
172
|
+
title={expanded ? "Collapse path" : "Expand full path"}
|
|
173
|
+
>
|
|
174
|
+
…
|
|
175
|
+
</button>
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<div
|
|
180
|
+
className="flex items-center gap-1 min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-xs font-mono text-slate-300"
|
|
181
|
+
title={info.fullPath}
|
|
182
|
+
>
|
|
183
|
+
{info.rootName && (
|
|
184
|
+
<>
|
|
185
|
+
<span className="shrink-0 text-cyan-300">{info.rootName}</span>
|
|
186
|
+
{separator}
|
|
187
|
+
</>
|
|
188
|
+
)}
|
|
189
|
+
{shouldCollapse && !expanded && (
|
|
190
|
+
<>
|
|
191
|
+
{toggle}
|
|
192
|
+
{separator}
|
|
193
|
+
</>
|
|
194
|
+
)}
|
|
195
|
+
{visibleParts.map((part, index) => {
|
|
196
|
+
const isLast = index === visibleParts.length - 1;
|
|
197
|
+
return (
|
|
198
|
+
<span key={`${part}-${index}`} className="contents">
|
|
199
|
+
<span
|
|
200
|
+
className={isLast ? "text-slate-200" : "text-slate-400 shrink-0"}
|
|
201
|
+
>
|
|
202
|
+
{part}
|
|
203
|
+
</span>
|
|
204
|
+
{!isLast && separator}
|
|
205
|
+
</span>
|
|
206
|
+
);
|
|
207
|
+
})}
|
|
208
|
+
{shouldCollapse && expanded && (
|
|
209
|
+
<>
|
|
210
|
+
{separator}
|
|
211
|
+
{toggle}
|
|
212
|
+
</>
|
|
213
|
+
)}
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
82
218
|
interface CodeViewerContentProps {
|
|
83
219
|
content: string;
|
|
84
220
|
lang: string;
|
|
85
221
|
selectedLines: Set<number>;
|
|
86
222
|
chatContextLines: Set<number>;
|
|
87
223
|
codeAnnotations: CodeAnnotation[];
|
|
224
|
+
activeSearchLine: number | null;
|
|
88
225
|
onLineClick: (e: React.MouseEvent, lineNumber: number) => void;
|
|
89
226
|
}
|
|
90
227
|
|
|
@@ -94,8 +231,10 @@ const CodeViewerContent = memo(function CodeViewerContent({
|
|
|
94
231
|
selectedLines,
|
|
95
232
|
chatContextLines,
|
|
96
233
|
codeAnnotations,
|
|
234
|
+
activeSearchLine,
|
|
97
235
|
onLineClick,
|
|
98
236
|
}: CodeViewerContentProps) {
|
|
237
|
+
const syntaxScrollRef = useRef<HTMLDivElement>(null);
|
|
99
238
|
const annotationLineSet = useMemo(
|
|
100
239
|
() => new Set(codeAnnotations.map((annotation) => annotation.lineNumber)),
|
|
101
240
|
[codeAnnotations],
|
|
@@ -105,6 +244,14 @@ const CodeViewerContent = memo(function CodeViewerContent({
|
|
|
105
244
|
lineCount > LARGE_FILE_LINE_THRESHOLD ||
|
|
106
245
|
content.length > LARGE_FILE_CHAR_THRESHOLD;
|
|
107
246
|
|
|
247
|
+
useEffect(() => {
|
|
248
|
+
if (!activeSearchLine || isLargeFile) return;
|
|
249
|
+
const target = syntaxScrollRef.current?.querySelector<HTMLElement>(
|
|
250
|
+
`[data-code-line="${activeSearchLine}"]`,
|
|
251
|
+
);
|
|
252
|
+
target?.scrollIntoView({ block: "center" });
|
|
253
|
+
}, [activeSearchLine, isLargeFile, content]);
|
|
254
|
+
|
|
108
255
|
if (isLargeFile) {
|
|
109
256
|
return (
|
|
110
257
|
<VirtualizedPlainCodeViewer
|
|
@@ -112,13 +259,14 @@ const CodeViewerContent = memo(function CodeViewerContent({
|
|
|
112
259
|
selectedLines={selectedLines}
|
|
113
260
|
chatContextLines={chatContextLines}
|
|
114
261
|
annotationLineSet={annotationLineSet}
|
|
262
|
+
activeSearchLine={activeSearchLine}
|
|
115
263
|
onLineClick={onLineClick}
|
|
116
264
|
/>
|
|
117
265
|
);
|
|
118
266
|
}
|
|
119
267
|
|
|
120
268
|
return (
|
|
121
|
-
<div className="h-full overflow-auto">
|
|
269
|
+
<div ref={syntaxScrollRef} className="h-full overflow-auto">
|
|
122
270
|
<SyntaxHighlighter
|
|
123
271
|
language={lang}
|
|
124
272
|
style={oneDark}
|
|
@@ -129,9 +277,13 @@ const CodeViewerContent = memo(function CodeViewerContent({
|
|
|
129
277
|
const hasAnnotation = annotationLineSet.has(lineNumber);
|
|
130
278
|
const isChatCtx = chatContextLines.has(lineNumber);
|
|
131
279
|
const isSelected = selectedLines.has(lineNumber);
|
|
280
|
+
const isSearchActive = activeSearchLine === lineNumber;
|
|
132
281
|
let bg: string | undefined;
|
|
133
282
|
let outline: string | undefined;
|
|
134
|
-
if (
|
|
283
|
+
if (isSearchActive) {
|
|
284
|
+
bg = "rgba(34, 211, 238, 0.24)";
|
|
285
|
+
outline = "1px solid rgba(34, 211, 238, 0.7)";
|
|
286
|
+
} else if (hasAnnotation) {
|
|
135
287
|
bg = "rgba(139, 92, 246, 0.2)";
|
|
136
288
|
outline = "1px solid rgba(139, 92, 246, 0.35)";
|
|
137
289
|
} else if (isChatCtx) {
|
|
@@ -142,6 +294,7 @@ const CodeViewerContent = memo(function CodeViewerContent({
|
|
|
142
294
|
outline = "1px solid rgba(6, 182, 212, 0.3)";
|
|
143
295
|
}
|
|
144
296
|
return {
|
|
297
|
+
"data-code-line": lineNumber,
|
|
145
298
|
onClick: (e: React.MouseEvent) =>
|
|
146
299
|
lineNumber !== undefined && onLineClick(e, lineNumber),
|
|
147
300
|
style: {
|
|
@@ -173,6 +326,7 @@ interface VirtualizedPlainCodeViewerProps {
|
|
|
173
326
|
selectedLines: Set<number>;
|
|
174
327
|
chatContextLines: Set<number>;
|
|
175
328
|
annotationLineSet: Set<number>;
|
|
329
|
+
activeSearchLine: number | null;
|
|
176
330
|
onLineClick: (e: React.MouseEvent, lineNumber: number) => void;
|
|
177
331
|
}
|
|
178
332
|
|
|
@@ -181,6 +335,7 @@ function VirtualizedPlainCodeViewer({
|
|
|
181
335
|
selectedLines,
|
|
182
336
|
chatContextLines,
|
|
183
337
|
annotationLineSet,
|
|
338
|
+
activeSearchLine,
|
|
184
339
|
onLineClick,
|
|
185
340
|
}: VirtualizedPlainCodeViewerProps) {
|
|
186
341
|
const lines = useMemo(() => content.split(/\r?\n/), [content]);
|
|
@@ -198,6 +353,17 @@ function VirtualizedPlainCodeViewer({
|
|
|
198
353
|
return () => observer.disconnect();
|
|
199
354
|
}, []);
|
|
200
355
|
|
|
356
|
+
useEffect(() => {
|
|
357
|
+
const el = containerRef.current;
|
|
358
|
+
if (!el || !activeSearchLine) return;
|
|
359
|
+
const targetScrollTop = Math.max(
|
|
360
|
+
0,
|
|
361
|
+
(activeSearchLine - 1) * VIRTUAL_ROW_HEIGHT - el.clientHeight / 2,
|
|
362
|
+
);
|
|
363
|
+
el.scrollTop = targetScrollTop;
|
|
364
|
+
setScrollTop(targetScrollTop);
|
|
365
|
+
}, [activeSearchLine]);
|
|
366
|
+
|
|
201
367
|
const visibleStart = Math.max(
|
|
202
368
|
0,
|
|
203
369
|
Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT) - VIRTUAL_OVERSCAN,
|
|
@@ -235,20 +401,25 @@ function VirtualizedPlainCodeViewer({
|
|
|
235
401
|
const hasAnnotation = annotationLineSet.has(lineNumber);
|
|
236
402
|
const isChatCtx = chatContextLines.has(lineNumber);
|
|
237
403
|
const isSelected = selectedLines.has(lineNumber);
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
404
|
+
const isSearchActive = activeSearchLine === lineNumber;
|
|
405
|
+
const backgroundColor = isSearchActive
|
|
406
|
+
? "rgba(34, 211, 238, 0.24)"
|
|
407
|
+
: hasAnnotation
|
|
408
|
+
? "rgba(139, 92, 246, 0.2)"
|
|
409
|
+
: isChatCtx
|
|
410
|
+
? "rgba(245, 158, 11, 0.15)"
|
|
411
|
+
: isSelected
|
|
412
|
+
? "rgba(6, 182, 212, 0.15)"
|
|
413
|
+
: undefined;
|
|
414
|
+
const outline = isSearchActive
|
|
415
|
+
? "1px solid rgba(34, 211, 238, 0.7)"
|
|
416
|
+
: hasAnnotation
|
|
417
|
+
? "1px solid rgba(139, 92, 246, 0.35)"
|
|
418
|
+
: isChatCtx
|
|
419
|
+
? "1px solid rgba(245, 158, 11, 0.3)"
|
|
420
|
+
: isSelected
|
|
421
|
+
? "1px solid rgba(6, 182, 212, 0.3)"
|
|
422
|
+
: undefined;
|
|
252
423
|
|
|
253
424
|
return (
|
|
254
425
|
<div
|
|
@@ -283,17 +454,25 @@ export default function FileViewerModal({ filePath, onClose }: Props) {
|
|
|
283
454
|
livePreferenceSuffix,
|
|
284
455
|
inlineCodeSnippets,
|
|
285
456
|
openCodeRunner,
|
|
457
|
+
codeContextRoots,
|
|
286
458
|
} = useStore();
|
|
287
459
|
// stable ref so callbacks can read the latest question id without re-creating
|
|
288
460
|
const currentQuestionIdRef = useRef<string | undefined>(undefined);
|
|
289
461
|
currentQuestionIdRef.current = currentQuestion?.id;
|
|
290
462
|
const [content, setContent] = useState<string | null>(null);
|
|
463
|
+
const [fetchedFileMeta, setFetchedFileMeta] = useState<CodeFileMeta | null>(
|
|
464
|
+
null,
|
|
465
|
+
);
|
|
291
466
|
const [loading, setLoading] = useState(true);
|
|
292
467
|
const [error, setError] = useState<string | null>(null);
|
|
293
468
|
const [maximized, setMaximized] = useState(false);
|
|
294
469
|
const [selectedLines, setSelectedLines] = useState<Set<number>>(new Set());
|
|
295
470
|
const [addedFeedback, setAddedFeedback] = useState(false);
|
|
296
471
|
const lastClickedLineRef = useRef<number | null>(null);
|
|
472
|
+
const [searchOpen, setSearchOpen] = useState(false);
|
|
473
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
474
|
+
const [activeSearchIndex, setActiveSearchIndex] = useState(0);
|
|
475
|
+
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
297
476
|
|
|
298
477
|
// ── Code chat ─────────────────────────────────────────────
|
|
299
478
|
const [chatPanelOpen, setChatPanelOpen] = useState(false);
|
|
@@ -335,15 +514,75 @@ export default function FileViewerModal({ filePath, onClose }: Props) {
|
|
|
335
514
|
|
|
336
515
|
const isInline = filePath.startsWith("inline:");
|
|
337
516
|
const inlineEntry = isInline ? inlineCodeSnippets[filePath] : undefined;
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
517
|
+
const breadcrumbInfo = useMemo(
|
|
518
|
+
() =>
|
|
519
|
+
resolveCodeFileBreadcrumb(
|
|
520
|
+
filePath,
|
|
521
|
+
codeContextRoots,
|
|
522
|
+
fetchedFileMeta,
|
|
523
|
+
inlineEntry?.label,
|
|
524
|
+
),
|
|
525
|
+
[filePath, codeContextRoots, fetchedFileMeta, inlineEntry?.label],
|
|
526
|
+
);
|
|
527
|
+
const fileName = breadcrumbInfo.fileName;
|
|
528
|
+
const deferredSearchQuery = useDeferredValue(searchQuery);
|
|
529
|
+
const searchableLines = useMemo(
|
|
530
|
+
() => content?.split(/\r?\n/).map((line) => line.toLowerCase()) ?? [],
|
|
531
|
+
[content],
|
|
532
|
+
);
|
|
533
|
+
const searchMatches = useMemo(() => {
|
|
534
|
+
const query = deferredSearchQuery.trim().toLowerCase();
|
|
535
|
+
if (!query || searchableLines.length === 0) return [];
|
|
536
|
+
const matches: number[] = [];
|
|
537
|
+
searchableLines.forEach((line, index) => {
|
|
538
|
+
if (line.includes(query)) matches.push(index + 1);
|
|
539
|
+
});
|
|
540
|
+
return matches;
|
|
541
|
+
}, [deferredSearchQuery, searchableLines]);
|
|
542
|
+
const clampedSearchIndex =
|
|
543
|
+
searchMatches.length === 0
|
|
544
|
+
? 0
|
|
545
|
+
: Math.min(activeSearchIndex, searchMatches.length - 1);
|
|
546
|
+
const activeSearchLine = searchMatches[clampedSearchIndex] ?? null;
|
|
547
|
+
|
|
548
|
+
useEffect(() => {
|
|
549
|
+
setActiveSearchIndex(0);
|
|
550
|
+
}, [deferredSearchQuery, content]);
|
|
551
|
+
|
|
552
|
+
useEffect(() => {
|
|
553
|
+
if (!searchOpen) return;
|
|
554
|
+
setTimeout(() => searchInputRef.current?.focus(), 0);
|
|
555
|
+
}, [searchOpen]);
|
|
556
|
+
|
|
557
|
+
const moveSearch = useCallback(
|
|
558
|
+
(direction: 1 | -1) => {
|
|
559
|
+
setActiveSearchIndex((prev) => {
|
|
560
|
+
if (searchMatches.length === 0) return 0;
|
|
561
|
+
return (prev + direction + searchMatches.length) % searchMatches.length;
|
|
562
|
+
});
|
|
563
|
+
},
|
|
564
|
+
[searchMatches.length],
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
const toggleSearch = useCallback(() => {
|
|
568
|
+
setSearchOpen((open) => {
|
|
569
|
+
const next = !open;
|
|
570
|
+
if (!next) {
|
|
571
|
+
setSearchQuery("");
|
|
572
|
+
setActiveSearchIndex(0);
|
|
573
|
+
}
|
|
574
|
+
return next;
|
|
575
|
+
});
|
|
576
|
+
}, []);
|
|
341
577
|
|
|
342
578
|
// Fetch file content + load persisted annotations
|
|
343
579
|
useEffect(() => {
|
|
344
580
|
setLoading(true);
|
|
345
581
|
setError(null);
|
|
582
|
+
setFetchedFileMeta(null);
|
|
346
583
|
setSelectedLines(new Set());
|
|
584
|
+
setSearchQuery("");
|
|
585
|
+
setActiveSearchIndex(0);
|
|
347
586
|
lastClickedLineRef.current = null;
|
|
348
587
|
setChatContextLines(new Set());
|
|
349
588
|
setChatInput("");
|
|
@@ -385,7 +624,15 @@ export default function FileViewerModal({ filePath, onClose }: Props) {
|
|
|
385
624
|
.then((r) => r.json())
|
|
386
625
|
.then((d) => {
|
|
387
626
|
if (d.error) setError(d.error);
|
|
388
|
-
else
|
|
627
|
+
else {
|
|
628
|
+
setContent(d.content as string);
|
|
629
|
+
setFetchedFileMeta({
|
|
630
|
+
path: d.path,
|
|
631
|
+
rootId: d.rootId,
|
|
632
|
+
rootName: d.rootName,
|
|
633
|
+
relativePath: d.relativePath,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
389
636
|
})
|
|
390
637
|
.catch(() => setError("Failed to load file."))
|
|
391
638
|
.finally(() => setLoading(false));
|
|
@@ -680,11 +927,18 @@ export default function FileViewerModal({ filePath, onClose }: Props) {
|
|
|
680
927
|
|
|
681
928
|
useEffect(() => {
|
|
682
929
|
const handler = (e: KeyboardEvent) => {
|
|
683
|
-
if (e.key
|
|
930
|
+
if (e.key !== "Escape") return;
|
|
931
|
+
if (searchOpen) {
|
|
932
|
+
e.preventDefault();
|
|
933
|
+
setSearchOpen(false);
|
|
934
|
+
setSearchQuery("");
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
onClose();
|
|
684
938
|
};
|
|
685
939
|
document.addEventListener("keydown", handler);
|
|
686
940
|
return () => document.removeEventListener("keydown", handler);
|
|
687
|
-
}, [onClose]);
|
|
941
|
+
}, [onClose, searchOpen]);
|
|
688
942
|
|
|
689
943
|
// Keep refs in sync with state for use inside [] dep callbacks
|
|
690
944
|
codeAnnotationsRef.current = codeAnnotations;
|
|
@@ -727,15 +981,22 @@ export default function FileViewerModal({ filePath, onClose }: Props) {
|
|
|
727
981
|
style={{ cursor: maximized ? "default" : "grab" }}
|
|
728
982
|
>
|
|
729
983
|
<GripVertical className="w-3.5 h-3.5 text-slate-600 shrink-0" />
|
|
730
|
-
<
|
|
731
|
-
className="text-xs font-mono text-slate-300 truncate flex-1"
|
|
732
|
-
title={filePath}
|
|
733
|
-
>
|
|
734
|
-
{fileName}
|
|
735
|
-
</span>
|
|
984
|
+
<FilePathBreadcrumb info={breadcrumbInfo} />
|
|
736
985
|
<span className="text-[10px] text-slate-600 shrink-0 uppercase tracking-wider hidden sm:block">
|
|
737
986
|
{lang}
|
|
738
987
|
</span>
|
|
988
|
+
<button
|
|
989
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
990
|
+
onClick={toggleSearch}
|
|
991
|
+
className={`p-1 rounded transition-colors shrink-0 ${
|
|
992
|
+
searchOpen
|
|
993
|
+
? "text-cyan-300 bg-cyan-500/10 hover:bg-cyan-500/20"
|
|
994
|
+
: "text-slate-500 hover:bg-slate-700 hover:text-slate-300"
|
|
995
|
+
}`}
|
|
996
|
+
title="Search in file"
|
|
997
|
+
>
|
|
998
|
+
<Search className="w-3.5 h-3.5" />
|
|
999
|
+
</button>
|
|
739
1000
|
<button
|
|
740
1001
|
onClick={toggleMax}
|
|
741
1002
|
className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors shrink-0"
|
|
@@ -796,6 +1057,64 @@ export default function FileViewerModal({ filePath, onClose }: Props) {
|
|
|
796
1057
|
</button>
|
|
797
1058
|
</div>
|
|
798
1059
|
|
|
1060
|
+
{searchOpen && (
|
|
1061
|
+
<div className="shrink-0 flex items-center gap-2 px-3 py-2 bg-slate-900 border-b border-slate-800">
|
|
1062
|
+
<Search className="w-3.5 h-3.5 text-cyan-400/70 shrink-0" />
|
|
1063
|
+
<input
|
|
1064
|
+
ref={searchInputRef}
|
|
1065
|
+
value={searchQuery}
|
|
1066
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
1067
|
+
onKeyDown={(e) => {
|
|
1068
|
+
if (e.key === "Enter") {
|
|
1069
|
+
e.preventDefault();
|
|
1070
|
+
moveSearch(e.shiftKey ? -1 : 1);
|
|
1071
|
+
}
|
|
1072
|
+
if (e.key === "Escape") {
|
|
1073
|
+
e.preventDefault();
|
|
1074
|
+
e.stopPropagation();
|
|
1075
|
+
setSearchOpen(false);
|
|
1076
|
+
setSearchQuery("");
|
|
1077
|
+
}
|
|
1078
|
+
}}
|
|
1079
|
+
placeholder="Search in this file…"
|
|
1080
|
+
className="flex-1 min-w-0 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-slate-200 placeholder-slate-600 focus:outline-none focus:border-cyan-500"
|
|
1081
|
+
/>
|
|
1082
|
+
<span className="text-[10px] text-slate-500 font-mono shrink-0 min-w-10 text-right">
|
|
1083
|
+
{searchQuery.trim()
|
|
1084
|
+
? searchMatches.length > 0
|
|
1085
|
+
? `${clampedSearchIndex + 1}/${searchMatches.length}`
|
|
1086
|
+
: "0/0"
|
|
1087
|
+
: ""}
|
|
1088
|
+
</span>
|
|
1089
|
+
<button
|
|
1090
|
+
onClick={() => moveSearch(-1)}
|
|
1091
|
+
disabled={searchMatches.length === 0}
|
|
1092
|
+
className="p-1 rounded text-slate-500 hover:text-cyan-300 hover:bg-slate-800 disabled:opacity-40 disabled:hover:text-slate-500 disabled:hover:bg-transparent"
|
|
1093
|
+
title="Previous match"
|
|
1094
|
+
>
|
|
1095
|
+
<ChevronUp className="w-3.5 h-3.5" />
|
|
1096
|
+
</button>
|
|
1097
|
+
<button
|
|
1098
|
+
onClick={() => moveSearch(1)}
|
|
1099
|
+
disabled={searchMatches.length === 0}
|
|
1100
|
+
className="p-1 rounded text-slate-500 hover:text-cyan-300 hover:bg-slate-800 disabled:opacity-40 disabled:hover:text-slate-500 disabled:hover:bg-transparent"
|
|
1101
|
+
title="Next match"
|
|
1102
|
+
>
|
|
1103
|
+
<ChevronDown className="w-3.5 h-3.5" />
|
|
1104
|
+
</button>
|
|
1105
|
+
<button
|
|
1106
|
+
onClick={() => {
|
|
1107
|
+
setSearchOpen(false);
|
|
1108
|
+
setSearchQuery("");
|
|
1109
|
+
}}
|
|
1110
|
+
className="p-1 rounded text-slate-500 hover:text-red-400 hover:bg-slate-800"
|
|
1111
|
+
title="Close search"
|
|
1112
|
+
>
|
|
1113
|
+
<X className="w-3.5 h-3.5" />
|
|
1114
|
+
</button>
|
|
1115
|
+
</div>
|
|
1116
|
+
)}
|
|
1117
|
+
|
|
799
1118
|
{/* ── Content ── */}
|
|
800
1119
|
<div className="flex-1 min-h-0">
|
|
801
1120
|
{loading && (
|
|
@@ -815,6 +1134,7 @@ export default function FileViewerModal({ filePath, onClose }: Props) {
|
|
|
815
1134
|
selectedLines={selectedLines}
|
|
816
1135
|
chatContextLines={chatContextLines}
|
|
817
1136
|
codeAnnotations={codeAnnotations}
|
|
1137
|
+
activeSearchLine={activeSearchLine}
|
|
818
1138
|
onLineClick={handleLineClick}
|
|
819
1139
|
/>
|
|
820
1140
|
)}
|
package/template/cockpit.json
CHANGED