Files
paperclip/ui/src/components/WorkspaceFileBrowser.tsx
T
Dotta 468edd8b22 Add workspace file viewer and artifact links (#7681)
## 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>
2026-06-09 17:17:43 -05:00

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>
);
}