mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Workspaces let users and agents inspect files that belong to an issue > - Changed-file views use full-tree Git status scans > - Many issue views could start those scans at the same time and make the server unresponsive > - Route-level limits did not protect the process or coalesce work for one repository > - This pull request adds one bounded scheduler for every expensive workspace Git scan > - It also starts browser scans only when the file panel is open and visible > - The benefit is bounded child-process use and responsive health checks during request storms ## Linked Issues or Issue Description **What happened?** Many changed-file requests could start full `git status --porcelain=v1 -z --untracked-files=all` scans at the same time. One production incident produced about 270 direct Git child processes. The Node process stayed alive but stopped answering health requests in time. **Expected behavior** Paperclip must bound expensive Git work across all companies, actors, issues, repositories, and browser tabs. Duplicate requests for one worktree must share work. Excess requests must fail fast with a retryable response. Hidden or closed file panels must not start scans. **Steps to reproduce** 1. Open changed-file views for many issue and actor keys. 2. Send requests for two large workspace roots at the same time. 3. Observe that route-level limiter keys allow many full Git scans to run together. 4. Observe delayed health responses and accumulated Git children. **Paperclip version or commit** Reproduced on master before commit `43ab441f0f`. **Deployment mode** Self-hosted server with local workspace repositories. ## What Changed - Add a process-wide scheduler with configurable concurrency, queue capacity, timeout, and cache TTL. - Add fair admission, a bounded queue, canonical worktree keys, single-flight joins, and bounded result caching. - Add subprocess timeouts, TERM-to-KILL escalation, bounded output, waiter cancellation, and slot cleanup. - Route full-tree status work from file resources, workspace runtime, execution workspaces, and adapter overlay sync through the scheduler. - Return stable retryable `503` and `504` error codes for saturation and timeout. - Add structured logs with safe workspace hashes, durations, queue state, cache use, joins, and terminal outcomes. - Gate UI queries on panel and document visibility. Cancel queries on close, hide, unmount, and workspace change. - Disable focus and reconnect bursts. Keep one explicit refresh action and a retryable unavailable state. - Document the 10-second default freshness tradeoff and all configuration variables. - Add unit, route, UI, adapter, and deterministic 500-request load coverage. ## Verification - `pnpm -r typecheck` - `pnpm build` - `pnpm check:token-gates` - `pnpm --filter @paperclipai/server exec vitest run src/services/workspace-git-operation-scheduler.test.ts src/__tests__/file-resources-git-scan-load.test.ts --reporter=dot` — 16 tests passed. - `pnpm --filter @paperclipai/ui exec vitest run src/components/WorkspaceFileBrowser.test.tsx src/lib/page-visibility.test.ts --reporter=dot` — 38 tests passed. - `pnpm --filter @paperclipai/adapter-utils exec vitest run src/git-workspace-sync.test.ts --reporter=dot` — 16 tests passed. - Existing file-resource, workspace-runtime, and execution-workspace regression selections passed. - Two cleanup safety regressions prove failed scans preserve the worktree before archive and at the final deletion fence. - Before: the incident produced about 270 Git children and health requests timed out. - After: 500 concurrent requests across 500 issue keys, 73 actors, and two roots started two underlying scans. Peak scan concurrency was 2. All 500 requests succeeded. Health p99 was 4.94 ms. The harness found zero unreaped children. - The full local Vitest run passed 4,267 tests. Ten existing fixed-port HTTPS exposure tests could not run because this host already owns Tailnet listeners on ports 42000 and 52000. Clean GitHub CI is the final full-suite result. - Latest-head GitHub CI passed all required test, typecheck, build, canary, e2e, policy, and security gates. - Greptile completed at 5/5 with zero unresolved comments, recommendations, or follow-ups. ## Risks - Changed-file results can be up to 10 seconds old by default. Explicit refresh remains available. - A full queue returns a retryable `503` instead of waiting without a bound. - A scan that exceeds the default 8-second deadline returns a retryable `504` and terminates its process group. - Operators can tune all limits with documented environment variables. Safe defaults protect local and shared servers. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 family. The runtime does not expose the exact deployment ID or context-window size. High reasoning, tool use, and code execution were enabled. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1159 lines
42 KiB
TypeScript
1159 lines
42 KiB
TypeScript
import {
|
|
useEffect,
|
|
useId,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type KeyboardEvent,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { AlertTriangle, ChevronDown, ChevronRight, Cloud, Download, FileCode2, FolderOpen, Loader2, RefreshCcw, Search } from "lucide-react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { cn } from "@/lib/utils";
|
|
import { fileResourcesApi } from "@/api/file-resources";
|
|
import { projectsApi } from "@/api/projects";
|
|
import { ApiError } from "@/api/client";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
import { usePageVisibility } from "@/lib/page-visibility";
|
|
import { parseWorkspaceFileRef } from "@/lib/workspace-file-parser";
|
|
import type {
|
|
Project,
|
|
WorkspaceFileListItem,
|
|
WorkspaceFileListFileItem,
|
|
WorkspaceFileListMode,
|
|
WorkspaceFileSelector,
|
|
} from "@paperclipai/shared";
|
|
|
|
type BrowserSource = "current" | "other";
|
|
|
|
// Hard list cap. The spec called out ~50 to keep reads cheap; 100 trades a bit
|
|
// more scan for fewer "refine to narrow" dead-ends on large trees. Footer always
|
|
// discloses truncation so the cap is never silent.
|
|
const LIST_LIMIT = 100;
|
|
|
|
function basename(path: string): string {
|
|
const idx = path.lastIndexOf("/");
|
|
return idx < 0 ? path : path.slice(idx + 1);
|
|
}
|
|
|
|
function normalizeFolderPrefix(path: string | null | undefined): string {
|
|
if (!path) return "";
|
|
const trimmed = path.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
return trimmed ? `${trimmed}/` : "";
|
|
}
|
|
|
|
function parentFolderPath(path: string | null | undefined): string | null {
|
|
const trimmed = path?.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
if (!trimmed) return null;
|
|
const index = trimmed.lastIndexOf("/");
|
|
return index > 0 ? trimmed.slice(0, index) : null;
|
|
}
|
|
|
|
function folderKey(path: string | null | undefined): string {
|
|
return path?.replace(/^\/+/, "").replace(/\/+$/, "") ?? "";
|
|
}
|
|
|
|
function selectedAncestorFolders(selectedPath: string | null | undefined, rootPath: string | null | undefined): string[] {
|
|
const parent = parentFolderPath(selectedPath);
|
|
if (!parent) return [];
|
|
const root = folderKey(rootPath);
|
|
const parentSegments = parent.split("/").filter(Boolean);
|
|
const rootSegments = root ? root.split("/").filter(Boolean) : [];
|
|
if (rootSegments.some((segment, index) => parentSegments[index] !== segment)) return [];
|
|
const paths: string[] = [];
|
|
for (let index = rootSegments.length; index < parentSegments.length; index += 1) {
|
|
paths.push(parentSegments.slice(0, index + 1).join("/"));
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
/**
|
|
* Maps a server `unavailableReason` to a calm, board-readable explanation.
|
|
* Copy is kept in sync with `describeDenial` in FileViewerSheet so the browse
|
|
* states and the viewer's error panels read in one voice. Substring matching
|
|
* keeps it resilient to small reason-string changes on the server.
|
|
*/
|
|
export function describeUnavailable(reason: string): { title: string; body: string; icon: ReactNode } {
|
|
const lower = reason.toLowerCase();
|
|
if (lower.includes("remote")) {
|
|
return {
|
|
icon: <Cloud aria-hidden="true" className="h-5 w-5 text-muted-foreground" />,
|
|
title: "Remote workspace preview not supported",
|
|
body: "This workspace is hosted remotely and is not available for inline preview yet.",
|
|
};
|
|
}
|
|
if (lower.includes("no_workspace") || lower.includes("no_local")) {
|
|
return {
|
|
icon: <FolderOpen aria-hidden="true" className="h-5 w-5 text-muted-foreground" />,
|
|
title: "No workspace yet",
|
|
body: "This issue does not have a workspace to browse. Files appear here once a run creates one.",
|
|
};
|
|
}
|
|
if (lower.includes("archiv") || lower.includes("cleaned") || lower.includes("unavailable")) {
|
|
return {
|
|
icon: <FolderOpen aria-hidden="true" className="h-5 w-5 text-muted-foreground" />,
|
|
title: "Workspace is no longer available",
|
|
body: "The isolated worktree for this issue has been cleaned up, so files cannot be previewed.",
|
|
};
|
|
}
|
|
return {
|
|
icon: <AlertTriangle aria-hidden="true" className="h-5 w-5 text-amber-500" />,
|
|
title: "Workspace unavailable",
|
|
body: "These workspace files can't be browsed right now.",
|
|
};
|
|
}
|
|
|
|
function StateMessage({
|
|
icon,
|
|
title,
|
|
body,
|
|
actions,
|
|
}: {
|
|
icon: ReactNode;
|
|
title: string;
|
|
body?: string;
|
|
actions?: ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="flex items-start gap-3 px-1 py-8 text-sm">
|
|
{icon}
|
|
<div className="space-y-1">
|
|
<p className="font-medium text-foreground">{title}</p>
|
|
{body ? <p className="text-muted-foreground">{body}</p> : null}
|
|
{actions ? <div className="pt-1">{actions}</div> : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function WorkspaceFileBreadcrumbs({
|
|
rootLabel,
|
|
folderPath,
|
|
onOpenFolder,
|
|
}: {
|
|
rootLabel: string | null;
|
|
folderPath: string | null;
|
|
onOpenFolder: (path: string | null) => void;
|
|
}) {
|
|
const segments = folderPath?.split("/").filter(Boolean) ?? [];
|
|
if (!rootLabel && segments.length === 0) return null;
|
|
|
|
return (
|
|
<nav aria-label="Current folder" className="min-w-0 overflow-hidden text-(length:--text-micro) text-muted-foreground">
|
|
<ol className="flex min-w-0 items-center gap-1 overflow-hidden">
|
|
{rootLabel ? (
|
|
<li className="min-w-0 shrink">
|
|
<button
|
|
type="button"
|
|
onClick={() => onOpenFolder(null)}
|
|
className="max-w-full truncate rounded px-1 py-0.5 text-left hover:bg-accent hover:text-foreground"
|
|
title={rootLabel}
|
|
>
|
|
{rootLabel}
|
|
</button>
|
|
</li>
|
|
) : null}
|
|
{segments.map((segment, index) => {
|
|
const path = segments.slice(0, index + 1).join("/");
|
|
return (
|
|
<li key={path} className="flex min-w-0 shrink items-center gap-1">
|
|
<span aria-hidden="true" className="shrink-0 opacity-50">/</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => onOpenFolder(path)}
|
|
className="max-w-full truncate rounded px-1 py-0.5 text-left font-mono hover:bg-accent hover:text-foreground"
|
|
title={path}
|
|
>
|
|
{segment}
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ol>
|
|
</nav>
|
|
);
|
|
}
|
|
|
|
interface WorkspaceFileRowProps {
|
|
item: WorkspaceFileListFileItem;
|
|
treeItemId: string;
|
|
selected: boolean;
|
|
highlighted: boolean;
|
|
depth: number;
|
|
onOpen: () => void;
|
|
onHover: () => void;
|
|
downloadUrl: string | null;
|
|
}
|
|
|
|
function WorkspaceFileRow({ item, treeItemId, selected, highlighted, depth, onOpen, onHover, downloadUrl }: WorkspaceFileRowProps) {
|
|
const name = basename(item.relativePath);
|
|
return (
|
|
<div
|
|
id={treeItemId}
|
|
role="treeitem"
|
|
aria-selected={selected}
|
|
onClick={onOpen}
|
|
onMouseEnter={onHover}
|
|
title={item.displayPath}
|
|
className={cn(
|
|
"flex min-h-(--sz-36px) cursor-pointer items-center gap-2 rounded-md py-1.5 pr-2 sm:min-h-0",
|
|
selected ? "bg-accent text-foreground" : highlighted ? "bg-accent/50" : "hover:bg-accent/60",
|
|
)}
|
|
style={{ paddingLeft: `${0.5 + depth * 0.875}rem` }}
|
|
>
|
|
<FileCode2 aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
<span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">{name}</span>
|
|
{downloadUrl ? (
|
|
<a
|
|
href={downloadUrl}
|
|
download={name}
|
|
aria-label={`Download ${name}`}
|
|
title={`Download ${name}`}
|
|
onClick={(event) => event.stopPropagation()}
|
|
className="ml-auto inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-70 hover:bg-background/70 hover:text-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
<Download aria-hidden="true" className="h-3.5 w-3.5" />
|
|
</a>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface WorkspaceFileTreeFolderNode {
|
|
kind: "folder";
|
|
key: string;
|
|
name: string;
|
|
depth: number;
|
|
children: WorkspaceFileTreeNode[];
|
|
lazy: boolean;
|
|
}
|
|
|
|
interface WorkspaceFileTreeFileNode {
|
|
kind: "file";
|
|
key: string;
|
|
item: WorkspaceFileListFileItem;
|
|
depth: number;
|
|
}
|
|
|
|
type WorkspaceFileTreeNode = WorkspaceFileTreeFolderNode | WorkspaceFileTreeFileNode;
|
|
|
|
interface MutableTreeFolder {
|
|
kind: "folder";
|
|
key: string;
|
|
name: string;
|
|
depth: number;
|
|
folders: Map<string, MutableTreeFolder>;
|
|
files: WorkspaceFileTreeFileNode[];
|
|
}
|
|
|
|
function itemKey(item: Pick<WorkspaceFileListItem, "kind" | "workspaceId" | "relativePath">): string {
|
|
return `${item.kind}:${item.workspaceId}:${item.relativePath}`;
|
|
}
|
|
|
|
function compareTreeNodes(a: WorkspaceFileTreeNode, b: WorkspaceFileTreeNode): number {
|
|
if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
|
|
const aName = a.kind === "folder" ? a.name : basename(a.item.relativePath);
|
|
const bName = b.kind === "folder" ? b.name : basename(b.item.relativePath);
|
|
return aName.localeCompare(bName);
|
|
}
|
|
|
|
function finalizeTreeFolder(folder: MutableTreeFolder): WorkspaceFileTreeFolderNode {
|
|
const children: WorkspaceFileTreeNode[] = [
|
|
...Array.from(folder.folders.values()).map(finalizeTreeFolder),
|
|
...folder.files,
|
|
];
|
|
children.sort(compareTreeNodes);
|
|
return {
|
|
kind: "folder",
|
|
key: folder.key,
|
|
name: folder.name,
|
|
depth: folder.depth,
|
|
children,
|
|
lazy: false,
|
|
};
|
|
}
|
|
|
|
function buildWorkspaceFileTree(items: WorkspaceFileListItem[], rootPath: string | null | undefined) {
|
|
const rootPrefix = normalizeFolderPrefix(rootPath);
|
|
const root: MutableTreeFolder = {
|
|
kind: "folder",
|
|
key: "__root__",
|
|
name: "",
|
|
depth: -1,
|
|
folders: new Map(),
|
|
files: [],
|
|
};
|
|
|
|
for (const item of items) {
|
|
if (item.kind !== "file") continue;
|
|
const path = item.relativePath.startsWith(rootPrefix)
|
|
? item.relativePath.slice(rootPrefix.length)
|
|
: item.relativePath;
|
|
const segments = path.split("/").filter(Boolean);
|
|
const fileName = segments.pop() ?? basename(item.relativePath);
|
|
let cursor = root;
|
|
let currentPath = rootPrefix.replace(/\/$/, "");
|
|
for (const segment of segments) {
|
|
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
|
let folder = cursor.folders.get(segment);
|
|
if (!folder) {
|
|
folder = {
|
|
kind: "folder",
|
|
key: currentPath,
|
|
name: segment,
|
|
depth: cursor.depth + 1,
|
|
folders: new Map(),
|
|
files: [],
|
|
};
|
|
cursor.folders.set(segment, folder);
|
|
}
|
|
cursor = folder;
|
|
}
|
|
cursor.files.push({
|
|
kind: "file",
|
|
key: itemKey(item),
|
|
item: { ...item, title: fileName },
|
|
depth: cursor.depth + 1,
|
|
});
|
|
}
|
|
|
|
const tree = finalizeTreeFolder(root);
|
|
return tree.children;
|
|
}
|
|
|
|
function buildWorkspaceDirectoryTree(items: WorkspaceFileListItem[]) {
|
|
const nodes = items.map((item): WorkspaceFileTreeNode => {
|
|
if (item.kind === "directory") {
|
|
return {
|
|
kind: "folder",
|
|
key: item.relativePath,
|
|
name: basename(item.relativePath),
|
|
depth: 0,
|
|
children: [],
|
|
lazy: true,
|
|
};
|
|
}
|
|
return {
|
|
kind: "file",
|
|
key: itemKey(item),
|
|
item,
|
|
depth: 0,
|
|
};
|
|
});
|
|
nodes.sort(compareTreeNodes);
|
|
return nodes;
|
|
}
|
|
|
|
interface WorkspaceFileTreeProps {
|
|
nodes: WorkspaceFileTreeNode[];
|
|
listboxId: string;
|
|
highlightedItemKey: string | null;
|
|
selectedItemKey: string | null;
|
|
collapsedFolders: Set<string>;
|
|
expandedLazyFolders: Set<string>;
|
|
forcedExpandedFolders: Set<string>;
|
|
getLazyChildren?: (path: string, depth: number) => WorkspaceFileTreeNode[];
|
|
isLazyFolderFetching?: (path: string) => boolean;
|
|
isLazyFolderTruncated?: (path: string) => boolean;
|
|
onLoadMoreFolder?: (path: string) => void;
|
|
onToggleFolder: (key: string) => void;
|
|
onOpen: (item: WorkspaceFileListFileItem) => void;
|
|
onHoverFile: (item: WorkspaceFileListFileItem) => void;
|
|
getDownloadUrl: (item: WorkspaceFileListFileItem) => string | null;
|
|
}
|
|
|
|
function WorkspaceFileTree({
|
|
nodes,
|
|
listboxId,
|
|
highlightedItemKey,
|
|
selectedItemKey,
|
|
collapsedFolders,
|
|
expandedLazyFolders,
|
|
forcedExpandedFolders,
|
|
getLazyChildren,
|
|
isLazyFolderFetching,
|
|
isLazyFolderTruncated,
|
|
onLoadMoreFolder,
|
|
onToggleFolder,
|
|
onOpen,
|
|
onHoverFile,
|
|
getDownloadUrl,
|
|
}: WorkspaceFileTreeProps) {
|
|
function renderNode(node: WorkspaceFileTreeNode): ReactNode {
|
|
if (node.kind === "folder") {
|
|
const expanded = node.lazy
|
|
? forcedExpandedFolders.has(node.key) || expandedLazyFolders.has(node.key)
|
|
: forcedExpandedFolders.has(node.key) || !collapsedFolders.has(node.key);
|
|
const children = node.lazy ? getLazyChildren?.(node.key, node.depth + 1) ?? [] : node.children;
|
|
const loading = node.lazy && isLazyFolderFetching?.(node.key);
|
|
const truncated = node.lazy && isLazyFolderTruncated?.(node.key);
|
|
return (
|
|
<div key={node.key}>
|
|
<button
|
|
type="button"
|
|
role="treeitem"
|
|
aria-expanded={expanded}
|
|
title={node.key}
|
|
onClick={() => onToggleFolder(node.key)}
|
|
className="flex min-h-(--sz-32px) w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-accent/60"
|
|
style={{ paddingLeft: `${0.25 + node.depth * 0.875}rem` }}
|
|
>
|
|
{expanded ? (
|
|
<ChevronDown aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
) : (
|
|
<ChevronRight aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
)}
|
|
<FolderOpen aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
<span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">{node.name}</span>
|
|
</button>
|
|
{expanded ? (
|
|
<>
|
|
{children.map(renderNode)}
|
|
{loading ? (
|
|
<div
|
|
className="flex min-h-(--sz-30px) items-center gap-2 py-1 pr-2 text-xs text-muted-foreground"
|
|
style={{ paddingLeft: `${1 + (node.depth + 1) * 0.875}rem` }}
|
|
>
|
|
<Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
|
|
<span>Loading folder…</span>
|
|
</div>
|
|
) : null}
|
|
{truncated ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => onLoadMoreFolder?.(node.key)}
|
|
className="flex min-h-(--sz-30px) w-full items-center gap-2 rounded-md py-1 pr-2 text-left text-xs text-muted-foreground hover:bg-accent/60 hover:text-foreground"
|
|
style={{ paddingLeft: `${1 + (node.depth + 1) * 0.875}rem` }}
|
|
>
|
|
<span className="h-3.5 w-3.5 shrink-0" />
|
|
<span>Load more from this folder</span>
|
|
</button>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<WorkspaceFileRow
|
|
key={node.key}
|
|
item={node.item}
|
|
treeItemId={`${listboxId}-file-${node.key}`}
|
|
selected={node.key === selectedItemKey}
|
|
highlighted={node.key === highlightedItemKey}
|
|
depth={node.depth}
|
|
onOpen={() => onOpen(node.item)}
|
|
onHover={() => onHoverFile(node.item)}
|
|
downloadUrl={getDownloadUrl(node.item)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div role="tree" id={listboxId} aria-label="Workspace files" className="space-y-0.5 py-1">
|
|
{nodes.map(renderNode)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export interface WorkspaceFileBrowserProps {
|
|
issueId: string;
|
|
companyId?: string | null;
|
|
onOpen: (ref: {
|
|
path: string;
|
|
workspace: WorkspaceFileSelector;
|
|
line?: number | null;
|
|
column?: number | null;
|
|
projectId?: string | null;
|
|
workspaceId?: string | null;
|
|
browseFolderPath?: string | null;
|
|
browseQuery?: string | null;
|
|
}) => void;
|
|
onBrowseStateChange?: (state: {
|
|
q: string | null;
|
|
folderPath: string | null;
|
|
projectId: string | null;
|
|
workspaceId: string | null;
|
|
}) => void;
|
|
/** Seed the search field (e.g. from a URL-backed deep link). */
|
|
initialQuery?: string | null;
|
|
initialFolderPath?: string | null;
|
|
initialProjectId?: string | null;
|
|
initialWorkspaceId?: string | null;
|
|
autoFocusSearch?: boolean;
|
|
compact?: boolean;
|
|
selectedPath?: string | null;
|
|
selectedProjectId?: string | null;
|
|
selectedWorkspaceId?: string | null;
|
|
/** True only while the containing Files panel is actually open. */
|
|
active?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
export function WorkspaceFileBrowser({
|
|
issueId,
|
|
companyId,
|
|
onOpen,
|
|
onBrowseStateChange,
|
|
initialQuery,
|
|
initialFolderPath,
|
|
initialProjectId,
|
|
initialWorkspaceId,
|
|
autoFocusSearch = true,
|
|
selectedPath,
|
|
selectedProjectId: activeProjectId,
|
|
selectedWorkspaceId: activeWorkspaceId,
|
|
active = true,
|
|
className,
|
|
}: WorkspaceFileBrowserProps) {
|
|
const queryClient = useQueryClient();
|
|
const { visible: pageVisible } = usePageVisibility();
|
|
const source: BrowserSource =
|
|
initialProjectId && initialWorkspaceId ? "other" : "current";
|
|
const workspace: WorkspaceFileSelector = "auto";
|
|
const selectedParentPath = useMemo(() => parentFolderPath(selectedPath), [selectedPath]);
|
|
const effectiveInitialFolderPath = useMemo(() => {
|
|
if (typeof initialFolderPath !== "undefined") return initialFolderPath;
|
|
return initialQuery?.trim() ? null : selectedParentPath;
|
|
}, [initialFolderPath, initialQuery, selectedParentPath]);
|
|
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(initialProjectId ?? null);
|
|
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | null>(initialWorkspaceId ?? null);
|
|
const [folderPath, setFolderPath] = useState<string | null>(effectiveInitialFolderPath ?? null);
|
|
const [searchInput, setSearchInput] = useState(initialQuery ?? "");
|
|
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery?.trim() ?? "");
|
|
// When the workspace has no git change-tracking we silently fall back to a full
|
|
// listing for the default (empty-query) view, per spec.
|
|
const [recentUnavailable, setRecentUnavailable] = useState(false);
|
|
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
|
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(() => new Set());
|
|
const [expandedLazyFolders, setExpandedLazyFolders] = useState<Set<string>>(() => new Set());
|
|
const [folderPageCounts, setFolderPageCounts] = useState<Record<string, number>>({});
|
|
|
|
const listboxId = useId();
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const projectsQuery = useQuery({
|
|
queryKey: companyId ? queryKeys.projects.list(companyId) : ["projects", "__none__"],
|
|
queryFn: () => projectsApi.list(companyId!),
|
|
enabled: source === "other" && !!companyId,
|
|
retry: false,
|
|
staleTime: 30_000,
|
|
});
|
|
|
|
const projectRows = useMemo(
|
|
() => Array.isArray(projectsQuery.data) ? projectsQuery.data : [],
|
|
[projectsQuery.data],
|
|
);
|
|
const projectsWithWorkspaces = useMemo(
|
|
() => projectRows.filter((project: Project) => project.workspaces.length > 0),
|
|
[projectRows],
|
|
);
|
|
const selectedProject = useMemo(
|
|
() => projectsWithWorkspaces.find((project) => project.id === selectedProjectId) ?? null,
|
|
[projectsWithWorkspaces, selectedProjectId],
|
|
);
|
|
const selectedWorkspace = useMemo(
|
|
() => selectedProject?.workspaces.find((item) => item.id === selectedWorkspaceId) ?? null,
|
|
[selectedProject, selectedWorkspaceId],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (source !== "other" || !projectsQuery.data) return;
|
|
const nextProject = selectedProject ?? projectsWithWorkspaces[0] ?? null;
|
|
if (!nextProject) {
|
|
setSelectedProjectId(null);
|
|
setSelectedWorkspaceId(null);
|
|
return;
|
|
}
|
|
if (selectedProjectId !== nextProject.id) {
|
|
setSelectedProjectId(nextProject.id);
|
|
}
|
|
const nextWorkspace = nextProject.workspaces.find((item) => item.id === selectedWorkspaceId)
|
|
?? nextProject.primaryWorkspace
|
|
?? nextProject.workspaces[0]
|
|
?? null;
|
|
setSelectedWorkspaceId(nextWorkspace?.id ?? null);
|
|
}, [projectsQuery.data, projectsWithWorkspaces, selectedProject, selectedProjectId, selectedWorkspaceId, source]);
|
|
|
|
useEffect(() => {
|
|
setSelectedProjectId(initialProjectId ?? null);
|
|
setSelectedWorkspaceId(initialWorkspaceId ?? null);
|
|
}, [initialProjectId, initialWorkspaceId]);
|
|
|
|
useEffect(() => {
|
|
setFolderPath(effectiveInitialFolderPath ?? null);
|
|
setSearchInput(initialQuery ?? "");
|
|
setDebouncedQuery(initialQuery?.trim() ?? "");
|
|
}, [effectiveInitialFolderPath, initialQuery]);
|
|
|
|
useEffect(() => {
|
|
const handle = window.setTimeout(() => setDebouncedQuery(searchInput.trim()), 150);
|
|
return () => window.clearTimeout(handle);
|
|
}, [searchInput]);
|
|
|
|
// A new search or workspace should re-attempt recent-change tracking.
|
|
useEffect(() => {
|
|
setRecentUnavailable(false);
|
|
}, [workspace, source, selectedProjectId, selectedWorkspaceId, folderPath]);
|
|
|
|
useEffect(() => {
|
|
setExpandedLazyFolders(new Set());
|
|
setFolderPageCounts({});
|
|
}, [workspace, source, selectedProjectId, selectedWorkspaceId, folderPath, debouncedQuery]);
|
|
|
|
const q = debouncedQuery || null;
|
|
const isSearch = q !== null;
|
|
const mode: WorkspaceFileListMode = folderPath || isSearch || selectedPath ? "all" : recentUnavailable ? "all" : "changed";
|
|
const targetProjectId = source === "other" ? selectedProjectId : null;
|
|
const targetWorkspaceId = source === "other" ? selectedWorkspaceId : null;
|
|
const effectiveWorkspace: WorkspaceFileSelector = source === "other" ? "project" : workspace;
|
|
const canListFiles = source === "current" || Boolean(targetProjectId && targetWorkspaceId);
|
|
const queriesEnabled = active && pageVisible && canListFiles;
|
|
const targetRef = targetProjectId && targetWorkspaceId
|
|
? { projectId: targetProjectId, workspaceId: targetWorkspaceId }
|
|
: {};
|
|
|
|
useEffect(() => {
|
|
onBrowseStateChange?.({
|
|
q: searchInput.trim() || null,
|
|
folderPath,
|
|
projectId: targetProjectId,
|
|
workspaceId: targetWorkspaceId,
|
|
});
|
|
}, [folderPath, onBrowseStateChange, searchInput, targetProjectId, targetWorkspaceId]);
|
|
|
|
const listQueryKey = useMemo(() => queryKeys.issues.fileResources(issueId, {
|
|
workspace: effectiveWorkspace,
|
|
projectId: targetProjectId,
|
|
workspaceId: targetWorkspaceId,
|
|
mode,
|
|
q,
|
|
limit: LIST_LIMIT,
|
|
offset: 0,
|
|
path: folderPath,
|
|
}), [effectiveWorkspace, folderPath, issueId, mode, q, targetProjectId, targetWorkspaceId]);
|
|
const listQuery = useQuery({
|
|
queryKey: listQueryKey,
|
|
queryFn: ({ signal }) => fileResourcesApi.list(issueId, {
|
|
workspace: effectiveWorkspace,
|
|
projectId: targetProjectId,
|
|
workspaceId: targetWorkspaceId,
|
|
mode,
|
|
q,
|
|
limit: LIST_LIMIT,
|
|
offset: 0,
|
|
path: folderPath,
|
|
}, { signal }),
|
|
enabled: queriesEnabled,
|
|
retry: false,
|
|
staleTime: 15_000,
|
|
refetchOnWindowFocus: false,
|
|
refetchOnReconnect: false,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (active && pageVisible) return;
|
|
void queryClient.cancelQueries({
|
|
queryKey: ["issues", "file-resources", issueId, "list"],
|
|
});
|
|
}, [active, issueId, pageVisible, queryClient]);
|
|
|
|
const data = listQuery.data;
|
|
const items = useMemo(() => data?.items ?? [], [data]);
|
|
const workspaceLabel = data?.workspace?.workspaceLabel ?? null;
|
|
const isLazyBrowse = data?.query.mode === "all" && !q;
|
|
const currentFolderKey = folderKey(folderPath);
|
|
const selectedFolders = useMemo(
|
|
() => selectedAncestorFolders(selectedPath, folderPath),
|
|
[folderPath, selectedPath],
|
|
);
|
|
const forcedExpandedFolders = useMemo(() => new Set(selectedFolders), [selectedFolders]);
|
|
const loadedLazyFolders = useMemo(() => {
|
|
if (!isLazyBrowse) return [];
|
|
return Array.from(new Set([...expandedLazyFolders, ...selectedFolders]))
|
|
.filter((path) => path !== currentFolderKey)
|
|
.sort();
|
|
}, [currentFolderKey, expandedLazyFolders, isLazyBrowse, selectedFolders]);
|
|
const folderPageSpecs = useMemo(() => {
|
|
if (!canListFiles || !isLazyBrowse) return [];
|
|
const specs: Array<{ path: string; pageIndex: number; offset: number }> = [];
|
|
for (const path of loadedLazyFolders) {
|
|
const pageCount = folderPageCounts[path] ?? 1;
|
|
for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) {
|
|
specs.push({ path, pageIndex, offset: pageIndex * LIST_LIMIT });
|
|
}
|
|
}
|
|
const rootPageCount = folderPageCounts[currentFolderKey] ?? 1;
|
|
for (let pageIndex = 1; pageIndex < rootPageCount; pageIndex += 1) {
|
|
specs.push({ path: currentFolderKey, pageIndex, offset: pageIndex * LIST_LIMIT });
|
|
}
|
|
return specs;
|
|
}, [canListFiles, currentFolderKey, folderPageCounts, isLazyBrowse, loadedLazyFolders]);
|
|
const folderPageQueries = useQueries({
|
|
queries: folderPageSpecs.map((spec) => ({
|
|
queryKey: queryKeys.issues.fileResources(issueId, {
|
|
workspace: effectiveWorkspace,
|
|
projectId: targetProjectId,
|
|
workspaceId: targetWorkspaceId,
|
|
mode: "all",
|
|
q: null,
|
|
limit: LIST_LIMIT,
|
|
offset: spec.offset,
|
|
path: spec.path || null,
|
|
}),
|
|
queryFn: ({ signal }: { signal: AbortSignal }) => fileResourcesApi.list(issueId, {
|
|
workspace: effectiveWorkspace,
|
|
projectId: targetProjectId,
|
|
workspaceId: targetWorkspaceId,
|
|
mode: "all",
|
|
q: null,
|
|
limit: LIST_LIMIT,
|
|
offset: spec.offset,
|
|
path: spec.path || null,
|
|
}, { signal }),
|
|
enabled: queriesEnabled && isLazyBrowse,
|
|
retry: false,
|
|
staleTime: 15_000,
|
|
refetchOnWindowFocus: false,
|
|
refetchOnReconnect: false,
|
|
})),
|
|
});
|
|
const lazyItemsByFolder = useMemo(() => {
|
|
const map = new Map<string, WorkspaceFileListItem[]>();
|
|
if (isLazyBrowse) map.set(currentFolderKey, [...items]);
|
|
folderPageSpecs.forEach((spec, index) => {
|
|
const response = folderPageQueries[index]?.data;
|
|
if (!response || response.state !== "available") return;
|
|
const current = map.get(spec.path) ?? [];
|
|
current.push(...response.items);
|
|
map.set(spec.path, current);
|
|
});
|
|
return map;
|
|
}, [currentFolderKey, folderPageQueries, folderPageSpecs, isLazyBrowse, items]);
|
|
const lazyTruncatedFolders = useMemo(() => {
|
|
const pageState = new Map<string, { pageIndex: number; truncated: boolean }>();
|
|
if (isLazyBrowse) pageState.set(currentFolderKey, { pageIndex: 0, truncated: Boolean(data?.truncated) });
|
|
folderPageSpecs.forEach((spec, index) => {
|
|
const response = folderPageQueries[index]?.data;
|
|
if (!response) return;
|
|
const existing = pageState.get(spec.path);
|
|
if (!existing || spec.pageIndex >= existing.pageIndex) {
|
|
pageState.set(spec.path, { pageIndex: spec.pageIndex, truncated: response.truncated });
|
|
}
|
|
});
|
|
const set = new Set<string>();
|
|
for (const [path, state] of pageState) {
|
|
if (state.truncated) set.add(path);
|
|
}
|
|
return set;
|
|
}, [currentFolderKey, data?.truncated, folderPageQueries, folderPageSpecs, isLazyBrowse]);
|
|
const lazyFetchingFolders = useMemo(() => {
|
|
const set = new Set<string>();
|
|
folderPageSpecs.forEach((spec, index) => {
|
|
if (folderPageQueries[index]?.isFetching) set.add(spec.path);
|
|
});
|
|
return set;
|
|
}, [folderPageQueries, folderPageSpecs]);
|
|
const lazyErroredFolders = useMemo(() => {
|
|
const set = new Set<string>();
|
|
folderPageSpecs.forEach((spec, index) => {
|
|
if (folderPageQueries[index]?.isError) set.add(spec.path);
|
|
});
|
|
return set;
|
|
}, [folderPageQueries, folderPageSpecs]);
|
|
const allLoadedItems = useMemo(() => {
|
|
if (!isLazyBrowse) return items;
|
|
return Array.from(lazyItemsByFolder.values()).flat();
|
|
}, [isLazyBrowse, items, lazyItemsByFolder]);
|
|
const allLoadedFileItems = useMemo(
|
|
() => allLoadedItems.filter((item): item is WorkspaceFileListFileItem => item.kind === "file"),
|
|
[allLoadedItems],
|
|
);
|
|
const treeNodes = useMemo(() => {
|
|
if (isLazyBrowse) {
|
|
return buildWorkspaceDirectoryTree(lazyItemsByFolder.get(currentFolderKey) ?? items);
|
|
}
|
|
return buildWorkspaceFileTree(items, folderPath);
|
|
}, [currentFolderKey, folderPath, isLazyBrowse, items, lazyItemsByFolder]);
|
|
const selectedItemIndex = selectedPath
|
|
? allLoadedFileItems.findIndex((item) =>
|
|
item.relativePath === selectedPath &&
|
|
(activeProjectId ? item.projectId === activeProjectId : true) &&
|
|
(activeWorkspaceId ? item.workspaceId === activeWorkspaceId : true)
|
|
)
|
|
: -1;
|
|
|
|
// Silent fallback: empty-query view with no change-tracking → list everything.
|
|
useEffect(() => {
|
|
if (!isSearch && data?.state === "unavailable" && (data.unavailableReason ?? "").toLowerCase().includes("changed")) {
|
|
setRecentUnavailable(true);
|
|
}
|
|
}, [data, isSearch]);
|
|
|
|
// Keep the highlighted option valid as results change.
|
|
useEffect(() => {
|
|
if (selectedPath) {
|
|
setHighlightedIndex(selectedItemIndex >= 0 ? selectedItemIndex : -1);
|
|
return;
|
|
}
|
|
setHighlightedIndex(allLoadedFileItems.length > 0 ? 0 : -1);
|
|
}, [allLoadedFileItems.length, q, workspace, source, selectedProjectId, selectedWorkspaceId, folderPath, selectedPath, selectedItemIndex]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedPath || !isLazyBrowse) return;
|
|
const selectedFolderKey = folderKey(parentFolderPath(selectedPath));
|
|
if (selectedFolderKey !== currentFolderKey && !loadedLazyFolders.includes(selectedFolderKey)) return;
|
|
if (!lazyTruncatedFolders.has(selectedFolderKey)) return;
|
|
if (lazyFetchingFolders.has(selectedFolderKey) || lazyErroredFolders.has(selectedFolderKey)) return;
|
|
const loadedItems = lazyItemsByFolder.get(selectedFolderKey) ?? [];
|
|
const selectedLoaded = loadedItems.some((item) =>
|
|
item.kind === "file" &&
|
|
item.relativePath === selectedPath &&
|
|
(activeProjectId ? item.projectId === activeProjectId : true) &&
|
|
(activeWorkspaceId ? item.workspaceId === activeWorkspaceId : true)
|
|
);
|
|
if (selectedLoaded) return;
|
|
setFolderPageCounts((current) => ({
|
|
...current,
|
|
[selectedFolderKey]: (current[selectedFolderKey] ?? 1) + 1,
|
|
}));
|
|
}, [
|
|
activeProjectId,
|
|
activeWorkspaceId,
|
|
currentFolderKey,
|
|
isLazyBrowse,
|
|
lazyErroredFolders,
|
|
lazyFetchingFolders,
|
|
lazyItemsByFolder,
|
|
lazyTruncatedFolders,
|
|
loadedLazyFolders,
|
|
selectedPath,
|
|
]);
|
|
|
|
const announcement = useMemo(() => {
|
|
if (listQuery.isFetching) return "Loading workspace files…";
|
|
if (listQuery.isError) return "Unable to load workspace files.";
|
|
if (data?.state === "unavailable") return describeUnavailable(data.unavailableReason ?? "").title;
|
|
if (items.length === 0) return "No matching files.";
|
|
return `${items.length} item${items.length === 1 ? "" : "s"} found.`;
|
|
}, [data, items.length, listQuery.isError, listQuery.isFetching]);
|
|
|
|
function openTypedPath() {
|
|
const value = searchInput.trim();
|
|
if (!value) return;
|
|
const parsed = parseWorkspaceFileRef(value);
|
|
const target = { workspace: effectiveWorkspace, ...targetRef };
|
|
if (parsed?.resourceKind === "directory") {
|
|
setFolderPath(parsed.path);
|
|
setSearchInput("");
|
|
setDebouncedQuery("");
|
|
} else if (parsed) {
|
|
onOpen({
|
|
path: parsed.path,
|
|
...target,
|
|
line: parsed.line,
|
|
column: parsed.column,
|
|
browseFolderPath: folderPath,
|
|
browseQuery: searchInput.trim() || null,
|
|
});
|
|
} else {
|
|
onOpen({
|
|
path: value,
|
|
...target,
|
|
browseFolderPath: folderPath,
|
|
browseQuery: searchInput.trim() || null,
|
|
});
|
|
}
|
|
}
|
|
|
|
function handleSearchKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
|
if (event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
setHighlightedIndex((current) => (allLoadedFileItems.length === 0 ? -1 : Math.min(allLoadedFileItems.length - 1, current + 1)));
|
|
} else if (event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
setHighlightedIndex((current) => (allLoadedFileItems.length === 0 ? -1 : Math.max(0, current - 1)));
|
|
} else if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
const item = highlightedIndex >= 0 ? allLoadedFileItems[highlightedIndex] : undefined;
|
|
if (item) {
|
|
const itemTarget = item.projectId
|
|
? { projectId: item.projectId, workspaceId: item.workspaceId }
|
|
: targetRef;
|
|
onOpen({
|
|
path: item.relativePath,
|
|
workspace: effectiveWorkspace,
|
|
...itemTarget,
|
|
browseFolderPath: folderPath,
|
|
browseQuery: searchInput.trim() || null,
|
|
});
|
|
}
|
|
else openTypedPath();
|
|
}
|
|
}
|
|
|
|
const highlightedItem = highlightedIndex >= 0 ? allLoadedFileItems[highlightedIndex] : undefined;
|
|
const highlightedItemKey = highlightedItem ? itemKey(highlightedItem) : null;
|
|
const selectedItem = selectedItemIndex >= 0 ? allLoadedFileItems[selectedItemIndex] : null;
|
|
const selectedItemKey = selectedItem ? itemKey(selectedItem) : null;
|
|
const selectedOptionId = selectedItemKey ? `${listboxId}-file-${selectedItemKey}` : null;
|
|
const activeOptionId = highlightedItemKey ? `${listboxId}-file-${highlightedItemKey}` : undefined;
|
|
|
|
useEffect(() => {
|
|
if (!selectedOptionId) return;
|
|
const scrollContainer = scrollContainerRef.current;
|
|
const selectedElement = document.getElementById(selectedOptionId);
|
|
if (!scrollContainer || !selectedElement) return;
|
|
const containerRect = scrollContainer.getBoundingClientRect();
|
|
const selectedRect = selectedElement.getBoundingClientRect();
|
|
if (selectedRect.top < containerRect.top || selectedRect.bottom > containerRect.bottom) {
|
|
selectedElement.scrollIntoView({ block: "nearest" });
|
|
}
|
|
}, [folderPath, allLoadedFileItems, selectedOptionId]);
|
|
|
|
function openFolder(path: string | null) {
|
|
setFolderPath(path);
|
|
setSearchInput("");
|
|
setDebouncedQuery("");
|
|
}
|
|
|
|
function openItem(item: WorkspaceFileListFileItem) {
|
|
const itemTarget = item.projectId
|
|
? { projectId: item.projectId, workspaceId: item.workspaceId }
|
|
: targetRef;
|
|
onOpen({
|
|
path: item.relativePath,
|
|
workspace: effectiveWorkspace,
|
|
...itemTarget,
|
|
browseFolderPath: folderPath,
|
|
browseQuery: searchInput.trim() || null,
|
|
});
|
|
}
|
|
|
|
function toggleFolder(key: string) {
|
|
if (isLazyBrowse) {
|
|
setExpandedLazyFolders((current) => {
|
|
const next = new Set(current);
|
|
if (next.has(key)) next.delete(key);
|
|
else next.add(key);
|
|
return next;
|
|
});
|
|
return;
|
|
}
|
|
setCollapsedFolders((current) => {
|
|
const next = new Set(current);
|
|
if (next.has(key)) next.delete(key);
|
|
else next.add(key);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function loadMoreFolder(path: string) {
|
|
setFolderPageCounts((current) => ({
|
|
...current,
|
|
[path]: (current[path] ?? 1) + 1,
|
|
}));
|
|
}
|
|
|
|
function handleHoverFile(item: WorkspaceFileListFileItem) {
|
|
const key = itemKey(item);
|
|
const index = allLoadedFileItems.findIndex((candidate) => itemKey(candidate) === key);
|
|
if (index >= 0) setHighlightedIndex(index);
|
|
}
|
|
|
|
function getDownloadUrl(item: WorkspaceFileListFileItem): string | null {
|
|
if (!item.capabilities.download) return null;
|
|
const itemTarget = item.projectId
|
|
? { projectId: item.projectId, workspaceId: item.workspaceId }
|
|
: targetRef;
|
|
return fileResourcesApi.downloadUrl(issueId, {
|
|
path: item.relativePath,
|
|
workspace: effectiveWorkspace,
|
|
...itemTarget,
|
|
});
|
|
}
|
|
|
|
function lazyChildren(path: string, depth: number) {
|
|
const children = buildWorkspaceDirectoryTree(lazyItemsByFolder.get(path) ?? []);
|
|
return children.map((node) => ({ ...node, depth }));
|
|
}
|
|
|
|
let body: ReactNode;
|
|
if (source === "other" && !companyId) {
|
|
body = (
|
|
<StateMessage
|
|
icon={<FolderOpen aria-hidden="true" className="h-5 w-5 text-muted-foreground" />}
|
|
title="No company selected"
|
|
body="Choose a company before browsing another project workspace."
|
|
/>
|
|
);
|
|
} else if (source === "other" && projectsQuery.isFetching && projectsWithWorkspaces.length === 0) {
|
|
body = (
|
|
<StateMessage
|
|
icon={<Loader2 aria-hidden="true" className="h-5 w-5 animate-spin text-muted-foreground" />}
|
|
title="Loading project workspaces"
|
|
body="Registered workspaces will appear here."
|
|
/>
|
|
);
|
|
} else if (source === "other" && !canListFiles) {
|
|
body = (
|
|
<StateMessage
|
|
icon={<FolderOpen aria-hidden="true" className="h-5 w-5 text-muted-foreground" />}
|
|
title="No project workspaces"
|
|
body="No same-company project has a registered workspace to browse."
|
|
/>
|
|
);
|
|
} else if (listQuery.isFetching && !data) {
|
|
body = (
|
|
<div className="space-y-1.5 py-2" aria-busy="true">
|
|
{Array.from({ length: 6 }).map((_, index) => (
|
|
<div key={index} className="flex items-center gap-2 px-2 py-1.5">
|
|
<div className="h-3.5 w-3.5 shrink-0 rounded bg-muted" />
|
|
<div className="h-3 flex-1 rounded bg-muted" style={{ maxWidth: `${80 - index * 8}%` }} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
} else if (listQuery.isError) {
|
|
const status = listQuery.error instanceof ApiError ? listQuery.error.status : 0;
|
|
const errorCode = listQuery.error instanceof ApiError && listQuery.error.body && typeof listQuery.error.body === "object"
|
|
? (listQuery.error.body as { code?: unknown }).code
|
|
: null;
|
|
const changedFilesTemporarilyUnavailable = mode === "changed" && (
|
|
status === 503 ||
|
|
status === 504 ||
|
|
errorCode === "workspace_git_scan_saturated" ||
|
|
errorCode === "workspace_git_scan_timeout"
|
|
);
|
|
body = (
|
|
<StateMessage
|
|
icon={<AlertTriangle aria-hidden="true" className="h-5 w-5 text-amber-500" />}
|
|
title={changedFilesTemporarilyUnavailable ? "Changed files temporarily unavailable" : "Couldn't load files"}
|
|
body={
|
|
changedFilesTemporarilyUnavailable
|
|
? "Paperclip is limiting workspace scans to keep the server responsive. Try again in a moment."
|
|
: status === 404
|
|
? "Workspace browsing isn't available for this issue."
|
|
: "Something went wrong loading workspace files."
|
|
}
|
|
actions={changedFilesTemporarilyUnavailable ? (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={!queriesEnabled}
|
|
onClick={() => void listQuery.refetch()}
|
|
>
|
|
<RefreshCcw aria-hidden="true" className="mr-1 h-3 w-3" /> Retry
|
|
</Button>
|
|
) : null}
|
|
/>
|
|
);
|
|
} else if (data?.state === "unavailable") {
|
|
const detail = describeUnavailable(data.unavailableReason ?? "");
|
|
body = <StateMessage icon={detail.icon} title={detail.title} body={detail.body} />;
|
|
} else if (items.length === 0) {
|
|
body = (
|
|
<StateMessage
|
|
icon={<Search aria-hidden="true" className="h-5 w-5 text-muted-foreground" />}
|
|
title={isSearch ? `No files match “${q}”` : "No recently changed files yet"}
|
|
body="Try searching by name or path."
|
|
/>
|
|
);
|
|
} else {
|
|
body = (
|
|
<WorkspaceFileTree
|
|
nodes={treeNodes}
|
|
listboxId={listboxId}
|
|
highlightedItemKey={highlightedItemKey}
|
|
selectedItemKey={selectedItemKey}
|
|
collapsedFolders={collapsedFolders}
|
|
expandedLazyFolders={expandedLazyFolders}
|
|
forcedExpandedFolders={forcedExpandedFolders}
|
|
getLazyChildren={lazyChildren}
|
|
isLazyFolderFetching={(path) => lazyFetchingFolders.has(path)}
|
|
isLazyFolderTruncated={(path) => lazyTruncatedFolders.has(path)}
|
|
onLoadMoreFolder={loadMoreFolder}
|
|
onToggleFolder={toggleFolder}
|
|
onOpen={openItem}
|
|
onHoverFile={handleHoverFile}
|
|
getDownloadUrl={getDownloadUrl}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={cn("flex min-h-0 min-w-0 flex-col gap-2", className)}>
|
|
<div className="flex min-w-0 max-w-full items-center gap-1 overflow-hidden">
|
|
<div className="relative min-w-0 flex-1">
|
|
<Search
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
|
|
/>
|
|
<Input
|
|
ref={inputRef}
|
|
type="search"
|
|
value={searchInput}
|
|
onChange={(event) => setSearchInput(event.target.value)}
|
|
onKeyDown={handleSearchKeyDown}
|
|
placeholder="Search files by name or path…"
|
|
aria-label="Search workspace files"
|
|
role="combobox"
|
|
aria-expanded={items.length > 0}
|
|
aria-controls={items.length > 0 ? listboxId : undefined}
|
|
aria-activedescendant={activeOptionId}
|
|
autoFocus={autoFocusSearch}
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
className="h-8 w-full max-w-full min-w-0 pl-8 font-mono text-xs"
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={() => void listQuery.refetch()}
|
|
disabled={!queriesEnabled || listQuery.isFetching}
|
|
aria-label="Refresh workspace files"
|
|
title="Refresh workspace files"
|
|
className="h-8 w-8 shrink-0"
|
|
>
|
|
<RefreshCcw aria-hidden="true" className={cn("h-3.5 w-3.5", listQuery.isFetching && "animate-spin")} />
|
|
</Button>
|
|
</div>
|
|
|
|
<WorkspaceFileBreadcrumbs
|
|
rootLabel={selectedProject && selectedWorkspace ? `${selectedProject.name} / ${selectedWorkspace.name}` : workspaceLabel}
|
|
folderPath={folderPath}
|
|
onOpenFolder={openFolder}
|
|
/>
|
|
|
|
<div aria-live="polite" className="sr-only">
|
|
{announcement}
|
|
</div>
|
|
|
|
<div ref={scrollContainerRef} className="min-h-0 flex-1 overflow-y-auto">{body}</div>
|
|
|
|
{lazyTruncatedFolders.has(currentFolderKey) || (data?.truncated && !isLazyBrowse) ? (
|
|
<div className="border-t border-border pt-2 text-(length:--text-micro) text-muted-foreground">
|
|
{isLazyBrowse ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => loadMoreFolder(currentFolderKey)}
|
|
className="rounded px-1 py-0.5 text-left hover:bg-accent hover:text-foreground"
|
|
>
|
|
Load more from this folder
|
|
</button>
|
|
) : (
|
|
<>Showing first {items.length} — refine the search to narrow.</>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|