468edd8b22
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent work is issue-centered, and reviewers often need to inspect files, artifacts, and path references produced during that work. > - Before this branch, workspace-relative paths and artifact file references were not first-class inspectable objects in the board UI. > - Safe file viewing needs shared resource contracts, server-side workspace boundary checks, and UI that opens files without exposing arbitrary host paths. > - The workspace file viewer branch needed to stay as one active PR and be rebased onto current `paperclipai/paperclip:master` for review. > - This pull request adds the workspace file resource API, issue-page file viewer and browser, markdown file-reference links, and artifact file chips. > - The benefit is that board users can inspect relevant files from issue context while preserving workspace boundaries and auditability. ## Linked Issues or Issue Description No public GitHub issue exists for this branch. Internal Paperclip issues: `PAP-1953`, `PAP-10539`, `PAP-10733`. Problem / motivation: - Board users need to open workspace-relative files mentioned by agents or attached as work-product metadata without switching to a terminal. - The UI needs to support both direct file-path opening and workspace browsing/searching from an issue page. - The server must enforce company access, workspace boundaries, size limits, rate limits, and safe audit logging. Related PR: - Prior closed attempt: #4442 - Single active PR for this branch: #7681 ## What Changed - Added shared workspace file resource types, validators, and workspace-file `resourceRef` metadata validation for work products. - Added server routes/services for resolving, listing, and previewing workspace-relative files with access checks, scan caps, list-specific limits, and audit logging. - Added the issue file viewer provider, sheet, workspace browser, command-palette action, markdown workspace-file autolinks, and artifact file chips. - Updated issue workspace UI and stories/tests for file browsing and workspace file opening. - Rebased the branch onto current `paperclipai/paperclip:master` and updated the existing single PR branch. - Addressed current-head Greptile follow-ups by applying `offset` consistently across search/recent/changed file listings, restoring stopped-service port ownership checks before auto-port reuse, and stabilizing the workspace browser pagination test. ## Verification Current local verification after rebase to `public/master`: - `pnpm exec vitest run packages/shared/src/work-product.test.ts server/src/__tests__/file-resources.test.ts server/src/__tests__/instance-settings-routes.test.ts server/src/__tests__/instance-settings-service.test.ts server/src/__tests__/workspace-runtime.test.ts ui/src/components/FileViewerSheet.test.tsx ui/src/components/FileViewerSheet.copy.test.tsx ui/src/components/WorkspaceFileBrowser.test.tsx ui/src/components/WorkspaceFileMarkdownBody.test.tsx ui/src/context/FileViewerContext.test.ts ui/src/lib/remark-workspace-file-refs.test.ts ui/src/lib/workspace-file-parser.test.ts ui/src/components/IssueWorkspaceCard.test.tsx` - 13 files passed, 197 tests passed. - `pnpm -r --filter @paperclipai/shared --filter @paperclipai/server --filter @paperclipai/ui typecheck` - passed. - `pnpm exec vitest run ui/src/components/WorkspaceFileBrowser.test.tsx` - 1 file passed, 25 tests passed. - `pnpm exec vitest run server/src/__tests__/file-resources.test.ts server/src/__tests__/workspace-runtime.test.ts` - 2 files passed, 90 tests passed. - `pnpm -r --filter @paperclipai/server typecheck` - passed. - Confirmed branch is `0` behind and `46` ahead of current `public/master` after rebase and follow-up commits. - Confirmed the PR diff does not include `pnpm-lock.yaml`. - Confirmed the PR diff does not include `.github/workflows` changes. - Searched GitHub for duplicate or related workspace file viewer PRs/issues; #4442 is the prior closed attempt and this PR is the single active PR for the branch. - No screenshots were committed; the task explicitly asked not to add design screenshots or images unless they were part of the work. Current remote verification on head `a698a7bc10137baf7d25bd5722e1d6e0343387c1`: - Greptile Review - success, 64 files reviewed, 0 comments added, no unresolved Greptile review threads. - PR workflow `verify` - success. - Typecheck + Release Registry, General tests, workspace test shards, serialized server suites, Build, Canary Dry Run, e2e, Socket, and Snyk - success. - `security-review` - neutral, with output saying a draft advisory was filed for maintainer review and is not a merge block. - `commitperclip PR Review / review` - cancelled after the security gate detected flags and timed out while creating/reviewing the advisory. I reran it once and it cancelled the same way; no actionable code/test failure was exposed in the job logs. ## Risks - This is a broad UI/server feature PR, so review needs to pay attention to route authorization, workspace boundary handling, and markdown autolink false positives. - Workspace browsing intentionally caps list results and scan depth; very large workspaces may require users to refine search terms. - Remote workspace preview remains unavailable until remote file-access support is implemented. - The neutral commitperclip security-review advisory needs maintainer review, but the check output says it is not a merge block. > 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 coding agent in a Paperclip/Codex local tool-use environment, medium reasoning, with shell/GitHub CLI tool use for branch inspection, verification, rebase, PR update, Greptile review, and CI inspection. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] 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> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1063 lines
38 KiB
TypeScript
1063 lines
38 KiB
TypeScript
import {
|
|
useEffect,
|
|
useId,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type KeyboardEvent,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { useQueries, useQuery } from "@tanstack/react-query";
|
|
import { AlertTriangle, ChevronDown, ChevronRight, Cloud, FileCode2, FolderOpen, Loader2, Search } from "lucide-react";
|
|
import { Input } from "@/components/ui/input";
|
|
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 { 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 }: { icon: ReactNode; title: string; body?: string }) {
|
|
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}
|
|
</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-[11px] 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;
|
|
}
|
|
|
|
function WorkspaceFileRow({ item, treeItemId, selected, highlighted, depth, onOpen, onHover }: 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-[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>
|
|
</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;
|
|
}
|
|
|
|
function WorkspaceFileTree({
|
|
nodes,
|
|
listboxId,
|
|
highlightedItemKey,
|
|
selectedItemKey,
|
|
collapsedFolders,
|
|
expandedLazyFolders,
|
|
forcedExpandedFolders,
|
|
getLazyChildren,
|
|
isLazyFolderFetching,
|
|
isLazyFolderTruncated,
|
|
onLoadMoreFolder,
|
|
onToggleFolder,
|
|
onOpen,
|
|
onHoverFile,
|
|
}: 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-[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-[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-[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)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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;
|
|
className?: string;
|
|
}
|
|
|
|
export function WorkspaceFileBrowser({
|
|
issueId,
|
|
companyId,
|
|
onOpen,
|
|
onBrowseStateChange,
|
|
initialQuery,
|
|
initialFolderPath,
|
|
initialProjectId,
|
|
initialWorkspaceId,
|
|
autoFocusSearch = true,
|
|
selectedPath,
|
|
selectedProjectId: activeProjectId,
|
|
selectedWorkspaceId: activeWorkspaceId,
|
|
className,
|
|
}: WorkspaceFileBrowserProps) {
|
|
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 targetRef = targetProjectId && targetWorkspaceId
|
|
? { projectId: targetProjectId, workspaceId: targetWorkspaceId }
|
|
: {};
|
|
|
|
useEffect(() => {
|
|
onBrowseStateChange?.({
|
|
q: searchInput.trim() || null,
|
|
folderPath,
|
|
projectId: targetProjectId,
|
|
workspaceId: targetWorkspaceId,
|
|
});
|
|
}, [folderPath, onBrowseStateChange, searchInput, targetProjectId, targetWorkspaceId]);
|
|
|
|
const listQuery = useQuery({
|
|
queryKey: queryKeys.issues.fileResources(issueId, {
|
|
workspace: effectiveWorkspace,
|
|
projectId: targetProjectId,
|
|
workspaceId: targetWorkspaceId,
|
|
mode,
|
|
q,
|
|
limit: LIST_LIMIT,
|
|
offset: 0,
|
|
path: folderPath,
|
|
}),
|
|
queryFn: () => fileResourcesApi.list(issueId, {
|
|
workspace: effectiveWorkspace,
|
|
projectId: targetProjectId,
|
|
workspaceId: targetWorkspaceId,
|
|
mode,
|
|
q,
|
|
limit: LIST_LIMIT,
|
|
offset: 0,
|
|
path: folderPath,
|
|
}),
|
|
enabled: canListFiles,
|
|
retry: false,
|
|
staleTime: 15_000,
|
|
});
|
|
|
|
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: () => fileResourcesApi.list(issueId, {
|
|
workspace: effectiveWorkspace,
|
|
projectId: targetProjectId,
|
|
workspaceId: targetWorkspaceId,
|
|
mode: "all",
|
|
q: null,
|
|
limit: LIST_LIMIT,
|
|
offset: spec.offset,
|
|
path: spec.path || null,
|
|
}),
|
|
enabled: canListFiles && isLazyBrowse,
|
|
retry: false,
|
|
staleTime: 15_000,
|
|
})),
|
|
});
|
|
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 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;
|
|
body = (
|
|
<StateMessage
|
|
icon={<AlertTriangle aria-hidden="true" className="h-5 w-5 text-amber-500" />}
|
|
title="Couldn't load files"
|
|
body={
|
|
status === 404
|
|
? "Workspace browsing isn't available for this issue."
|
|
: "Something went wrong loading workspace files."
|
|
}
|
|
/>
|
|
);
|
|
} 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}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={cn("flex min-h-0 min-w-0 flex-col gap-2", className)}>
|
|
<div className="relative min-w-0 max-w-full overflow-hidden">
|
|
<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>
|
|
|
|
<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-[11px] 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>
|
|
);
|
|
}
|