Skip to content
Source
Sign inSign up
Open History to see the last commit

FileBrowserPanel.tsx

621 lines23.6 KB

1import { RefreshIcon } from "~/components/ui/refresh-icon";
2import type {
3 ContextMenuItem as TreeContextMenuItem,
4 ContextMenuOpenContext as TreeContextMenuOpenContext,
5} from "@pierre/trees";
6import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
7import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react";
8import { settlePromise } from "@t3tools/client-runtime/state/runtime";
9import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
10import * as AsyncResult from "effect/reactivity/AsyncResult";
11import * as Cause from "effect/Cause";
12import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react";
13import { type Ref, useEffect, useEffectEvent, useRef, useState } from "react";
14​
15import { Button } from "~/components/ui/button";
16import { InputGroup, InputGroupInput } from "~/components/ui/input-group";
17import { toastManager } from "~/components/ui/toast";
18import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
19import { useComposerHandleContext } from "~/composerHandleContext";
20import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
21import { useStableCallback } from "~/hooks/useStableCallback";
22import { useTheme } from "~/hooks/useTheme";
23import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";
24import { useFileContextMenu, type FileContextMenuAction } from "~/fileContextMenu";
25import { readLocalApi } from "~/localApi";
26import { T3_PIERRE_ICONS } from "~/pierre-icons";
27import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme";
28​
29import { joinHostPath } from "./filePath";
30import { createFileTreeDragMentionController } from "./fileTreeDragMention";
31import {
32 areAllDirectoriesExpanded,
33 coalesceReentrantCalls,
34 readExpandedDirectories,
35 setAllDirectoriesExpanded,
36 writeExpandedDirectories,
37} from "./fileTreeExpansion";
38import { buildFileTreePathUpdates } from "./fileTreePathReconciliation";
39import { loadFileAncestors } from "./loadFileAncestors";
40import { useDirectoryEntries } from "./useDirectoryEntries";
41import { useProjectPathSearch } from "~/state/queries";
42import { isMacPlatform } from "~/lib/utils";
43import { type PreviewAction, subscribePreviewAction } from "../preview/previewActionBus";
44​
45interface FileBrowserPanelProps {
46 environmentId: EnvironmentId;
47 cwd: string;
48 projectName: string;
49 selectedPath: string | null;
50 selectedPathRevealId: number;
51 onOpenFile: (relativePath: string) => void;
52 /** Host-folder entries use absolute paths. Avoid full traversal: disable expand-all and search only
53 * loaded folders. */
54 hostFolder?: boolean;
55 onRefreshSelectedFile?: () => void;
56 workspaceMutationId: string | null;
57 answersPanelFind?: boolean;
59​
60function treePath(entry: ProjectEntry): string {
61 return entry.kind === "directory" ? `${entry.path}/` : entry.path;
63​
64function RefreshFilesButton(props: { isPending: boolean; onRefresh: () => void }) {
65 return (
66 <Tooltip>
67 <TooltipTrigger
68 render={
69 <Button
70 type="button"
71 variant="ghost"
72 size="icon-xs"
73 aria-label="Refresh workspace files"
74 onClick={props.onRefresh}
75 />
76 }
77 >
78 <RefreshIcon refreshing={props.isPending} />
79 </TooltipTrigger>
80 <TooltipPopup>{props.isPending ? "Refreshing…" : "Refresh files"}</TooltipPopup>
81 </Tooltip>
82 );
84​
85function FileSearchField({
86 ref,
87 ...props
88}: {
89 ariaLabel: string;
90 name: string;
91 onClose: () => void;
92 onValueChange: (value: string) => void;
93 ref: Ref<HTMLInputElement>;
94 value: string;
95}) {
96 return (
97 <InputGroup variant="ghost" className="h-7 min-w-0 flex-1">
98 <InputGroupInput
99 ref={ref}
100 type="search"
101 name={props.name}
102 size="sm"
103 value={props.value}
104 aria-label={props.ariaLabel}
105 placeholder="Search files"
106 data-workspace-focus-default
107 spellCheck={false}
108 onChange={(event) => props.onValueChange(event.target.value)}
109 onKeyDown={(event) => {
110 if (event.key !== "Escape") return;
111 props.onClose();
112 event.currentTarget.blur();
113 }}
114 />
115 </InputGroup>
116 );
118​
119function buildTreeEntries(
120 directoryEntries: readonly ProjectEntry[],
121 searchEntries: readonly ProjectEntry[],
122 searchPending: boolean,
123 query: string,
124) {
125 const result = new Map(directoryEntries.map((entry) => [entry.path, entry]));
126 if (query.trim() && !searchPending) {
127 for (const entry of searchEntries) {
128 if (!result.has(entry.path)) result.set(entry.path, entry);
129 const segments = entry.path.split("/");
130 for (let index = 1; index < segments.length; index++) {
131 const path = segments.slice(0, index).join("/");
132 if (!result.has(path)) result.set(path, { path, kind: "directory" });
133 }
134 }
135 }
136 const entries = [...result.values()];
137 const entryKinds: ReadonlyMap<string, ProjectEntry["kind"]> = new Map(
138 entries.map((entry) => [entry.path, entry.kind] as const),
139 );
140 const treePaths = entries.map(treePath);
141 const directories = entries.filter((entry) => entry.kind === "directory");
142 const directoryPaths = directories.map(treePath);
143 // Expanding a folder lists it, and an ignored one such as `node_modules` can hold tens of
144 // thousands, so expand all skips ignored folders. They still open by hand.
145 const expandableDirectoryPaths = directories.filter((entry) => !entry.ignored).map(treePath);
146 return { entries, entryKinds, treePaths, directoryPaths, expandableDirectoryPaths };
148​
149export default function FileBrowserPanel({
150 environmentId,
151 cwd,
152 projectName,
153 selectedPath,
154 selectedPathRevealId,
155 onOpenFile,
156 onRefreshSelectedFile,
157 workspaceMutationId,
158 hostFolder = false,
159 answersPanelFind = false,
160}: FileBrowserPanelProps) {
161 const panelPath = (relativePath: string) =>
162 hostFolder ? joinHostPath(cwd, relativePath) : relativePath;
163 const { resolvedTheme } = useTheme();
164 const composerRef = useComposerHandleContext();
165 const fileContextMenu = useFileContextMenu(environmentId);
166 const {
167 entries: directoryEntries,
168 load,
169 refresh,
170 ready,
171 error,
172 isPending,
173 loadingDirectories,
174 } = useDirectoryEntries(environmentId, cwd);
175 const [query, setQuery] = useState("");
176 const [expandAll, setExpandAll] = useState(false);
177 const pathSearch = useProjectPathSearch(
178 { environmentId, cwd: hostFolder ? null : cwd, query: query.slice(0, 256) },
179 200,
180 );
181 const { entries, entryKinds, treePaths, directoryPaths, expandableDirectoryPaths } =
182 buildTreeEntries(directoryEntries, pathSearch.entries, pathSearch.isPending, query);
183 const entryKindsRef = useRef(entryKinds);
184 const previousTreePathsRef = useRef<readonly string[] | null>(null);
185 const syncingSelectionRef = useRef(false);
186 const treeSelectionPathRef = useRef<string | null>(null);
187 const handledRevealRef = useRef<{ path: string; revealId: number } | null>(null);
188​
189 // Shadow-DOM row rects are unreliable; capture the composed contextmenu event's viewport position.
190 const contextMenuPointerRef = useRef<{ x: number; y: number; at: number } | null>(null);
191​
192 const showEntryContextMenu = useStableCallback(
193 (item: TreeContextMenuItem, context: TreeContextMenuOpenContext) => {
194 const api = readLocalApi();
195 if (!api) {
196 context.close();
197 return;
198 }
199 const relativePath = item.path.replace(/\/$/, "");
200 const mention = serializeComposerFileLink(panelPath(relativePath));
201 const pointer = contextMenuPointerRef.current;
202 const pointerIsFresh = pointer !== null && performance.now() - pointer.at < 1000;
203 const anchorRect = context.anchorElement.getBoundingClientRect();
204 const position = pointerIsFresh
205 ? { x: pointer.x, y: pointer.y }
206 : { x: anchorRect.left, y: anchorRect.bottom };
207 const fileTarget = { environmentId, filePath: relativePath, workspaceRoot: cwd };
208 const fileMenuItems = fileContextMenu.buildItems(fileTarget);
209 // The app menu owns focus; the tree's outside-click handler would dismiss it before click.
210 context.close({ restoreFocus: !pointerIsFresh });
211 void settlePromise(async () => {
212 const clicked = await api.contextMenu.show(
213 [
214 ...fileMenuItems,
215 { id: "copy-mention", label: "Copy mention" },
216 { id: "add-to-chat", label: "Add to chat" },
217 ],
218 position,
219 );
220 if (clicked === null) return;
221 // Open-with submenu ids are absent from the top-level item list.
222 const isFileMenuAction =
223 fileMenuItems.some((entry) => entry.id === clicked) || clicked.startsWith("editor:");
224 if (isFileMenuAction) {
225 await fileContextMenu.activate(clicked as FileContextMenuAction, fileTarget);
226 return;
227 }
228 if (clicked === "copy-mention") {
229 try {
230 await writeTextToClipboard(mention);
231 toastManager.add({
232 type: "success",
233 title: "Mention copied",
234 description: relativePath,
235 });
236 } catch (error) {
237 toastManager.add({
238 type: "error",
239 title: "Failed to copy mention",
240 description: error instanceof Error ? error.message : "An error occurred.",
241 });
242 }
243 return;
244 }
245 if (clicked === "add-to-chat") {
246 const composer = composerRef?.current;
247 if (!composer) {
248 toastManager.add({
249 type: "error",
250 title: "Unable to add to chat",
251 description: "Open a chat for this project and try again.",
252 });
253 return;
254 }
255 const inserted = composer.insertTextAtEnd(`${mention} `, { ensureLeadingBoundary: true });
256 if (!inserted) {
257 toastManager.add({
258 type: "error",
259 title: "Unable to add to chat",
260 description: "The chat isn't ready to accept input right now.",
261 });
262 }
263 }
264 }).then((result) => {
265 if (!AsyncResult.isSuccess(result)) throw Cause.squash(result.cause);
266 });
267 },
268 );
269​
270 const loadingDirectoriesRef = useRef(loadingDirectories);
271 const dragMention = createFileTreeDragMentionController(
272 hostFolder ? { mentionPath: (path: string) => joinHostPath(cwd, path) } : {},
273 );
274 const { model } = useFileTree({
275 composition: {
276 contextMenu: {
277 triggerMode: "right-click",
278 onOpen: (item, context) => {
279 showEntryContextMenu(item, context);
280 },
281 },
282 },
283 dragAndDrop: { canDrop: () => false },
284 density: "compact",
285 fileTreeSearchMode: "hide-non-matches",
286 flattenEmptyDirectories: true,
287 initialExpansion: "closed",
288 icons: T3_PIERRE_ICONS,
289 onSelectionChange: (selectedPaths) => {
290 // Update drag selection even for reveals so subsequent drags cannot use stale paths.
291 dragMention.handleSelectionChange(selectedPaths);
292 if (syncingSelectionRef.current) return;
293 if (dragMention.isDragInProgress()) {
294 return;
295 }
296 const selectedPath = selectedPaths.at(-1)?.replace(/\/$/, "");
297 if (selectedPath && entryKindsRef.current.get(selectedPath) === "file") {
298 treeSelectionPathRef.current = selectedPath;
299 onOpenFile(panelPath(selectedPath));
300 }
301 },
302 paths: [],
303 renderRowDecoration: ({ item, row }) =>
304 row.kind === "directory" &&
305 row.isExpanded &&
306 loadingDirectoriesRef.current.has(item.path.replace(/\/$/, ""))
307 ? { icon: "t3-tree-icon-loading", title: "Loading…" }
308 : null,
309 search: false,
310 onSearchChange: (value) => setQuery(value ?? ""),
311 unsafeCSS: PIERRE_TREE_UNSAFE_CSS,
312 });
313 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- makes the Pierre tree model re-render row decorations when loading directories change
314 useEffect(() => {
315 if (loadingDirectoriesRef.current === loadingDirectories) return;
316 loadingDirectoriesRef.current = loadingDirectories;
317 model.setComposition(model.getComposition());
318 }, [loadingDirectories, model]);
319 const search = useFileTreeSearch(model);
320 // Pierre restores the unfiltered tree when a search has no matches.
321 const hasNoSearchMatches = search.value.trim().length > 0 && search.matchingPaths.length === 0;
322 const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) =>
323 areAllDirectoriesExpanded(currentModel, expandableDirectoryPaths),
324 );
325 const toggleAllDirectories = () => {
326 const expanded = !(expandAll || allDirectoriesExpanded);
327 setExpandAll(expanded);
328 setAllDirectoriesExpanded(
329 model,
330 expanded ? expandableDirectoryPaths : directoryPaths,
331 expanded,
332 );
333 };
334 const closeSearch = () => {
335 setQuery("");
336 search.close();
337 };
338 const searchInputRef = useRef<HTMLInputElement>(null);
339 const focusSearch = () => {
340 searchInputRef.current?.focus();
341 searchInputRef.current?.select();
342 };
343 const onPanelAction = useEffectEvent((action: PreviewAction) => {
344 // Hidden panel tabs stay mounted; only the visible Files tab answers.
345 if (action === "find" && searchInputRef.current?.getClientRects().length) focusSearch();
346 });
347 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to the panel find command, which fires while focus is anywhere in the right panel
348 useEffect(() => {
349 if (!answersPanelFind) return;
350 return subscribePreviewAction(onPanelAction);
351 }, [answersPanelFind]);
352 const expandedPathsRef = useRef(new Set<string>());
353 const expansionRoot = `${environmentId}:${cwd}`;
354 // Folders expanded when the panel last unmounted; each reopens once lazy loading lists it.
355 const [pendingExpandedPaths] = useState(() => new Set(readExpandedDirectories(expansionRoot)));
356 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to the Pierre tree model to load directories as they are expanded
357 useEffect(() => {
358 const currentPaths = new Set(directoryPaths);
359 for (const path of expandedPathsRef.current) {
360 if (!currentPaths.has(path)) expandedPathsRef.current.delete(path);
361 }
362 // Restoring calls item.expand(), which notifies subscribers synchronously.
363 const loadExpanded = coalesceReentrantCalls(() => {
364 if (model.isSearchOpen()) return;
365 let changed = false;
366 for (const path of directoryPaths) {
367 const item = model.getItem(path);
368 if (item?.isDirectory() && pendingExpandedPaths.delete(path) && "expand" in item) {
369 item.expand();
370 }
371 if (item?.isDirectory() && "isExpanded" in item && item.isExpanded()) {
372 if (!expandedPathsRef.current.has(path)) {
373 expandedPathsRef.current.add(path);
374 changed = true;
375 void load(path.replace(/\/$/, ""));
376 }
377 } else {
378 if (item?.isDirectory() && expandedPathsRef.current.has(path)) setExpandAll(false);
379 if (expandedPathsRef.current.delete(path)) changed = true;
380 }
381 }
382 if (changed) {
383 writeExpandedDirectories(expansionRoot, [
384 ...expandedPathsRef.current,
385 ...pendingExpandedPaths,
386 ]);
387 }
388 });
389 loadExpanded();
390 return model.subscribe(loadExpanded);
391 }, [directoryPaths, expansionRoot, load, model, pendingExpandedPaths]);
392 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- pushes ignored-file status into the imperative Pierre tree model
393 useEffect(() => {
394 model.setGitStatus(
395 entries
396 .filter((entry) => entry.ignored)
397 .map((entry) => ({
398 path: treePath(entry),
399 status: "ignored",
400 })),
401 );
402 }, [entries, model]);
403 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- loads the selected file's ancestor directories and aborts on cleanup
404 useEffect(() => {
405 if (!selectedPath) return;
406 const controller = new AbortController();
407 void loadFileAncestors(selectedPath, load, { signal: controller.signal });
408 return () => {
409 controller.abort();
410 };
411 }, [load, selectedPath]);
412 const handleSearchValueChange = (value: string) => {
413 setQuery(value);
414 if (value.trim().length === 0) {
415 search.close();
416 return;
417 }
418 search.setValue(value);
419 };
420 const handleRefresh = () => {
421 refresh();
422 if (query.trim()) pathSearch.refresh();
423 onRefreshSelectedFile?.();
424 };
425 useWorkspaceMutationRefresh({
426 mutationId: workspaceMutationId,
427 refresh: () => {
428 refresh();
429 if (query.trim()) pathSearch.refresh();
430 },
431 resourceKey: `files:${environmentId}:${cwd}`,
432 });
433​
434 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- applies tree path changes to the imperative Pierre tree model as a reset or batch update
435 useEffect(() => {
436 if (!ready) return;
437 if (previousTreePathsRef.current === treePaths) return;
438 entryKindsRef.current = entryKinds;
439 const previousTreePaths = previousTreePathsRef.current;
440 previousTreePathsRef.current = treePaths;
441 if (previousTreePaths === null) {
442 model.resetPaths(treePaths);
443 return;
444 }
445 const updates = buildFileTreePathUpdates(previousTreePaths, treePaths);
446 if (updates.length > 0) model.batch(updates);
447 }, [ready, entryKinds, model, treePaths]);
448​
449 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- expands every directory in the Pierre tree model while expand-all is on and no search is active
450 useEffect(() => {
451 if (expandAll && !query.trim())
452 setAllDirectoriesExpanded(model, expandableDirectoryPaths, true);
453 }, [expandableDirectoryPaths, expandAll, model, query]);
454​
455 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reveals an externally selected path in the Pierre tree model by expanding, selecting and scrolling
456 useEffect(() => {
457 if (!selectedPath) {
458 handledRevealRef.current = null;
459 return;
460 }
461 const selectedKind = entryKinds.get(selectedPath);
462 if (selectedKind === undefined) {
463 handledRevealRef.current = null;
464 return;
465 }
466 const revealRequest = { path: selectedPath, revealId: selectedPathRevealId };
467 const handledReveal = handledRevealRef.current;
468 // Refresh rebuilds treePaths; replaying handled reveals would close search and steal focus.
469 if (
470 handledReveal?.path === revealRequest.path &&
471 handledReveal.revealId === revealRequest.revealId
472 ) {
473 return;
474 }
475 const selectedTreePath = selectedKind === "directory" ? `${selectedPath}/` : selectedPath;
476 const selectedItem = model.getItem(selectedTreePath);
477 if (!selectedItem) return;
478​
479 // Reveal only external selections; tree selections are visible and re-revealing would close search.
480 const selectedInTree = model
481 .getSelectedPaths()
482 .some((path) => path.replace(/\/$/, "") === selectedPath);
483 if (selectedInTree && treeSelectionPathRef.current === selectedPath) {
484 treeSelectionPathRef.current = null;
485 handledRevealRef.current = revealRequest;
486 return;
487 }
488 treeSelectionPathRef.current = null;
489 handledRevealRef.current = revealRequest;
490​
491 syncingSelectionRef.current = true;
492 setQuery("");
493 model.closeSearch();
494 for (const path of model.getSelectedPaths()) {
495 model.getItem(path)?.deselect();
496 }
497​
498 // Directory paths include trailing slashes; ancestor lookups must match.
499 const segments = selectedPath.split("/");
500 let ancestorPath = "";
501 for (const segment of segments.slice(0, -1)) {
502 ancestorPath = ancestorPath ? `${ancestorPath}/${segment}` : segment;
503 const item = model.getItem(`${ancestorPath}/`) ?? model.getItem(ancestorPath);
504 if (item && "expand" in item) item.expand();
505 }
506​
507 if ("expand" in selectedItem) selectedItem.expand();
508 selectedItem.select();
509 model.scrollToPath(selectedTreePath, {
510 focus: true,
511 offset: "center",
512 });
513 queueMicrotask(() => {
514 syncingSelectionRef.current = false;
515 });
516 }, [entryKinds, model, selectedPath, selectedPathRevealId]);
517​
518 return (
519 <div
520 className="flex min-h-0 flex-1 flex-col bg-background"
521 data-file-browser-panel={`${environmentId}:${cwd}`}
522 // Capture runs before the tree's shadow-internal row handler opens the menu.
523 onContextMenuCapture={(event) => {
524 contextMenuPointerRef.current = { x: event.clientX, y: event.clientY, at: event.timeStamp };
525 }}
526 // Capture dragstart before the tree selects the row so selection sees the drag flag.
527 onDragStartCapture={(event) => dragMention.handleDragStart(event.nativeEvent)}
528 onDragEnd={() => dragMention.handleDragEnd((path) => model.getItem(path)?.deselect())}
529 onKeyDown={(event) => {
530 const isModPressed = isMacPlatform(navigator.platform) ? event.metaKey : event.ctrlKey;
531 if (!isModPressed || event.altKey || event.shiftKey || event.key.toLowerCase() !== "f")
532 return;
533 event.preventDefault();
534 focusSearch();
535 }}
536 >
537 <div
538 className="flex h-10 min-h-10 shrink-0 items-center gap-1 border-b border-border/60 bg-background px-2 in-data-[preview-panel-mode=inline]:mb-1 in-data-[preview-panel-mode=inline]:h-9 in-data-[preview-panel-mode=inline]:min-h-9 in-data-[preview-panel-mode=inline]:border-b-transparent"
539 data-surface-subheader
540 >
541 <RefreshFilesButton isPending={isPending} onRefresh={handleRefresh} />
542 <FileSearchField
543 ref={searchInputRef}
544 name="project-files-search"
545 ariaLabel={`Search ${projectName} files`}
546 value={search.value}
547 onValueChange={handleSearchValueChange}
548 onClose={closeSearch}
549 />
550 {directoryPaths.length > 0 && !hostFolder ? (
551 <Tooltip>
552 <TooltipTrigger
553 render={
554 <Button
555 type="button"
556 size="icon-xs"
557 variant="ghost"
558 aria-label={
559 expandAll || allDirectoriesExpanded
560 ? "Collapse all folders"
561 : "Expand all folders"
562 }
563 onClick={toggleAllDirectories}
564 />
565 }
566 >
567 {allDirectoriesExpanded ? (
568 <ChevronsDownUpIcon className="size-3.5" />
569 ) : (
570 <ChevronsUpDownIcon className="size-3.5" />
571 )}
572 </TooltipTrigger>
573 <TooltipPopup>
574 {expandAll || allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"}
575 </TooltipPopup>
576 </Tooltip>
577 ) : null}
578 </div>
579 {error || pathSearch.error ? (
580 <button
581 type="button"
582 onClick={handleRefresh}
583 className="p-4 text-left text-xs leading-relaxed text-destructive"
584 >
585 {error ?? pathSearch.error} Click to retry.
586 </button>
587 ) : null}
588 {query.trim() && pathSearch.truncated && !pathSearch.isPending ? (
589 <div className="px-3 py-1 text-xs text-muted-foreground">
590 More matches available. Refine your search.
591 </div>
592 ) : null}
593 {(!ready || pathSearch.isPending) && (
594 <div
595 role="status"
596 className={
597 entries.length > 0 && !hasNoSearchMatches
598 ? "sr-only"
599 : "px-3 py-1 text-xs text-muted-foreground"
600 }
601 >
602 Loading files…
603 </div>
604 )}
605 {hasNoSearchMatches ? (
606 ready && !isPending && !pathSearch.isPending && !error && !pathSearch.error ? (
607 <div role="status" className="px-3 py-4 text-xs text-muted-foreground">
608 No matching files.
609 </div>
610 ) : null
611 ) : (
612 <FileTree
613 model={model}
614 aria-label={`${projectName} files`}
615 className="min-h-0 flex-1 overflow-hidden"
616 style={pierreTreeStyle(resolvedTheme)}
617 />
618 )}
619 </div>
620 );