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 { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
9import { ChevronsDownUp, ChevronsUpDown } from "lucide";
10import { useEffect, useMemo, useRef, useState } from "react";
11
12import { Button } from "~/components/ui/button";
13import { InputGroup, InputGroupInput } from "~/components/ui/input-group";
14import { MorphIcon } from "~/components/MorphIcon";
15import { toastManager } from "~/components/ui/toast";
16import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
17import { useComposerHandleContext } from "~/composerHandleContext";
18import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
19import { useTheme } from "~/hooks/useTheme";
20import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";
21import { useFileContextMenu, type FileContextMenuAction } from "~/fileContextMenu";
22import { readLocalApi } from "~/localApi";
23import { T3_PIERRE_ICONS } from "~/pierre-icons";
24import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme";
25
26import { createFileTreeDragMentionController } from "./fileTreeDragMention";
27import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion";
28import { buildFileTreePathUpdates } from "./fileTreePathReconciliation";
29import { useDirectoryEntries } from "./useDirectoryEntries";
30import { useProjectPathSearch } from "~/state/queries";
31
32interface FileBrowserPanelProps {
33 environmentId: EnvironmentId;
34 cwd: string;
35 projectName: string;
36 /** Entry currently open in the surface; revealed and selected in the tree. A directory is expanded. */
37 selectedPath: string | null;
38 /** Bumped when the same path should be revealed again (e.g. re-opened from search). */
39 selectedPathRevealId: number;
40 onOpenFile: (relativePath: string) => void;
41 onRefreshSelectedFile?: () => void;
42 workspaceMutationId: string | null;
43}
44
45function treePath(entry: ProjectEntry): string {
46 return entry.kind === "directory" ? `${entry.path}/` : entry.path;
47}
48
49function RefreshFilesButton(props: { isPending: boolean; onRefresh: () => void }) {
50 return (
51 <Tooltip>
52 <TooltipTrigger
53 render={
54 <Button
55 type="button"
56 variant="ghost"
57 size="icon-xs"
58 aria-label="Refresh workspace files"
59 onClick={props.onRefresh}
60 />
61 }
62 >
63 <RefreshIcon refreshing={props.isPending} />
64 </TooltipTrigger>
65 <TooltipPopup>{props.isPending ? "Refreshing…" : "Refresh files"}</TooltipPopup>
66 </Tooltip>
67 );
68}
69
70function FileSearchField(props: {
71 ariaLabel: string;
72 name: string;
73 onClose: () => void;
74 onValueChange: (value: string) => void;
75 value: string;
76}) {
77 return (
78 <InputGroup variant="ghost" className="h-7 min-w-0 flex-1">
79 <InputGroupInput
80 type="search"
81 name={props.name}
82 size="sm"
83 value={props.value}
84 aria-label={props.ariaLabel}
85 placeholder="Search files"
86 spellCheck={false}
87 onChange={(event) => props.onValueChange(event.target.value)}
88 onKeyDown={(event) => {
89 if (event.key !== "Escape") return;
90 props.onClose();
91 event.currentTarget.blur();
92 }}
93 />
94 </InputGroup>
95 );
96}
97
98export default function FileBrowserPanel({
99 environmentId,
100 cwd,
101 projectName,
102 selectedPath,
103 selectedPathRevealId,
104 onOpenFile,
105 onRefreshSelectedFile,
106 workspaceMutationId,
107}: FileBrowserPanelProps) {
108 const { resolvedTheme } = useTheme();
109 const composerRef = useComposerHandleContext();
110 const fileContextMenu = useFileContextMenu(environmentId);
111 const {
112 entries: directoryEntries,
113 load,
114 refresh,
115 ready,
116 error,
117 isPending,
118 loadingDirectories,
119 } = useDirectoryEntries(environmentId, cwd);
120 const [query, setQuery] = useState("");
121 const [expandAll, setExpandAll] = useState(false);
122 const pathSearch = useProjectPathSearch({ environmentId, cwd, query: query.slice(0, 256) }, 200);
123 const entries = useMemo(() => {
124 const result = new Map(directoryEntries.map((entry) => [entry.path, entry]));
125 if (query.trim() && !pathSearch.isPending) {
126 for (const entry of pathSearch.entries) {
127 if (!result.has(entry.path)) result.set(entry.path, entry);
128 const segments = entry.path.split("/");
129 for (let index = 1; index < segments.length; index++) {
130 const path = segments.slice(0, index).join("/");
131 if (!result.has(path)) result.set(path, { path, kind: "directory" });
132 }
133 }
134 }
135 return [...result.values()];
136 }, [directoryEntries, pathSearch.entries, pathSearch.isPending, query]);
137 const entryKinds = useMemo(
138 () => new Map(entries.map((entry) => [entry.path, entry.kind] as const)),
139 [entries],
140 );
141 const entryKindsRef = useRef<ReadonlyMap<string, ProjectEntry["kind"]>>(entryKinds);
142 const treePaths = useMemo(() => entries.map(treePath), [entries]);
143 const directoryPaths = useMemo(
144 () => entries.filter((entry) => entry.kind === "directory").map(treePath),
145 [entries],
146 );
147 const previousTreePathsRef = useRef<readonly string[] | null>(null);
148 const syncingSelectionRef = useRef(false);
149 const treeSelectionPathRef = useRef<string | null>(null);
150 const handledRevealRef = useRef<{ path: string; revealId: number } | null>(null);
151
152 // The tree renders rows in shadow DOM and its anchor rect is unreliable, so
153 // capture the right-click position ourselves; contextmenu is a composed
154 // event, so a capture-phase listener sees it with viewport coordinates.
155 const contextMenuPointerRef = useRef<{ x: number; y: number; at: number } | null>(null);
156 useEffect(() => {
157 const capturePointer = (event: MouseEvent) => {
158 contextMenuPointerRef.current = { x: event.clientX, y: event.clientY, at: event.timeStamp };
159 };
160 document.addEventListener("contextmenu", capturePointer, true);
161 return () => document.removeEventListener("contextmenu", capturePointer, true);
162 }, []);
163
164 /** Combines the file actions (open/reveal/open with) with the panel's own mention actions. */
165 const showEntryContextMenu = async (
166 item: TreeContextMenuItem,
167 context: TreeContextMenuOpenContext,
168 ) => {
169 const api = readLocalApi();
170 if (!api) {
171 context.close();
172 return;
173 }
174 const relativePath = item.path.replace(/\/$/, "");
175 const mention = serializeComposerFileLink(relativePath);
176 const pointer = contextMenuPointerRef.current;
177 const pointerIsFresh = pointer !== null && performance.now() - pointer.at < 1000;
178 const anchorRect = context.anchorElement.getBoundingClientRect();
179 const position = pointerIsFresh
180 ? { x: pointer.x, y: pointer.y }
181 : { x: anchorRect.left, y: anchorRect.bottom };
182 const fileTarget = { environmentId, filePath: relativePath, workspaceRoot: cwd };
183 const fileMenuItems = fileContextMenu.buildItems(fileTarget);
184 try {
185 const clicked = await api.contextMenu.show(
186 [
187 ...fileMenuItems,
188 { id: "copy-mention", label: "Copy mention" },
189 { id: "add-to-chat", label: "Add to chat" },
190 ],
191 position,
192 );
193 if (clicked === null) return;
194 // "Open with" submenu selections report the child id ("editor:<id>"),
195 // which is not present in the top-level item list.
196 const isFileMenuAction =
197 fileMenuItems.some((entry) => entry.id === clicked) || clicked.startsWith("editor:");
198 if (isFileMenuAction) {
199 await fileContextMenu.activate(clicked as FileContextMenuAction, fileTarget);
200 return;
201 }
202 if (clicked === "copy-mention") {
203 try {
204 await writeTextToClipboard(mention);
205 toastManager.add({ type: "success", title: "Mention copied", description: relativePath });
206 } catch (error) {
207 toastManager.add({
208 type: "error",
209 title: "Failed to copy mention",
210 description: error instanceof Error ? error.message : "An error occurred.",
211 });
212 }
213 return;
214 }
215 if (clicked === "add-to-chat") {
216 const composer = composerRef?.current;
217 if (!composer) {
218 toastManager.add({
219 type: "error",
220 title: "Unable to add to chat",
221 description: "Open a chat for this project and try again.",
222 });
223 return;
224 }
225 const inserted = composer.insertTextAtEnd(`${mention} `, { ensureLeadingBoundary: true });
226 if (!inserted) {
227 toastManager.add({
228 type: "error",
229 title: "Unable to add to chat",
230 description: "The chat isn't ready to accept input right now.",
231 });
232 }
233 }
234 } finally {
235 context.close();
236 }
237 };
238 const showEntryContextMenuRef = useRef(showEntryContextMenu);
239 useEffect(() => {
240 showEntryContextMenuRef.current = showEntryContextMenu;
241 });
242
243 // The tree reads decorations at render time; a folder still loading its
244 // children shows a spinner in its row instead of a banner that shifts the tree.
245 const loadingDirectoriesRef = useRef(loadingDirectories);
246 const treeModelRef = useRef<ReturnType<typeof useFileTree>["model"] | null>(null);
247 const dragMention = useMemo(
248 () =>
249 createFileTreeDragMentionController({
250 deselect: (path) => treeModelRef.current?.getItem(path)?.deselect(),
251 }),
252 [],
253 );
254 const { model } = useFileTree({
255 composition: {
256 contextMenu: {
257 triggerMode: "right-click",
258 onOpen: (item, context) => {
259 void showEntryContextMenuRef.current(item, context);
260 },
261 },
262 },
263 // Rows only need to be draggable so entries can be dropped into the chat
264 // composer; rearranging files inside the tree stays off.
265 dragAndDrop: { canDrop: () => false },
266 density: "compact",
267 fileTreeSearchMode: "hide-non-matches",
268 flattenEmptyDirectories: true,
269 initialExpansion: "closed",
270 icons: T3_PIERRE_ICONS,
271 onSelectionChange: (selectedPaths) => {
272 // The drag controller's selection cache must track every change,
273 // including reveal-driven ones, or drags act on a stale selection.
274 dragMention.handleSelectionChange(selectedPaths);
275 // Selection changes driven by the reveal sync below are echoes of an
276 // already-open file, not a request to open it again.
277 if (syncingSelectionRef.current) return;
278 // Starting a drag selects the dragged row; that selection is a side
279 // effect of the gesture, not a request to open the file.
280 if (dragMention.isDragInProgress()) {
281 return;
282 }
283 const selectedPath = selectedPaths.at(-1)?.replace(/\/$/, "");
284 if (selectedPath && entryKindsRef.current.get(selectedPath) === "file") {
285 treeSelectionPathRef.current = selectedPath;
286 onOpenFile(selectedPath);
287 }
288 },
289 paths: [],
290 renderRowDecoration: ({ item, row }) =>
291 row.kind === "directory" &&
292 row.isExpanded &&
293 loadingDirectoriesRef.current.has(item.path.replace(/\/$/, ""))
294 ? { icon: "t3-tree-icon-loading", title: "Loading…" }
295 : null,
296 search: false,
297 onSearchChange: (value) => setQuery(value ?? ""),
298 unsafeCSS: PIERRE_TREE_UNSAFE_CSS,
299 });
300 useEffect(() => {
301 if (loadingDirectoriesRef.current === loadingDirectories) return;
302 loadingDirectoriesRef.current = loadingDirectories;
303 // Re-render the rows with the current options so decorations update.
304 model.setComposition(model.getComposition());
305 }, [loadingDirectories, model]);
306 const search = useFileTreeSearch(model);
307 const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) =>
308 areAllDirectoriesExpanded(currentModel, directoryPaths),
309 );
310 const toggleAllDirectories = () => {
311 const expanded = !(expandAll || allDirectoriesExpanded);
312 setExpandAll(expanded);
313 setAllDirectoriesExpanded(model, directoryPaths, expanded);
314 };
315 const closeSearch = () => {
316 setQuery("");
317 search.close();
318 };
319 const expandedPathsRef = useRef(new Set<string>());
320 useEffect(() => {
321 const currentPaths = new Set(directoryPaths);
322 for (const path of expandedPathsRef.current) {
323 if (!currentPaths.has(path)) expandedPathsRef.current.delete(path);
324 }
325 const loadExpanded = () => {
326 if (model.isSearchOpen()) return;
327 for (const path of directoryPaths) {
328 const item = model.getItem(path);
329 if (item?.isDirectory() && "isExpanded" in item && item.isExpanded()) {
330 if (!expandedPathsRef.current.has(path)) {
331 expandedPathsRef.current.add(path);
332 void load(path.replace(/\/$/, ""));
333 }
334 } else {
335 if (item?.isDirectory() && expandedPathsRef.current.has(path)) setExpandAll(false);
336 expandedPathsRef.current.delete(path);
337 }
338 }
339 };
340 loadExpanded();
341 return model.subscribe(loadExpanded);
342 }, [directoryPaths, load, model]);
343 useEffect(() => {
344 model.setGitStatus(
345 entries
346 .filter((entry) => entry.ignored)
347 .map((entry) => ({
348 path: treePath(entry),
349 status: "ignored",
350 })),
351 );
352 }, [entries, model]);
353 useEffect(() => {
354 if (!selectedPath) return;
355 const controller = new AbortController();
356 void (async () => {
357 const segments = selectedPath.split("/");
358 for (let index = 0; index < segments.length && !controller.signal.aborted; index++) {
359 await load(segments.slice(0, index).join("/"));
360 }
361 })();
362 return () => {
363 controller.abort();
364 };
365 }, [load, selectedPath]);
366 const handleSearchValueChange = (value: string) => {
367 setQuery(value);
368 if (value.trim().length === 0) {
369 search.close();
370 return;
371 }
372 search.setValue(value);
373 };
374 const handleRefresh = () => {
375 refresh();
376 if (query.trim()) pathSearch.refresh();
377 onRefreshSelectedFile?.();
378 };
379 useWorkspaceMutationRefresh({
380 mutationId: workspaceMutationId,
381 refresh: () => {
382 refresh();
383 if (query.trim()) pathSearch.refresh();
384 },
385 resourceKey: `files:${environmentId}:${cwd}`,
386 });
387
388 useEffect(() => {
389 if (!ready) return;
390 if (previousTreePathsRef.current === treePaths) return;
391 entryKindsRef.current = entryKinds;
392 const previousTreePaths = previousTreePathsRef.current;
393 previousTreePathsRef.current = treePaths;
394 if (previousTreePaths === null) {
395 model.resetPaths(treePaths);
396 return;
397 }
398 const updates = buildFileTreePathUpdates(previousTreePaths, treePaths);
399 if (updates.length > 0) model.batch(updates);
400 }, [ready, entryKinds, model, treePaths]);
401
402 useEffect(() => {
403 if (expandAll && !query.trim()) setAllDirectoriesExpanded(model, directoryPaths, true);
404 }, [directoryPaths, expandAll, model, query]);
405
406 useEffect(() => {
407 if (!selectedPath) {
408 handledRevealRef.current = null;
409 return;
410 }
411 const selectedKind = entryKinds.get(selectedPath);
412 // An unloaded entry has no row to reveal yet; folders do, and chat links can
413 // point at them.
414 if (selectedKind === undefined) {
415 handledRevealRef.current = null;
416 return;
417 }
418 const revealRequest = { path: selectedPath, revealId: selectedPathRevealId };
419 const handledReveal = handledRevealRef.current;
420 // Entry refreshes rebuild treePaths while the same preview stays open.
421 // Replaying a handled reveal would close an active tree search and steal focus.
422 if (
423 handledReveal?.path === revealRequest.path &&
424 handledReveal.revealId === revealRequest.revealId
425 ) {
426 return;
427 }
428 // Directory rows are registered with a trailing slash (see treePath).
429 const selectedTreePath = selectedKind === "directory" ? `${selectedPath}/` : selectedPath;
430 const selectedItem = model.getItem(selectedTreePath);
431 if (!selectedItem) return;
432
433 // A selection that originated inside the tree (clicking a row, possibly
434 // in an active tree search) is already visible; re-revealing it would
435 // close the search and clobber the user's context. Only sync external
436 // opens (file picker, content search, chat links).
437 const selectedInTree = model
438 .getSelectedPaths()
439 .some((path) => path.replace(/\/$/, "") === selectedPath);
440 if (selectedInTree && treeSelectionPathRef.current === selectedPath) {
441 treeSelectionPathRef.current = null;
442 handledRevealRef.current = revealRequest;
443 return;
444 }
445 treeSelectionPathRef.current = null;
446 handledRevealRef.current = revealRequest;
447
448 syncingSelectionRef.current = true;
449 setQuery("");
450 model.closeSearch();
451 for (const path of model.getSelectedPaths()) {
452 model.getItem(path)?.deselect();
453 }
454
455 // Directory rows are registered with a trailing slash (see treePath), so
456 // ancestor lookups must use the same form to expand them.
457 const segments = selectedPath.split("/");
458 let ancestorPath = "";
459 for (const segment of segments.slice(0, -1)) {
460 ancestorPath = ancestorPath ? `${ancestorPath}/${segment}` : segment;
461 const item = model.getItem(`${ancestorPath}/`) ?? model.getItem(ancestorPath);
462 if (item && "expand" in item) item.expand();
463 }
464
465 if ("expand" in selectedItem) selectedItem.expand();
466 selectedItem.select();
467 model.scrollToPath(selectedTreePath, {
468 focus: true,
469 offset: "center",
470 });
471 queueMicrotask(() => {
472 syncingSelectionRef.current = false;
473 });
474 }, [entryKinds, model, selectedPath, selectedPathRevealId]);
475
476 // Tag tree drags with the composer mention payload. The row is read from
477 // the composed event path (the tree's shadow root is open), so this does
478 // not depend on running after the tree's own dragstart handler; the drag
479 // data store is writable for every dragstart listener in the dispatch.
480 // The capture phase runs before the tree's own dragstart handler selects
481 // the dragged row, so the drag flag is up before that selection emits.
482 const panelRef = useRef<HTMLDivElement | null>(null);
483 useEffect(() => {
484 treeModelRef.current = model;
485 }, [model]);
486 useEffect(() => {
487 const panel = panelRef.current;
488 if (panel === null) {
489 return;
490 }
491 const handleDragStart = (event: DragEvent) => dragMention.handleDragStart(event);
492 const handleDragEnd = () => dragMention.handleDragEnd();
493 panel.addEventListener("dragstart", handleDragStart, true);
494 panel.addEventListener("dragend", handleDragEnd);
495 return () => {
496 panel.removeEventListener("dragstart", handleDragStart, true);
497 panel.removeEventListener("dragend", handleDragEnd);
498 };
499 }, [dragMention]);
500
501 return (
502 <div
503 ref={panelRef}
504 className="flex min-h-0 flex-1 flex-col bg-background"
505 data-file-browser-panel={`${environmentId}:${cwd}`}
506 >
507 <div
508 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"
509 data-surface-subheader
510 >
511 <RefreshFilesButton isPending={isPending} onRefresh={handleRefresh} />
512 <FileSearchField
513 name="project-files-search"
514 ariaLabel={`Search ${projectName} files`}
515 value={search.value}
516 onValueChange={handleSearchValueChange}
517 onClose={closeSearch}
518 />
519 {directoryPaths.length > 0 ? (
520 <Tooltip>
521 <TooltipTrigger
522 render={
523 <Button
524 type="button"
525 size="icon-xs"
526 variant="ghost"
527 aria-label={
528 expandAll || allDirectoriesExpanded
529 ? "Collapse all folders"
530 : "Expand all folders"
531 }
532 onClick={toggleAllDirectories}
533 />
534 }
535 >
536 <MorphIcon
537 className="size-3.5"
538 icon={allDirectoriesExpanded ? ChevronsDownUp : ChevronsUpDown}
539 />
540 </TooltipTrigger>
541 <TooltipPopup>
542 {expandAll || allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"}
543 </TooltipPopup>
544 </Tooltip>
545 ) : null}
546 </div>
547 {error || pathSearch.error ? (
548 <button
549 type="button"
550 onClick={handleRefresh}
551 className="p-4 text-left text-xs leading-relaxed text-destructive"
552 >
553 {error ?? pathSearch.error} Click to retry.
554 </button>
555 ) : null}
556 {query.trim() && pathSearch.truncated && !pathSearch.isPending ? (
557 <div className="px-3 py-1 text-xs text-muted-foreground">
558 More matches available. Refine your search.
559 </div>
560 ) : null}
561 {(!ready || pathSearch.isPending) && (
562 <div role="status" className="px-3 py-1 text-xs text-muted-foreground">
563 Loading files…
564 </div>
565 )}
566 <FileTree
567 model={model}
568 aria-label={`${projectName} files`}
569 className="min-h-0 flex-1 overflow-hidden"
570 style={pierreTreeStyle(resolvedTheme)}
571 />
572 </div>
573 );
574}