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>
827 lines
30 KiB
TypeScript
827 lines
30 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import type { ComponentProps } from "react";
|
|
import { flushSync } from "react-dom";
|
|
import { createRoot } from "react-dom/client";
|
|
import type { Root } from "react-dom/client";
|
|
import type { Project, ProjectWorkspace, WorkspaceFileListDirectoryItem, WorkspaceFileListFileItem, WorkspaceFileListItem, WorkspaceFileListResponse } from "@paperclipai/shared";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { WorkspaceFileBrowser, describeUnavailable } from "./WorkspaceFileBrowser";
|
|
|
|
function act(callback: () => void | Promise<void>) {
|
|
let result: void | Promise<void> | undefined;
|
|
flushSync(() => {
|
|
result = callback();
|
|
});
|
|
return result;
|
|
}
|
|
|
|
async function waitForExpectation(assertion: () => void) {
|
|
let lastError: unknown;
|
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
try {
|
|
assertion();
|
|
return;
|
|
} catch (error) {
|
|
lastError = error;
|
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
}
|
|
}
|
|
throw lastError;
|
|
}
|
|
|
|
const useQueryMock = vi.fn();
|
|
const LIST_LIMIT = 100;
|
|
|
|
vi.mock("@tanstack/react-query", async () => {
|
|
const actual = await vi.importActual<typeof import("@tanstack/react-query")>("@tanstack/react-query");
|
|
return {
|
|
...actual,
|
|
useQuery: (options: unknown) => useQueryMock(options),
|
|
useQueries: ({ queries }: { queries: unknown[] }) => queries.map((options) => useQueryMock(options)),
|
|
};
|
|
});
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
function createItem(overrides: Partial<WorkspaceFileListFileItem> = {}): WorkspaceFileListFileItem {
|
|
return {
|
|
kind: "file",
|
|
provider: "git_worktree",
|
|
title: "IssueDetail.tsx",
|
|
relativePath: "ui/src/pages/IssueDetail.tsx",
|
|
displayPath: "ui/src/pages/IssueDetail.tsx",
|
|
workspaceLabel: "Isolated workspace",
|
|
workspaceKind: "execution_workspace",
|
|
workspaceId: "ws-1",
|
|
contentType: "text/plain; charset=utf-8",
|
|
byteSize: 2048,
|
|
modifiedAt: new Date(Date.now() - 120_000).toISOString(),
|
|
previewKind: "text",
|
|
capabilities: { preview: true, download: false, listChildren: false },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createDirectoryItem(overrides: Partial<WorkspaceFileListDirectoryItem> = {}): WorkspaceFileListDirectoryItem {
|
|
return {
|
|
kind: "directory",
|
|
provider: "git_worktree",
|
|
title: "src",
|
|
relativePath: "ui/src",
|
|
displayPath: "ui/src/",
|
|
workspaceLabel: "Isolated workspace",
|
|
workspaceKind: "execution_workspace",
|
|
workspaceId: "ws-1",
|
|
contentType: null,
|
|
byteSize: null,
|
|
modifiedAt: null,
|
|
previewKind: "unsupported",
|
|
capabilities: { preview: false, download: false, listChildren: true },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function availableResponse(items: WorkspaceFileListItem[], truncated = false): WorkspaceFileListResponse {
|
|
return {
|
|
kind: "workspace_file_list",
|
|
state: "available",
|
|
workspace: {
|
|
provider: "git_worktree",
|
|
workspaceLabel: "Isolated workspace",
|
|
workspaceKind: "execution_workspace",
|
|
workspaceId: "ws-1",
|
|
},
|
|
query: { workspace: "auto", mode: "changed", q: null, limit: 100, offset: 0 },
|
|
items,
|
|
scannedCount: items.length,
|
|
truncated,
|
|
};
|
|
}
|
|
|
|
function availableAllResponse(
|
|
items: WorkspaceFileListItem[],
|
|
path: string | null,
|
|
truncated = false,
|
|
offset = 0,
|
|
): WorkspaceFileListResponse {
|
|
const response = availableResponse(items, truncated);
|
|
response.query = { ...response.query, mode: "all", path, offset };
|
|
return response;
|
|
}
|
|
|
|
function createWorkspace(overrides: Partial<ProjectWorkspace> = {}): ProjectWorkspace {
|
|
return {
|
|
id: "workspace-content",
|
|
companyId: "company-1",
|
|
projectId: "project-content",
|
|
name: "Paperclip Content",
|
|
sourceType: "local_path",
|
|
cwd: "/srv/paperclip/home/paperclipai/paperclip-content",
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
defaultRef: null,
|
|
visibility: "default",
|
|
setupCommand: null,
|
|
cleanupCommand: null,
|
|
remoteProvider: null,
|
|
remoteWorkspaceRef: null,
|
|
sharedWorkspaceKey: null,
|
|
metadata: null,
|
|
runtimeConfig: null,
|
|
isPrimary: true,
|
|
createdAt: new Date("2026-06-08T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-06-08T00:00:00.000Z"),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createProject(overrides: Partial<Project> = {}): Project {
|
|
const workspace = createWorkspace();
|
|
return {
|
|
id: "project-content",
|
|
companyId: "company-1",
|
|
urlKey: "paperclip-content",
|
|
goalId: null,
|
|
goalIds: [],
|
|
goals: [],
|
|
name: "Paperclip Content",
|
|
description: null,
|
|
status: "in_progress",
|
|
leadAgentId: null,
|
|
targetDate: null,
|
|
color: null,
|
|
icon: null,
|
|
env: null,
|
|
pauseReason: null,
|
|
pausedAt: null,
|
|
executionWorkspacePolicy: null,
|
|
codebase: {
|
|
workspaceId: workspace.id,
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
defaultRef: null,
|
|
repoName: null,
|
|
localFolder: workspace.cwd,
|
|
managedFolder: "",
|
|
effectiveLocalFolder: workspace.cwd ?? "",
|
|
origin: "local_folder",
|
|
},
|
|
workspaces: [workspace],
|
|
primaryWorkspace: workspace,
|
|
archivedAt: null,
|
|
createdAt: new Date("2026-06-08T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-06-08T00:00:00.000Z"),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function unavailableResponse(reason: string): WorkspaceFileListResponse {
|
|
return {
|
|
kind: "workspace_file_list",
|
|
state: "unavailable",
|
|
unavailableReason: reason,
|
|
workspace: null,
|
|
query: { workspace: "auto", mode: "changed", q: null, limit: 100, offset: 0 },
|
|
items: [],
|
|
scannedCount: 0,
|
|
truncated: false,
|
|
};
|
|
}
|
|
|
|
function ok<T>(data: T) {
|
|
return { data, isFetching: false, isError: false, error: null, refetch: vi.fn() };
|
|
}
|
|
|
|
describe("WorkspaceFileBrowser", () => {
|
|
let container: HTMLDivElement;
|
|
const roots: Root[] = [];
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
useQueryMock.mockReset();
|
|
Object.defineProperty(Element.prototype, "scrollIntoView", {
|
|
configurable: true,
|
|
value: vi.fn(),
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const root of roots.splice(0)) {
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
}
|
|
container.remove();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function renderBrowser(onOpen = vi.fn(), props: Partial<ComponentProps<typeof WorkspaceFileBrowser>> = {}) {
|
|
const root = createRoot(container);
|
|
roots.push(root);
|
|
act(() => {
|
|
root.render(<WorkspaceFileBrowser issueId="issue-1" onOpen={onOpen} {...props} />);
|
|
});
|
|
return { root, onOpen };
|
|
}
|
|
|
|
it("renders the Recently changed files as a tree and opens a row with its relative path", () => {
|
|
useQueryMock.mockReturnValue(
|
|
ok(availableResponse([createItem(), createItem({ relativePath: "README.md", displayPath: "README.md" })])),
|
|
);
|
|
|
|
const { onOpen } = renderBrowser();
|
|
|
|
expect(container.querySelector('[role="tree"]')).not.toBeNull();
|
|
expect(container.textContent).toContain("Isolated workspace");
|
|
expect(container.textContent).not.toContain("Recently changed");
|
|
expect(container.textContent).not.toContain("From Isolated workspace");
|
|
|
|
const option = Array.from(container.querySelectorAll('[role="treeitem"]')).find(
|
|
(el) => el.getAttribute("title") === "ui/src/pages/IssueDetail.tsx",
|
|
);
|
|
expect(option).not.toBeUndefined();
|
|
|
|
act(() => {
|
|
option!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
});
|
|
expect(onOpen).toHaveBeenCalledWith({
|
|
path: "ui/src/pages/IssueDetail.tsx",
|
|
workspace: "auto",
|
|
browseFolderPath: null,
|
|
browseQuery: null,
|
|
});
|
|
});
|
|
|
|
it("groups nested paths into collapsible folder rows instead of repeating full paths", () => {
|
|
useQueryMock.mockReturnValue(
|
|
ok(availableResponse([
|
|
createItem({
|
|
title: "tweet.md",
|
|
relativePath: "videos/90-days-paperclip/tweet.md",
|
|
displayPath: "videos/90-days-paperclip/tweet.md",
|
|
}),
|
|
createItem({
|
|
title: "90-days-paperclip-1x1.mp4",
|
|
relativePath: "videos/90-days-paperclip/out/90-days-paperclip-1x1.mp4",
|
|
displayPath: "videos/90-days-paperclip/out/90-days-paperclip-1x1.mp4",
|
|
previewKind: "video",
|
|
}),
|
|
])),
|
|
);
|
|
|
|
renderBrowser();
|
|
|
|
expect(container.querySelector('[role="tree"]')).not.toBeNull();
|
|
expect(container.textContent).toContain("videos");
|
|
expect(container.textContent).toContain("90-days-paperclip");
|
|
expect(container.textContent).toContain("tweet.md");
|
|
expect(container.textContent).toContain("90-days-paperclip-1x1.mp4");
|
|
expect(container.textContent).not.toContain("videos/90-days-paperclip/tweet.md");
|
|
|
|
const videosFolder = Array.from(container.querySelectorAll('button[role="treeitem"]')).find(
|
|
(el) => el.getAttribute("title") === "videos",
|
|
);
|
|
expect(videosFolder).not.toBeUndefined();
|
|
act(() => {
|
|
videosFolder!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
});
|
|
expect(container.textContent).not.toContain("tweet.md");
|
|
});
|
|
|
|
it("can render without autofocus when embedded beside a preview", () => {
|
|
useQueryMock.mockReturnValue(ok(availableResponse([createItem()])));
|
|
renderBrowser(vi.fn(), { autoFocusSearch: false });
|
|
expect(container.querySelector("input")?.hasAttribute("autofocus")).toBe(false);
|
|
expect(container.querySelector("input")?.className).toContain("max-w-full");
|
|
});
|
|
|
|
it("hides source controls, folder headings, workspace labels, and timestamps", () => {
|
|
useQueryMock.mockReturnValue(ok(availableResponse([
|
|
createItem({
|
|
relativePath: "videos/90-days-paperclip/tweet.md",
|
|
displayPath: "videos/90-days-paperclip/tweet.md",
|
|
}),
|
|
])));
|
|
|
|
renderBrowser(vi.fn(), { compact: true, autoFocusSearch: false });
|
|
|
|
expect(container.textContent).not.toContain("Source");
|
|
expect(container.textContent).not.toContain("Workspace");
|
|
expect(container.textContent).not.toContain("Recently changed");
|
|
expect(container.textContent).not.toContain("Files in folder");
|
|
expect(container.textContent).not.toContain("From Isolated workspace");
|
|
expect(container.querySelector(".tabular-nums")).toBeNull();
|
|
expect(container.textContent).toContain("videos");
|
|
expect(container.textContent).toContain("tweet.md");
|
|
});
|
|
|
|
it("marks the selected file in the tree", () => {
|
|
useQueryMock.mockReturnValue(ok(availableResponse([createItem()])));
|
|
renderBrowser(vi.fn(), { selectedPath: "ui/src/pages/IssueDetail.tsx" });
|
|
const selected = Array.from(container.querySelectorAll('[role="treeitem"]')).find(
|
|
(el) => el.getAttribute("title") === "ui/src/pages/IssueDetail.tsx",
|
|
);
|
|
expect(selected?.getAttribute("aria-selected")).toBe("true");
|
|
});
|
|
|
|
it("lists the selected file's parent folder so deep-linked files appear selected", () => {
|
|
const commandsItem = createItem({
|
|
title: "commands.md",
|
|
relativePath: "docs/reference/cli/commands.md",
|
|
displayPath: "docs/reference/cli/commands.md",
|
|
byteSize: 8192,
|
|
});
|
|
useQueryMock.mockReturnValue(ok(availableResponse([commandsItem])));
|
|
|
|
renderBrowser(vi.fn(), {
|
|
compact: true,
|
|
autoFocusSearch: false,
|
|
selectedPath: "docs/reference/cli/commands.md",
|
|
});
|
|
|
|
const listCall = useQueryMock.mock.calls.find(([options]) => options.queryKey?.[3] === "list");
|
|
expect(listCall?.[0].queryKey[4]).toMatchObject({
|
|
mode: "all",
|
|
path: "docs/reference/cli",
|
|
});
|
|
const selected = Array.from(container.querySelectorAll('[role="treeitem"]')).find(
|
|
(el) => el.getAttribute("title") === "docs/reference/cli/commands.md",
|
|
);
|
|
expect(selected?.getAttribute("aria-selected")).toBe("true");
|
|
expect(container.textContent).toContain("commands.md");
|
|
});
|
|
|
|
it("treats an explicit null initial folder as the workspace root", () => {
|
|
useQueryMock.mockReturnValue(ok(availableResponse([createItem({
|
|
title: "README.md",
|
|
relativePath: "README.md",
|
|
displayPath: "README.md",
|
|
})])));
|
|
|
|
renderBrowser(vi.fn(), {
|
|
compact: true,
|
|
autoFocusSearch: false,
|
|
initialFolderPath: null,
|
|
selectedPath: "docs/reference/cli/commands.md",
|
|
});
|
|
|
|
const listCall = useQueryMock.mock.calls.find(([options]) => options.queryKey?.[3] === "list");
|
|
expect(listCall?.[0].queryKey[4]).toMatchObject({
|
|
mode: "all",
|
|
path: null,
|
|
});
|
|
});
|
|
|
|
it("preserves an initial search instead of narrowing to the selected file's parent folder", () => {
|
|
useQueryMock.mockReturnValue(ok(availableResponse([createItem({
|
|
title: "FileViewerSheet.tsx",
|
|
relativePath: "ui/src/components/FileViewerSheet.tsx",
|
|
displayPath: "ui/src/components/FileViewerSheet.tsx",
|
|
})])));
|
|
|
|
renderBrowser(vi.fn(), {
|
|
compact: true,
|
|
autoFocusSearch: false,
|
|
initialQuery: "FileViewerSheet",
|
|
selectedPath: "ui/src/components/FileViewerSheet.tsx",
|
|
});
|
|
|
|
const listCall = useQueryMock.mock.calls.find(([options]) => options.queryKey?.[3] === "list");
|
|
expect(listCall?.[0].queryKey[4]).toMatchObject({
|
|
mode: "all",
|
|
q: "FileViewerSheet",
|
|
path: null,
|
|
});
|
|
});
|
|
|
|
it("does not mark an unrelated highlighted row as selected", () => {
|
|
useQueryMock.mockReturnValue(ok(availableResponse([
|
|
createItem({
|
|
title: "vite.config.ts",
|
|
relativePath: "ui/vite.config.ts",
|
|
displayPath: "ui/vite.config.ts",
|
|
}),
|
|
])));
|
|
|
|
renderBrowser(vi.fn(), {
|
|
compact: true,
|
|
autoFocusSearch: false,
|
|
selectedPath: "docs/reference/cli/commands.md",
|
|
});
|
|
|
|
const unrelated = Array.from(container.querySelectorAll('[role="treeitem"]')).find(
|
|
(el) => el.getAttribute("title") === "ui/vite.config.ts",
|
|
);
|
|
expect(unrelated?.getAttribute("aria-selected")).toBe("false");
|
|
});
|
|
|
|
it("does not render a Recent/All toggle", () => {
|
|
useQueryMock.mockReturnValue(ok(availableResponse([createItem()])));
|
|
renderBrowser();
|
|
expect(container.textContent).not.toContain("All files");
|
|
expect(container.textContent).not.toContain("Recent changes / All");
|
|
});
|
|
|
|
it("discloses truncation in the footer", () => {
|
|
useQueryMock.mockReturnValue(ok(availableResponse([createItem()], true)));
|
|
renderBrowser();
|
|
expect(container.textContent).toContain("refine the search to narrow");
|
|
});
|
|
|
|
it("renders newly loaded current-folder rows after Load more", () => {
|
|
const folderPath = "ui/src/components";
|
|
const pageZero = availableAllResponse([
|
|
createItem({
|
|
title: "IssueLinkQuicklook.tsx",
|
|
relativePath: `${folderPath}/IssueLinkQuicklook.tsx`,
|
|
displayPath: `${folderPath}/IssueLinkQuicklook.tsx`,
|
|
}),
|
|
], folderPath, true, 0);
|
|
const pageOne = availableAllResponse([
|
|
createItem({
|
|
title: "SourceTrustBadge.tsx",
|
|
relativePath: `${folderPath}/SourceTrustBadge.tsx`,
|
|
displayPath: `${folderPath}/SourceTrustBadge.tsx`,
|
|
}),
|
|
], folderPath, true, LIST_LIMIT);
|
|
useQueryMock.mockImplementation((options: { queryKey: readonly unknown[] }) => {
|
|
const query = options.queryKey[4] as { path?: string | null; offset?: number } | undefined;
|
|
if (query?.path === folderPath && query.offset === LIST_LIMIT) return ok(pageOne);
|
|
return ok(pageZero);
|
|
});
|
|
|
|
renderBrowser(vi.fn(), {
|
|
compact: true,
|
|
autoFocusSearch: false,
|
|
initialFolderPath: folderPath,
|
|
});
|
|
|
|
expect(container.textContent).toContain("IssueLinkQuicklook.tsx");
|
|
expect(container.textContent).not.toContain("SourceTrustBadge.tsx");
|
|
const loadMore = Array.from(container.querySelectorAll("button")).find(
|
|
(el) => el.textContent === "Load more from this folder",
|
|
);
|
|
act(() => {
|
|
loadMore!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
});
|
|
|
|
expect(container.textContent).toContain("SourceTrustBadge.tsx");
|
|
});
|
|
|
|
it("opens the highlighted row when Enter is pressed in the search field", () => {
|
|
useQueryMock.mockReturnValue(ok(availableResponse([createItem()])));
|
|
const { onOpen } = renderBrowser();
|
|
const input = container.querySelector("input")!;
|
|
act(() => {
|
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }));
|
|
});
|
|
act(() => {
|
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
|
});
|
|
expect(onOpen).toHaveBeenCalledWith({
|
|
path: "ui/src/pages/IssueDetail.tsx",
|
|
workspace: "auto",
|
|
browseFolderPath: null,
|
|
browseQuery: null,
|
|
});
|
|
});
|
|
|
|
it("reports live search state for URL-backed browse preservation", async () => {
|
|
useQueryMock.mockReturnValue(ok(availableResponse([createItem()])));
|
|
const onBrowseStateChange = vi.fn();
|
|
renderBrowser(vi.fn(), {
|
|
initialFolderPath: "ui/src/components",
|
|
onBrowseStateChange,
|
|
});
|
|
onBrowseStateChange.mockClear();
|
|
|
|
const input = container.querySelector("input")!;
|
|
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
|
act(() => {
|
|
nativeSetter?.call(input, "FileViewerSheet");
|
|
input.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
|
|
});
|
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
|
|
expect(onBrowseStateChange).toHaveBeenLastCalledWith({
|
|
q: "FileViewerSheet",
|
|
folderPath: "ui/src/components",
|
|
projectId: null,
|
|
workspaceId: null,
|
|
});
|
|
});
|
|
|
|
it("shows the remote-workspace state without file rows", () => {
|
|
useQueryMock.mockReturnValue(ok(unavailableResponse("remote_workspace")));
|
|
renderBrowser();
|
|
expect(container.textContent).toContain("Remote workspace preview not supported");
|
|
expect(container.querySelector('[role="tree"]')).toBeNull();
|
|
});
|
|
|
|
it("shows the no-workspace state when the issue has no workspace", () => {
|
|
useQueryMock.mockReturnValue(ok(unavailableResponse("no_workspace")));
|
|
renderBrowser();
|
|
expect(container.textContent).toContain("No workspace yet");
|
|
});
|
|
|
|
it("opens a result from a selected other project workspace", () => {
|
|
const contentItem = createItem({
|
|
relativePath: "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/README.md",
|
|
displayPath: "Paperclip Content / content-os/cases/active/2026-06-06-pap-10199-bundled-skills/README.md",
|
|
workspaceLabel: "Paperclip Content",
|
|
workspaceKind: "project_workspace",
|
|
workspaceId: "workspace-content",
|
|
projectId: "project-content",
|
|
projectName: "Paperclip Content",
|
|
});
|
|
useQueryMock.mockImplementation((options: { queryKey: readonly unknown[] }) => {
|
|
if (options.queryKey[0] === "projects") return ok([createProject()]);
|
|
return ok(availableResponse([contentItem]));
|
|
});
|
|
|
|
const { onOpen } = renderBrowser(vi.fn(), {
|
|
companyId: "company-1",
|
|
initialProjectId: "project-content",
|
|
initialWorkspaceId: "workspace-content",
|
|
});
|
|
|
|
expect(container.textContent).not.toContain("Other project");
|
|
expect(container.textContent).toContain("Paperclip Content / Paperclip Content");
|
|
const listCall = useQueryMock.mock.calls.find(([options]) => options.queryKey?.[3] === "list");
|
|
expect(listCall?.[0].queryKey[4]).toMatchObject({
|
|
workspace: "project",
|
|
projectId: "project-content",
|
|
workspaceId: "workspace-content",
|
|
});
|
|
|
|
const option = Array.from(container.querySelectorAll('[role="treeitem"]')).find(
|
|
(el) => el.getAttribute("title") === contentItem.displayPath,
|
|
)!;
|
|
act(() => {
|
|
option.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
});
|
|
expect(onOpen).toHaveBeenCalledWith({
|
|
path: "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/README.md",
|
|
workspace: "project",
|
|
projectId: "project-content",
|
|
workspaceId: "workspace-content",
|
|
browseFolderPath: null,
|
|
browseQuery: null,
|
|
});
|
|
});
|
|
|
|
it("focuses an initial folder in a selected other project workspace", () => {
|
|
const folderPath = "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/";
|
|
const contentItem = createItem({
|
|
relativePath: `${folderPath}README.md`,
|
|
displayPath: `Paperclip Content / ${folderPath}README.md`,
|
|
workspaceLabel: "Paperclip Content",
|
|
workspaceKind: "project_workspace",
|
|
workspaceId: "workspace-content",
|
|
projectId: "project-content",
|
|
projectName: "Paperclip Content",
|
|
});
|
|
useQueryMock.mockImplementation((options: { queryKey: readonly unknown[] }) => {
|
|
if (options.queryKey[0] === "projects") return ok([createProject()]);
|
|
return ok(availableResponse([contentItem]));
|
|
});
|
|
|
|
const { onOpen } = renderBrowser(vi.fn(), {
|
|
companyId: "company-1",
|
|
initialProjectId: "project-content",
|
|
initialWorkspaceId: "workspace-content",
|
|
initialFolderPath: folderPath,
|
|
});
|
|
|
|
expect(container.textContent).toContain("bundled-skills");
|
|
expect(container.textContent).not.toContain(folderPath);
|
|
expect(container.textContent).not.toContain("Files in folder");
|
|
const listCall = useQueryMock.mock.calls.find(([options]) => options.queryKey?.[3] === "list");
|
|
expect(listCall?.[0].queryKey[4]).toMatchObject({
|
|
workspace: "project",
|
|
projectId: "project-content",
|
|
workspaceId: "workspace-content",
|
|
path: folderPath,
|
|
});
|
|
|
|
const option = Array.from(container.querySelectorAll('[role="treeitem"]')).find(
|
|
(el) => el.getAttribute("title") === contentItem.displayPath,
|
|
)!;
|
|
act(() => {
|
|
option.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
});
|
|
expect(onOpen).toHaveBeenCalledWith({
|
|
path: `${folderPath}README.md`,
|
|
workspace: "project",
|
|
projectId: "project-content",
|
|
workspaceId: "workspace-content",
|
|
browseFolderPath: folderPath,
|
|
browseQuery: null,
|
|
});
|
|
});
|
|
|
|
it("opens sibling files with the current folder scope", () => {
|
|
const folderPath = "docs/cli";
|
|
const controlPlane = createItem({
|
|
title: "control-plane-commands.md",
|
|
relativePath: `${folderPath}/control-plane-commands.md`,
|
|
displayPath: `${folderPath}/control-plane-commands.md`,
|
|
});
|
|
useQueryMock.mockReturnValue(ok(availableResponse([
|
|
createItem({
|
|
title: "setup-commands.md",
|
|
relativePath: `${folderPath}/setup-commands.md`,
|
|
displayPath: `${folderPath}/setup-commands.md`,
|
|
}),
|
|
controlPlane,
|
|
])));
|
|
|
|
const { onOpen } = renderBrowser(vi.fn(), {
|
|
compact: true,
|
|
autoFocusSearch: false,
|
|
initialFolderPath: folderPath,
|
|
selectedPath: `${folderPath}/setup-commands.md`,
|
|
});
|
|
|
|
const option = Array.from(container.querySelectorAll('[role="treeitem"]')).find(
|
|
(el) => el.getAttribute("title") === controlPlane.displayPath,
|
|
)!;
|
|
act(() => {
|
|
option.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
});
|
|
|
|
expect(onOpen).toHaveBeenCalledWith({
|
|
path: `${folderPath}/control-plane-commands.md`,
|
|
workspace: "auto",
|
|
browseFolderPath: folderPath,
|
|
browseQuery: null,
|
|
});
|
|
});
|
|
|
|
it("scrolls the selected file into view after breadcrumb-scoped listings", () => {
|
|
const folderPath = "docs";
|
|
const selectedPath = "docs/cli/setup-commands.md";
|
|
const scrollIntoView = vi.fn();
|
|
Object.defineProperty(Element.prototype, "scrollIntoView", {
|
|
configurable: true,
|
|
value: scrollIntoView,
|
|
});
|
|
vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function getBoundingClientRect(this: Element) {
|
|
if (this.getAttribute("aria-selected") === "true") {
|
|
return { top: 1086, bottom: 1114, left: 0, right: 100, width: 100, height: 28, x: 0, y: 1086, toJSON: () => ({}) };
|
|
}
|
|
if (typeof this.className === "string" && this.className.includes("overflow-y-auto")) {
|
|
return { top: 188, bottom: 546, left: 0, right: 320, width: 320, height: 358, x: 0, y: 188, toJSON: () => ({}) };
|
|
}
|
|
return { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}) };
|
|
});
|
|
useQueryMock.mockReturnValue(ok(availableResponse([createItem({
|
|
title: "setup-commands.md",
|
|
relativePath: selectedPath,
|
|
displayPath: selectedPath,
|
|
})])));
|
|
|
|
renderBrowser(vi.fn(), {
|
|
compact: true,
|
|
autoFocusSearch: false,
|
|
initialFolderPath: folderPath,
|
|
selectedPath,
|
|
});
|
|
|
|
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest" });
|
|
});
|
|
|
|
it("loads the selected file's ancestor folders when a parent breadcrumb is open", () => {
|
|
const selectedPath = "docs/cli/setup-commands.md";
|
|
const docsResponse = availableResponse([
|
|
createDirectoryItem({
|
|
title: "cli",
|
|
relativePath: "docs/cli",
|
|
displayPath: "docs/cli/",
|
|
}),
|
|
]);
|
|
docsResponse.query = { ...docsResponse.query, mode: "all", path: "docs" };
|
|
const cliResponse = availableResponse([
|
|
createItem({
|
|
title: "setup-commands.md",
|
|
relativePath: selectedPath,
|
|
displayPath: selectedPath,
|
|
}),
|
|
]);
|
|
cliResponse.query = { ...cliResponse.query, mode: "all", path: "docs/cli" };
|
|
useQueryMock.mockImplementation((options: { queryKey: readonly unknown[] }) => {
|
|
const query = options.queryKey[4] as { path?: string | null } | undefined;
|
|
if (query?.path === "docs/cli") return ok(cliResponse);
|
|
return ok(docsResponse);
|
|
});
|
|
|
|
renderBrowser(vi.fn(), {
|
|
compact: true,
|
|
autoFocusSearch: false,
|
|
initialFolderPath: "docs",
|
|
selectedPath,
|
|
});
|
|
|
|
const childListCall = useQueryMock.mock.calls.find(([options]) => options.queryKey?.[4]?.path === "docs/cli");
|
|
expect(childListCall?.[0].queryKey[4]).toMatchObject({ path: "docs/cli", offset: 0 });
|
|
const selected = Array.from(container.querySelectorAll('[role="treeitem"]')).find(
|
|
(el) => el.getAttribute("title") === selectedPath,
|
|
);
|
|
expect(selected?.getAttribute("aria-selected")).toBe("true");
|
|
expect(container.textContent).toContain("setup-commands.md");
|
|
});
|
|
|
|
it("auto-pages the selected file's current folder until the selected row is loaded", async () => {
|
|
const folderPath = "ui/src/components";
|
|
const selectedPath = `${folderPath}/WorkspaceFileBrowser.tsx`;
|
|
const pageZero = availableAllResponse([
|
|
createItem({
|
|
title: "ActivityCharts.tsx",
|
|
relativePath: `${folderPath}/ActivityCharts.tsx`,
|
|
displayPath: `${folderPath}/ActivityCharts.tsx`,
|
|
}),
|
|
], folderPath, true, 0);
|
|
const pageOne = availableAllResponse([
|
|
createItem({
|
|
title: "SourceTrustBadge.tsx",
|
|
relativePath: `${folderPath}/SourceTrustBadge.tsx`,
|
|
displayPath: `${folderPath}/SourceTrustBadge.tsx`,
|
|
}),
|
|
], folderPath, true, LIST_LIMIT);
|
|
const pageTwo = availableAllResponse([
|
|
createItem({
|
|
title: "WorkspaceFileBrowser.tsx",
|
|
relativePath: selectedPath,
|
|
displayPath: selectedPath,
|
|
}),
|
|
], folderPath, false, LIST_LIMIT * 2);
|
|
useQueryMock.mockImplementation((options: { queryKey: readonly unknown[] }) => {
|
|
const query = options.queryKey[4] as { path?: string | null; offset?: number } | undefined;
|
|
if (query?.path === folderPath && query.offset === LIST_LIMIT) return ok(pageOne);
|
|
if (query?.path === folderPath && query.offset === LIST_LIMIT * 2) return ok(pageTwo);
|
|
return ok(pageZero);
|
|
});
|
|
|
|
renderBrowser(vi.fn(), {
|
|
compact: true,
|
|
autoFocusSearch: false,
|
|
initialFolderPath: folderPath,
|
|
selectedPath,
|
|
});
|
|
|
|
await waitForExpectation(() => {
|
|
const pageTwoCall = useQueryMock.mock.calls.find(
|
|
([options]) => options.queryKey?.[4]?.path === folderPath && options.queryKey[4].offset === LIST_LIMIT * 2,
|
|
);
|
|
expect(pageTwoCall?.[0].queryKey[4]).toMatchObject({ path: folderPath, offset: LIST_LIMIT * 2 });
|
|
});
|
|
await waitForExpectation(() => {
|
|
const selected = Array.from(container.querySelectorAll('[role="treeitem"]')).find(
|
|
(el) => el.getAttribute("title") === selectedPath,
|
|
);
|
|
expect(selected?.getAttribute("aria-selected")).toBe("true");
|
|
expect(container.textContent).toContain("WorkspaceFileBrowser.tsx");
|
|
});
|
|
});
|
|
|
|
it("lets breadcrumb folders navigate to parent directories", () => {
|
|
const folderPath = "content-os/cases/active/";
|
|
useQueryMock.mockReturnValue(ok(availableResponse([createItem({
|
|
relativePath: `${folderPath}README.md`,
|
|
displayPath: `${folderPath}README.md`,
|
|
})])));
|
|
|
|
renderBrowser(vi.fn(), { initialFolderPath: folderPath });
|
|
const contentOsCrumb = Array.from(container.querySelectorAll("button")).find(
|
|
(el) => el.getAttribute("title") === "content-os",
|
|
);
|
|
expect(contentOsCrumb).not.toBeUndefined();
|
|
act(() => {
|
|
contentOsCrumb!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
});
|
|
const latestListCall = useQueryMock.mock.calls.filter(([options]) => options.queryKey?.[3] === "list").at(-1);
|
|
expect(latestListCall?.[0].queryKey[4]).toMatchObject({ path: "content-os" });
|
|
});
|
|
});
|
|
|
|
describe("describeUnavailable", () => {
|
|
it("maps reasons to copy that matches the viewer's denial voice", () => {
|
|
expect(describeUnavailable("remote_workspace").title).toBe("Remote workspace preview not supported");
|
|
expect(describeUnavailable("no_workspace").title).toBe("No workspace yet");
|
|
expect(describeUnavailable("no_local_workspace").title).toBe("No workspace yet");
|
|
expect(describeUnavailable("workspace_unavailable").title).toBe("Workspace is no longer available");
|
|
expect(describeUnavailable("archived").title).toBe("Workspace is no longer available");
|
|
});
|
|
|
|
it("never leaks the raw reason code as the body", () => {
|
|
for (const reason of ["remote_workspace", "no_workspace", "workspace_unavailable", "weird_unknown"]) {
|
|
const { body } = describeUnavailable(reason);
|
|
expect(body).not.toBe(reason);
|
|
expect(body).not.toMatch(/^[a-z_]+$/);
|
|
}
|
|
});
|
|
});
|