mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
Rework workspace browser to match PAP-10508 design (PAP-10511)
Addresses UXDesigner review against the approved design spec:
- Placement: move the discovery affordance into a calm action row at the
bottom of IssueWorkspaceCard ("Browse files…" + "Open file by path…"),
removing the orphaned button between Artifacts and Tabs. The existing
top-right toolbar / `g f` entry stays as the secondary affordance.
- Single sheet, two modes: opening a result from browse now shows a
"← Back to files" affordance (and a separate × close) in the view header,
with Esc returning to the list. Browse state is URL-backed (`browse=1`,
`q`) via FileViewerContext (open(...{fromBrowse}), openBrowse, backToFiles).
- Remove the Recent/All toggle: empty query → Recently changed (silent
fallback to full listing when git change-tracking is unavailable);
populated query → Files matching. Search field accepts `path:line` and
Enter opens the highlighted row or the typed path.
- Mobile (<sm) two-line rows (basename / dirname, no timestamp, 44px tap
targets); desktop single-line with dir-truncate. Recently-changed shows
relative timestamps; search results omit them to maximise path width.
- Keyboard: listbox/option roles, ↑/↓ + Enter, aria-activedescendant,
combobox search field.
- Align unavailable copy with describeDenial (remote / cleaned-up);
debounce 150ms; documented the 100-row cap.
- Stories now render the real FileViewerSheet / IssueWorkspaceCard for
truthful light+dark, desktop+360px, view-mode, and placement captures.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
8569315d74
commit
0581aa1256
7 files changed
+576
-451
No files matched your search
@@ -9,6 +9,7 @@ import {
|
||||
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Ban,
|
||||
Check,
|
||||
Cloud,
|
||||
@@ -16,10 +17,12 @@ import {
|
||||
FileCode2,
|
||||
FileSearch,
|
||||
FolderOpen,
|
||||
FolderSearch,
|
||||
Link2,
|
||||
Loader2,
|
||||
Lock,
|
||||
RefreshCcw,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Sheet,
|
||||
@@ -37,7 +40,6 @@ import {
|
||||
useRequiredFileViewer,
|
||||
type FileViewerUrlState,
|
||||
} from "@/context/FileViewerContext";
|
||||
import { parseWorkspaceFileRef } from "@/lib/workspace-file-parser";
|
||||
import { WorkspaceFileBrowser } from "@/components/WorkspaceFileBrowser";
|
||||
import type {
|
||||
ResolvedWorkspaceResource,
|
||||
@@ -178,112 +180,6 @@ function FileViewerStateView({
|
||||
);
|
||||
}
|
||||
|
||||
interface OpenFilePromptProps {
|
||||
onSubmit: (
|
||||
path: string,
|
||||
workspace: WorkspaceFileSelector,
|
||||
line: number | null,
|
||||
column: number | null,
|
||||
) => void;
|
||||
}
|
||||
|
||||
function OpenFilePrompt({ onSubmit }: OpenFilePromptProps) {
|
||||
const [value, setValue] = useState("");
|
||||
const [workspace, setWorkspace] = useState<WorkspaceFileSelector>("auto");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const frame = requestAnimationFrame(() => inputRef.current?.focus());
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
const parsed = parseWorkspaceFileRef(trimmed);
|
||||
if (parsed) {
|
||||
onSubmit(parsed.path, workspace, parsed.line, parsed.column);
|
||||
return;
|
||||
}
|
||||
onSubmit(trimmed, workspace, null, null);
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="space-y-4 p-6" onSubmit={handleSubmit}>
|
||||
<label htmlFor="paperclip-file-viewer-input" className="sr-only">
|
||||
Workspace-relative file path
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id="paperclip-file-viewer-input"
|
||||
type="text"
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="e.g. ui/src/pages/IssueDetail.tsx:42"
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-xs text-muted-foreground">Workspace</legend>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["auto", "execution", "project"] as const).map((option) => (
|
||||
<label
|
||||
key={option}
|
||||
className={cn(
|
||||
"cursor-pointer rounded-md border px-3 py-1 text-xs capitalize",
|
||||
workspace === option
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-foreground/80 hover:bg-accent/40",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="paperclip-file-viewer-workspace"
|
||||
value={option}
|
||||
checked={workspace === option}
|
||||
onChange={() => setWorkspace(option)}
|
||||
className="sr-only"
|
||||
/>
|
||||
{option === "auto" ? "Auto (issue default)" : option}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" size="sm" disabled={!value.trim()}>
|
||||
Open file
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
interface FileViewerEmptyStateProps {
|
||||
issueId: string;
|
||||
onBrowseOpen: (ref: { path: string; workspace: WorkspaceFileSelector }) => void;
|
||||
onSubmitPath: OpenFilePromptProps["onSubmit"];
|
||||
}
|
||||
|
||||
function FileViewerEmptyState({ issueId, onBrowseOpen, onSubmitPath }: FileViewerEmptyStateProps) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<WorkspaceFileBrowser
|
||||
issueId={issueId}
|
||||
onOpen={onBrowseOpen}
|
||||
className="min-h-0 flex-1 p-4 pb-2"
|
||||
/>
|
||||
<details className="border-t border-border">
|
||||
<summary className="cursor-pointer list-none px-4 py-2.5 text-xs text-muted-foreground hover:text-foreground">
|
||||
Open a specific path…
|
||||
</summary>
|
||||
<OpenFilePrompt onSubmit={onSubmitPath} />
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FileContentViewerProps {
|
||||
content: WorkspaceFileContent;
|
||||
highlightedLine: number | null;
|
||||
@@ -438,7 +334,12 @@ export function FileViewerSheet({
|
||||
}: FileViewerSheetProps) {
|
||||
const viewer = useRequiredFileViewer();
|
||||
const state = typeof stateProp !== "undefined" ? stateProp : viewer.state;
|
||||
const computedOpen = typeof openProp === "boolean" ? openProp : state !== null || showPromptWhenEmpty;
|
||||
// Browse mode: no file selected, but the sheet was opened to browse/search.
|
||||
const browseMode = state === null && (showPromptWhenEmpty || viewer.browse);
|
||||
// True when the current file was reached from the browse list (drill-down).
|
||||
const cameFromBrowse = state !== null && viewer.browse;
|
||||
const computedOpen =
|
||||
typeof openProp === "boolean" ? openProp : state !== null || showPromptWhenEmpty || viewer.browse;
|
||||
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
const [copiedField, setCopiedField] = useState<"path" | "link" | null>(null);
|
||||
@@ -506,14 +407,12 @@ export function FileViewerSheet({
|
||||
[onOpenChange, viewer],
|
||||
);
|
||||
|
||||
const handlePromptSubmit = useCallback(
|
||||
(
|
||||
path: string,
|
||||
workspace: WorkspaceFileSelector,
|
||||
line: number | null,
|
||||
column: number | null,
|
||||
) => {
|
||||
viewer.open({ path, line, column, workspace });
|
||||
const handleBrowseOpen = useCallback(
|
||||
(ref: { path: string; workspace: WorkspaceFileSelector; line?: number | null; column?: number | null }) => {
|
||||
viewer.open(
|
||||
{ path: ref.path, line: ref.line ?? null, column: ref.column ?? null, workspace: ref.workspace },
|
||||
{ fromBrowse: true },
|
||||
);
|
||||
},
|
||||
[viewer],
|
||||
);
|
||||
@@ -556,10 +455,21 @@ export function FileViewerSheet({
|
||||
aria-labelledby={FILE_VIEWER_LABELLED_BY_ID}
|
||||
aria-describedby={FILE_VIEWER_DESCRIBED_BY_ID}
|
||||
showCloseButton={false}
|
||||
onEscapeKeyDown={(event) => {
|
||||
// From a file reached via browse, Esc returns to the list; a second Esc closes.
|
||||
if (cameFromBrowse) {
|
||||
event.preventDefault();
|
||||
viewer.backToFiles();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SheetHeader className="border-b border-border gap-1 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<FileCode2 aria-hidden="true" className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
{browseMode ? (
|
||||
<FolderSearch aria-hidden="true" className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<FileCode2 aria-hidden="true" className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<SheetTitle id={FILE_VIEWER_LABELLED_BY_ID} className="truncate text-sm">
|
||||
{title}
|
||||
@@ -576,6 +486,19 @@ export function FileViewerSheet({
|
||||
</SheetDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{cameFromBrowse ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => viewer.backToFiles()}
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
aria-label="Back to files"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Back to files
|
||||
</Button>
|
||||
) : null}
|
||||
{state ? (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -602,16 +525,30 @@ export function FileViewerSheet({
|
||||
{copiedField === "link" ? <Check className="h-4 w-4 text-green-500" /> : <Link2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
className="h-7 px-2 text-xs"
|
||||
aria-label="Close file viewer"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
{cameFromBrowse ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
className="h-7 w-7"
|
||||
aria-label="Close file viewer"
|
||||
title="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
className="h-7 px-2 text-xs"
|
||||
aria-label="Close file viewer"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{resolvedResource ? (
|
||||
@@ -668,11 +605,12 @@ export function FileViewerSheet({
|
||||
: null
|
||||
}
|
||||
/>
|
||||
) : showPromptWhenEmpty ? (
|
||||
<FileViewerEmptyState
|
||||
) : browseMode ? (
|
||||
<WorkspaceFileBrowser
|
||||
issueId={issueId}
|
||||
onBrowseOpen={(ref) => viewer.open({ path: ref.path, line: null, column: null, workspace: ref.workspace })}
|
||||
onSubmitPath={handlePromptSubmit}
|
||||
onOpen={handleBrowseOpen}
|
||||
initialQuery={viewer.query}
|
||||
className="min-h-0 flex-1 p-4"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { queryKeys } from "../lib/queryKeys";
|
||||
import { orderReusableExecutionWorkspaces } from "../lib/reusable-execution-workspaces";
|
||||
import { cn, projectWorkspaceUrl } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check, Copy, GitBranch, FolderOpen, Pencil, X } from "lucide-react";
|
||||
import { Check, Copy, FileSearch, FolderOpen, FolderSearch, GitBranch, Pencil, X } from "lucide-react";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Utility helpers (mirrored from IssueProperties for self-containment) */
|
||||
@@ -192,6 +192,10 @@ interface IssueWorkspaceCardProps {
|
||||
initialEditing?: boolean;
|
||||
livePreview?: boolean;
|
||||
onDraftChange?: (data: Record<string, unknown>, meta: { canSave: boolean; workspaceBranchName?: string | null }) => void;
|
||||
/** Opens the workspace file browser sheet. When omitted, the browse row is hidden. */
|
||||
onBrowseFiles?: () => void;
|
||||
/** Opens the same browser sheet focused for path entry. */
|
||||
onOpenFileByPath?: () => void;
|
||||
}
|
||||
|
||||
export function IssueWorkspaceCard({
|
||||
@@ -201,6 +205,8 @@ export function IssueWorkspaceCard({
|
||||
initialEditing = false,
|
||||
livePreview = false,
|
||||
onDraftChange,
|
||||
onBrowseFiles,
|
||||
onOpenFileByPath,
|
||||
}: IssueWorkspaceCardProps) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const companyId = issue.companyId ?? selectedCompanyId;
|
||||
@@ -572,6 +578,28 @@ export function IssueWorkspaceCard({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Workspace file discovery — calm row under the workspace identity. */}
|
||||
{!showEditingControls && onBrowseFiles && (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-border/50 pt-2 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBrowseFiles}
|
||||
className="inline-flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<FolderSearch className="h-3.5 w-3.5 shrink-0" />
|
||||
Browse files…
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenFileByPath ?? onBrowseFiles}
|
||||
className="inline-flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<FileSearch className="h-3.5 w-3.5 shrink-0" />
|
||||
Open file by path…
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,14 +31,14 @@ function createItem(overrides: Partial<WorkspaceFileListItem> = {}): WorkspaceFi
|
||||
workspaceId: "ws-1",
|
||||
contentType: "text/plain; charset=utf-8",
|
||||
byteSize: 2048,
|
||||
modifiedAt: null,
|
||||
modifiedAt: new Date(Date.now() - 120_000).toISOString(),
|
||||
previewKind: "text",
|
||||
capabilities: { preview: true, download: false, listChildren: false },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function availableResponse(items: WorkspaceFileListItem[]): WorkspaceFileListResponse {
|
||||
function availableResponse(items: WorkspaceFileListItem[], truncated = false): WorkspaceFileListResponse {
|
||||
return {
|
||||
kind: "workspace_file_list",
|
||||
state: "available",
|
||||
@@ -51,7 +51,7 @@ function availableResponse(items: WorkspaceFileListItem[]): WorkspaceFileListRes
|
||||
query: { workspace: "auto", mode: "changed", q: null, limit: 100 },
|
||||
items,
|
||||
scannedCount: items.length,
|
||||
truncated: false,
|
||||
truncated,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,6 +68,10 @@ function unavailableResponse(reason: string): WorkspaceFileListResponse {
|
||||
};
|
||||
}
|
||||
|
||||
function ok(data: WorkspaceFileListResponse) {
|
||||
return { data, isFetching: false, isError: false, error: null, refetch: vi.fn() };
|
||||
}
|
||||
|
||||
describe("WorkspaceFileBrowser", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
@@ -89,82 +93,72 @@ describe("WorkspaceFileBrowser", () => {
|
||||
return { root, onOpen };
|
||||
}
|
||||
|
||||
it("lists available files and opens one with its relative path and workspace selector", () => {
|
||||
useQueryMock.mockReturnValue({
|
||||
data: availableResponse([createItem(), createItem({ relativePath: "README.md", displayPath: "README.md" })]),
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
it("renders the Recently changed list as a listbox 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="listbox"]')).not.toBeNull();
|
||||
expect(container.textContent).toContain("Recently changed");
|
||||
expect(container.textContent).toContain("From Isolated workspace");
|
||||
const fileButton = Array.from(container.querySelectorAll("button")).find((b) =>
|
||||
b.getAttribute("title") === "ui/src/pages/IssueDetail.tsx",
|
||||
|
||||
const option = Array.from(container.querySelectorAll('[role="option"]')).find(
|
||||
(el) => el.getAttribute("title") === "ui/src/pages/IssueDetail.tsx",
|
||||
);
|
||||
expect(fileButton).not.toBeUndefined();
|
||||
expect(option).not.toBeUndefined();
|
||||
|
||||
act(() => {
|
||||
fileButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
option!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
expect(onOpen).toHaveBeenCalledWith({ path: "ui/src/pages/IssueDetail.tsx", workspace: "auto" });
|
||||
});
|
||||
|
||||
it("shows the remote-workspace state without any file rows", () => {
|
||||
useQueryMock.mockReturnValue({
|
||||
data: unavailableResponse("remote_workspace"),
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
it("does not render a Recent/All toggle", () => {
|
||||
useQueryMock.mockReturnValue(ok(availableResponse([createItem()])));
|
||||
renderBrowser();
|
||||
expect(container.textContent).toContain("Remote workspace");
|
||||
expect(container.textContent).not.toContain("From Isolated workspace");
|
||||
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("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: "Enter", bubbles: true, cancelable: true }));
|
||||
});
|
||||
expect(onOpen).toHaveBeenCalledWith({ path: "ui/src/pages/IssueDetail.tsx", workspace: "auto" });
|
||||
});
|
||||
|
||||
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="listbox"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the no-workspace state when the issue has no workspace", () => {
|
||||
useQueryMock.mockReturnValue({
|
||||
data: unavailableResponse("no_workspace"),
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
useQueryMock.mockReturnValue(ok(unavailableResponse("no_workspace")));
|
||||
renderBrowser();
|
||||
expect(container.textContent).toContain("No workspace yet");
|
||||
});
|
||||
|
||||
it("offers a fallback to all files when recent-change tracking is unavailable", () => {
|
||||
useQueryMock.mockReturnValue({
|
||||
data: unavailableResponse("changed_unavailable"),
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
renderBrowser();
|
||||
expect(container.textContent).toContain("Recent changes unavailable");
|
||||
const fallback = Array.from(container.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes("Browse all files"),
|
||||
);
|
||||
expect(fallback).not.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeUnavailable", () => {
|
||||
it("maps each documented reason to a distinct, human-readable title", () => {
|
||||
expect(describeUnavailable("remote_workspace").title).toBe("Remote workspace");
|
||||
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 cleaned up");
|
||||
expect(describeUnavailable("changed_unavailable").title).toBe("Recent changes unavailable");
|
||||
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", () => {
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Cloud,
|
||||
FileCode2,
|
||||
FolderOpen,
|
||||
FolderSearch,
|
||||
Loader2,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Cloud, FileCode2, FolderOpen, Loader2, Search } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { timeAgo } from "@/lib/timeAgo";
|
||||
import { fileResourcesApi } from "@/api/file-resources";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { parseWorkspaceFileRef } from "@/lib/workspace-file-parser";
|
||||
import type {
|
||||
WorkspaceFileListItem,
|
||||
WorkspaceFileListMode,
|
||||
@@ -27,20 +28,11 @@ const WORKSPACE_OPTIONS: ReadonlyArray<{ value: WorkspaceFileSelector; label: st
|
||||
{ value: "project", label: "Project" },
|
||||
];
|
||||
|
||||
const MODE_OPTIONS: ReadonlyArray<{ value: Extract<WorkspaceFileListMode, "changed" | "all">; label: string }> = [
|
||||
{ value: "changed", label: "Recent changes" },
|
||||
{ value: "all", label: "All files" },
|
||||
];
|
||||
|
||||
// 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 formatBytes(size: number | null | undefined): string | null {
|
||||
if (typeof size !== "number" || !Number.isFinite(size) || size < 0) return null;
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function basename(path: string): string {
|
||||
const idx = path.lastIndexOf("/");
|
||||
return idx < 0 ? path : path.slice(idx + 1);
|
||||
@@ -52,17 +44,18 @@ function dirname(path: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a server `unavailableReason` (or transport error) to a calm, board-readable
|
||||
* explanation. Substring matching keeps it resilient to small reason-string changes
|
||||
* on the server, mirroring describeDenial() in FileViewerSheet.
|
||||
* 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",
|
||||
body: "This issue runs in a remote workspace, so its files can't be browsed here yet.",
|
||||
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")) {
|
||||
@@ -72,18 +65,11 @@ export function describeUnavailable(reason: string): { title: string; body: stri
|
||||
body: "This issue does not have a workspace to browse. Files appear here once a run creates one.",
|
||||
};
|
||||
}
|
||||
if (lower.includes("changed")) {
|
||||
return {
|
||||
icon: <AlertTriangle aria-hidden="true" className="h-5 w-5 text-amber-500" />,
|
||||
title: "Recent changes unavailable",
|
||||
body: "Recent-change tracking isn't available for this workspace. Browse all files instead.",
|
||||
};
|
||||
}
|
||||
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 cleaned up",
|
||||
body: "The workspace for this issue has been cleaned up, so its files can no longer be browsed.",
|
||||
title: "Workspace is no longer available",
|
||||
body: "The isolated worktree for this issue has been cleaned up, so files cannot be previewed.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -93,39 +79,28 @@ export function describeUnavailable(reason: string): { title: string; body: stri
|
||||
};
|
||||
}
|
||||
|
||||
function StateMessage({ icon, title, body, action }: { icon: ReactNode; title: string; body?: string; action?: ReactNode }) {
|
||||
function StateMessage({ icon, title, body }: { icon: ReactNode; title: string; body?: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-3 px-1 py-8 text-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
{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 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>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SegmentedControl<T extends string>({
|
||||
label,
|
||||
function WorkspaceSelector({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: T;
|
||||
options: ReadonlyArray<{ value: T; label: string }>;
|
||||
onChange: (next: T) => void;
|
||||
value: WorkspaceFileSelector;
|
||||
onChange: (next: WorkspaceFileSelector) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={label}
|
||||
className="inline-flex shrink-0 rounded-md border border-border p-0.5"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<div role="group" aria-label="Workspace" className="inline-flex shrink-0 rounded-md border border-border p-0.5">
|
||||
{WORKSPACE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
@@ -133,9 +108,7 @@ function SegmentedControl<T extends string>({
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
"rounded px-2 py-1 text-xs transition-colors",
|
||||
value === option.value
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
value === option.value ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
@@ -147,74 +120,116 @@ function SegmentedControl<T extends string>({
|
||||
|
||||
interface WorkspaceFileRowProps {
|
||||
item: WorkspaceFileListItem;
|
||||
onOpen: (item: WorkspaceFileListItem) => void;
|
||||
optionId: string;
|
||||
selected: boolean;
|
||||
showTimestamp: boolean;
|
||||
onOpen: () => void;
|
||||
onHover: () => void;
|
||||
}
|
||||
|
||||
function WorkspaceFileRow({ item, onOpen }: WorkspaceFileRowProps) {
|
||||
function WorkspaceFileRow({ item, optionId, selected, showTimestamp, onOpen, onHover }: WorkspaceFileRowProps) {
|
||||
const name = basename(item.displayPath);
|
||||
const dir = dirname(item.displayPath);
|
||||
const size = formatBytes(item.byteSize);
|
||||
const stamp = showTimestamp && item.modifiedAt ? timeAgo(item.modifiedAt) : null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(item)}
|
||||
<div
|
||||
id={optionId}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
onClick={onOpen}
|
||||
onMouseEnter={onHover}
|
||||
title={item.displayPath}
|
||||
className="group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className={cn(
|
||||
"flex min-h-[44px] cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 sm:min-h-0",
|
||||
selected ? "bg-accent" : "hover:bg-accent/60",
|
||||
)}
|
||||
>
|
||||
<FileCode2 aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="flex min-w-0 flex-1 items-baseline gap-0 overflow-hidden">
|
||||
{/* Desktop (>=sm): single line, directory truncates and basename stays visible. */}
|
||||
<span className="hidden min-w-0 flex-1 items-baseline gap-0 overflow-hidden sm:flex">
|
||||
{dir ? (
|
||||
<span className="min-w-0 shrink truncate font-mono text-xs text-muted-foreground">
|
||||
{dir}
|
||||
</span>
|
||||
<span className="min-w-0 shrink truncate font-mono text-xs text-muted-foreground">{dir}</span>
|
||||
) : null}
|
||||
<span className="shrink-0 truncate font-mono text-xs text-foreground">{name}</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span className="capitalize">{item.previewKind}</span>
|
||||
{size ? <span className="tabular-nums">{size}</span> : null}
|
||||
{/* Mobile (<sm): two lines, basename first so the filename is always readable. */}
|
||||
<span className="flex min-w-0 flex-1 flex-col overflow-hidden sm:hidden">
|
||||
<span className="truncate font-mono text-xs text-foreground">{name}</span>
|
||||
{dir ? (
|
||||
<span className="truncate font-mono text-[11px] text-muted-foreground" style={{ overflowWrap: "anywhere" }}>
|
||||
{dir}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
{stamp ? (
|
||||
<span className="hidden shrink-0 text-[11px] tabular-nums text-muted-foreground sm:inline">{stamp}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface WorkspaceFileBrowserProps {
|
||||
issueId: string;
|
||||
onOpen: (ref: { path: string; workspace: WorkspaceFileSelector }) => void;
|
||||
onOpen: (ref: {
|
||||
path: string;
|
||||
workspace: WorkspaceFileSelector;
|
||||
line?: number | null;
|
||||
column?: number | null;
|
||||
}) => void;
|
||||
/** Seed the search field (e.g. from a URL-backed deep link). */
|
||||
initialQuery?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WorkspaceFileBrowser({ issueId, onOpen, className }: WorkspaceFileBrowserProps) {
|
||||
export function WorkspaceFileBrowser({ issueId, onOpen, initialQuery, className }: WorkspaceFileBrowserProps) {
|
||||
const [workspace, setWorkspace] = useState<WorkspaceFileSelector>("auto");
|
||||
const [mode, setMode] = useState<Extract<WorkspaceFileListMode, "changed" | "all">>("changed");
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||
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 listboxId = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = window.setTimeout(() => setDebouncedQuery(searchInput.trim()), 250);
|
||||
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]);
|
||||
|
||||
const q = debouncedQuery || null;
|
||||
const isSearch = q !== null;
|
||||
const mode: WorkspaceFileListMode = isSearch ? "all" : recentUnavailable ? "all" : "changed";
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: queryKeys.issues.fileResources(issueId, {
|
||||
workspace,
|
||||
mode,
|
||||
q,
|
||||
limit: LIST_LIMIT,
|
||||
}),
|
||||
queryFn: () =>
|
||||
fileResourcesApi.list(issueId, { workspace, mode, q, limit: LIST_LIMIT }),
|
||||
queryKey: queryKeys.issues.fileResources(issueId, { workspace, mode, q, limit: LIST_LIMIT }),
|
||||
queryFn: () => fileResourcesApi.list(issueId, { workspace, mode, q, limit: LIST_LIMIT }),
|
||||
retry: false,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
const data = listQuery.data;
|
||||
const items = data?.items ?? [];
|
||||
const items = useMemo(() => data?.items ?? [], [data]);
|
||||
const workspaceLabel = data?.workspace?.workspaceLabel ?? null;
|
||||
|
||||
const liveRegionRef = useRef<HTMLDivElement>(null);
|
||||
// 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(() => {
|
||||
setHighlightedIndex(items.length > 0 ? 0 : -1);
|
||||
}, [items, q, workspace]);
|
||||
|
||||
const announcement = useMemo(() => {
|
||||
if (listQuery.isFetching) return "Loading workspace files…";
|
||||
if (listQuery.isError) return "Unable to load workspace files.";
|
||||
@@ -223,6 +238,31 @@ export function WorkspaceFileBrowser({ issueId, onOpen, className }: WorkspaceFi
|
||||
return `${items.length} file${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);
|
||||
if (parsed) onOpen({ path: parsed.path, workspace, line: parsed.line, column: parsed.column });
|
||||
else onOpen({ path: value, workspace });
|
||||
}
|
||||
|
||||
function handleSearchKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setHighlightedIndex((current) => (items.length === 0 ? -1 : Math.min(items.length - 1, current + 1)));
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setHighlightedIndex((current) => (items.length === 0 ? -1 : Math.max(0, current - 1)));
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const item = highlightedIndex >= 0 ? items[highlightedIndex] : undefined;
|
||||
if (item) onOpen({ path: item.relativePath, workspace });
|
||||
else openTypedPath();
|
||||
}
|
||||
}
|
||||
|
||||
const activeOptionId = highlightedIndex >= 0 ? `${listboxId}-opt-${highlightedIndex}` : undefined;
|
||||
|
||||
let body: ReactNode;
|
||||
if (listQuery.isFetching && !data) {
|
||||
body = (
|
||||
@@ -237,68 +277,40 @@ export function WorkspaceFileBrowser({ issueId, onOpen, className }: WorkspaceFi
|
||||
);
|
||||
} else if (listQuery.isError) {
|
||||
const status = listQuery.error instanceof ApiError ? listQuery.error.status : 0;
|
||||
const fallback =
|
||||
status === 404
|
||||
? "Workspace browsing isn't available for this issue."
|
||||
: "Something went wrong loading workspace files.";
|
||||
body = (
|
||||
<StateMessage
|
||||
icon={<AlertTriangle aria-hidden="true" className="h-5 w-5 text-amber-500" />}
|
||||
title="Couldn't load files"
|
||||
body={fallback}
|
||||
action={
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => void listQuery.refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
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 ?? "");
|
||||
const isChangedReason = (data.unavailableReason ?? "").toLowerCase().includes("changed");
|
||||
body = (
|
||||
<StateMessage
|
||||
icon={detail.icon}
|
||||
title={detail.title}
|
||||
body={detail.body}
|
||||
action={
|
||||
isChangedReason && mode !== "all" ? (
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => setMode("all")}>
|
||||
Browse all files
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
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={debouncedQuery ? "No files match your search" : "No files to show"}
|
||||
body={
|
||||
debouncedQuery
|
||||
? "Try a different path or name, or switch to all files."
|
||||
: mode === "changed"
|
||||
? "No recent changes detected. Switch to all files to browse everything."
|
||||
: "This workspace has no browsable files."
|
||||
}
|
||||
action={
|
||||
mode !== "all" ? (
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => setMode("all")}>
|
||||
Browse all files
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
title={isSearch ? `No files match “${q}”` : "No recently changed files yet"}
|
||||
body="Try searching by name or path."
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<div className="space-y-0.5 py-1">
|
||||
{items.map((item) => (
|
||||
<div role="listbox" id={listboxId} aria-label="Workspace files" className="space-y-0.5 py-1">
|
||||
{items.map((item, index) => (
|
||||
<WorkspaceFileRow
|
||||
key={`${item.workspaceId}:${item.relativePath}`}
|
||||
item={item}
|
||||
onOpen={(picked) => onOpen({ path: picked.relativePath, workspace })}
|
||||
optionId={`${listboxId}-opt-${index}`}
|
||||
selected={index === highlightedIndex}
|
||||
showTimestamp={!isSearch}
|
||||
onOpen={() => onOpen({ path: item.relativePath, workspace })}
|
||||
onHover={() => setHighlightedIndex(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -307,53 +319,62 @@ export function WorkspaceFileBrowser({ issueId, onOpen, className }: WorkspaceFi
|
||||
|
||||
return (
|
||||
<div className={cn("flex min-h-0 flex-col gap-2", className)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderSearch aria-hidden="true" className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="relative flex-1">
|
||||
<Search
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
value={searchInput}
|
||||
onChange={(event) => setSearchInput(event.target.value)}
|
||||
placeholder="Search workspace files by path or name"
|
||||
aria-label="Search workspace files"
|
||||
className="h-8 pl-8 font-mono text-xs"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<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
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="h-8 pl-8 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<SegmentedControl label="Workspace" value={workspace} options={WORKSPACE_OPTIONS} onChange={setWorkspace} />
|
||||
<SegmentedControl label="File set" value={mode} options={MODE_OPTIONS} onChange={setMode} />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Workspace</span>
|
||||
<WorkspaceSelector value={workspace} onChange={setWorkspace} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-baseline justify-between gap-2 pt-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{isSearch ? <>Files matching “{q}”</> : "Recently changed"}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
{listQuery.isFetching ? <Loader2 aria-hidden="true" className="h-3 w-3 animate-spin" /> : null}
|
||||
{data?.state === "available" && items.length > 0 ? <span>· {items.length}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{workspaceLabel ? (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span className="truncate" title={workspaceLabel}>
|
||||
From {workspaceLabel}
|
||||
</span>
|
||||
{listQuery.isFetching ? (
|
||||
<Loader2 aria-hidden="true" className="h-3 w-3 shrink-0 animate-spin" />
|
||||
) : null}
|
||||
{data?.truncated ? (
|
||||
<>
|
||||
<span aria-hidden="true" className="opacity-50">·</span>
|
||||
<span>Showing first {items.length}</span>
|
||||
</>
|
||||
) : null}
|
||||
<div className="truncate text-[11px] text-muted-foreground" title={workspaceLabel}>
|
||||
From {workspaceLabel}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div ref={liveRegionRef} aria-live="polite" className="sr-only">
|
||||
<div aria-live="polite" className="sr-only">
|
||||
{announcement}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
|
||||
|
||||
{data?.truncated ? (
|
||||
<div className="border-t border-border pt-2 text-[11px] text-muted-foreground">
|
||||
Showing first {items.length} — refine the search to narrow.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,18 @@ export interface FileViewerContextValue {
|
||||
issueId: string;
|
||||
/** Current viewer state derived from the URL, or null if closed. */
|
||||
state: FileViewerUrlState | null;
|
||||
open(ref: Pick<ParsedWorkspaceFileRef, "path" | "line" | "column"> & { workspace?: WorkspaceFileSelector }): void;
|
||||
/** True when the sheet is in browse mode (URL carries `browse=1`). */
|
||||
browse: boolean;
|
||||
/** The active browse search query (URL `q`), or null. */
|
||||
query: string | null;
|
||||
open(
|
||||
ref: Pick<ParsedWorkspaceFileRef, "path" | "line" | "column"> & { workspace?: WorkspaceFileSelector },
|
||||
opts?: { fromBrowse?: boolean },
|
||||
): void;
|
||||
/** Open (or stay in) browse mode, optionally seeding the search query. */
|
||||
openBrowse(opts?: { q?: string }): void;
|
||||
/** From a file opened via browse, return to the browse list. */
|
||||
backToFiles(): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
@@ -42,6 +53,9 @@ export function readFileViewerStateFromSearch(search: string): FileViewerUrlStat
|
||||
|
||||
export function writeFileViewerStateToSearch(current: string, next: FileViewerUrlState | null): string {
|
||||
const params = new URLSearchParams(current);
|
||||
// A direct file open/close is never a browse origin — clear browse params too.
|
||||
params.delete("browse");
|
||||
params.delete("q");
|
||||
if (!next) {
|
||||
params.delete("file");
|
||||
params.delete("line");
|
||||
@@ -60,6 +74,17 @@ export function writeFileViewerStateToSearch(current: string, next: FileViewerUr
|
||||
return str ? `?${str}` : "";
|
||||
}
|
||||
|
||||
export interface FileViewerBrowseState {
|
||||
q: string | null;
|
||||
}
|
||||
|
||||
export function readBrowseStateFromSearch(search: string): FileViewerBrowseState | null {
|
||||
const params = new URLSearchParams(search);
|
||||
if (params.get("browse") !== "1") return null;
|
||||
const q = params.get("q");
|
||||
return { q: q && q.length > 0 ? q : null };
|
||||
}
|
||||
|
||||
interface FileViewerProviderProps {
|
||||
issueId: string;
|
||||
children: ReactNode;
|
||||
@@ -69,34 +94,81 @@ export function FileViewerProvider({ issueId, children }: FileViewerProviderProp
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const state = useMemo(() => readFileViewerStateFromSearch(location.search), [location.search]);
|
||||
const browseState = useMemo(() => readBrowseStateFromSearch(location.search), [location.search]);
|
||||
|
||||
const open = useCallback<FileViewerContextValue["open"]>(
|
||||
(ref) => {
|
||||
const nextSearch = writeFileViewerStateToSearch(location.search, {
|
||||
path: ref.path,
|
||||
line: ref.line ?? null,
|
||||
column: ref.column ?? null,
|
||||
workspace: ref.workspace ?? "auto",
|
||||
});
|
||||
const navigateSearch = useCallback(
|
||||
(nextSearch: string) => {
|
||||
navigate(
|
||||
{ pathname: location.pathname, hash: location.hash, search: nextSearch },
|
||||
{ state: location.state, replace: false },
|
||||
);
|
||||
},
|
||||
[location.hash, location.pathname, location.search, location.state, navigate],
|
||||
[location.hash, location.pathname, location.state, navigate],
|
||||
);
|
||||
|
||||
const open = useCallback<FileViewerContextValue["open"]>(
|
||||
(ref, opts) => {
|
||||
let nextSearch = writeFileViewerStateToSearch(location.search, {
|
||||
path: ref.path,
|
||||
line: ref.line ?? null,
|
||||
column: ref.column ?? null,
|
||||
workspace: ref.workspace ?? "auto",
|
||||
});
|
||||
if (opts?.fromBrowse) {
|
||||
const params = new URLSearchParams(nextSearch);
|
||||
params.set("browse", "1");
|
||||
const prevQ = new URLSearchParams(location.search).get("q");
|
||||
if (prevQ) params.set("q", prevQ);
|
||||
nextSearch = params.toString() ? `?${params.toString()}` : "";
|
||||
}
|
||||
navigateSearch(nextSearch);
|
||||
},
|
||||
[location.search, navigateSearch],
|
||||
);
|
||||
|
||||
const openBrowse = useCallback<FileViewerContextValue["openBrowse"]>(
|
||||
(opts) => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete("file");
|
||||
params.delete("line");
|
||||
params.delete("column");
|
||||
params.set("browse", "1");
|
||||
if (typeof opts?.q === "string" && opts.q.length > 0) params.set("q", opts.q);
|
||||
else params.delete("q");
|
||||
navigateSearch(params.toString() ? `?${params.toString()}` : "");
|
||||
},
|
||||
[location.search, navigateSearch],
|
||||
);
|
||||
|
||||
const backToFiles = useCallback(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete("file");
|
||||
params.delete("line");
|
||||
params.delete("column");
|
||||
params.delete("workspace");
|
||||
params.set("browse", "1");
|
||||
navigateSearch(params.toString() ? `?${params.toString()}` : "");
|
||||
}, [location.search, navigateSearch]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
const nextSearch = writeFileViewerStateToSearch(location.search, null);
|
||||
navigate(
|
||||
{ pathname: location.pathname, hash: location.hash, search: nextSearch },
|
||||
{ state: location.state, replace: false },
|
||||
);
|
||||
}, [location.hash, location.pathname, location.search, location.state, navigate]);
|
||||
const params = new URLSearchParams(writeFileViewerStateToSearch(location.search, null).replace(/^\?/, ""));
|
||||
params.delete("browse");
|
||||
params.delete("q");
|
||||
navigateSearch(params.toString() ? `?${params.toString()}` : "");
|
||||
}, [location.search, navigateSearch]);
|
||||
|
||||
const value = useMemo<FileViewerContextValue>(
|
||||
() => ({ issueId, state, open, close }),
|
||||
[issueId, state, open, close],
|
||||
() => ({
|
||||
issueId,
|
||||
state,
|
||||
browse: browseState !== null,
|
||||
query: browseState?.q ?? null,
|
||||
open,
|
||||
openBrowse,
|
||||
backToFiles,
|
||||
close,
|
||||
}),
|
||||
[issueId, state, browseState, open, openBrowse, backToFiles, close],
|
||||
);
|
||||
|
||||
return <FileViewerContext.Provider value={value}>{children}</FileViewerContext.Provider>;
|
||||
|
||||
@@ -136,7 +136,6 @@ import {
|
||||
EyeOff,
|
||||
Flag,
|
||||
FileCode2,
|
||||
FolderSearch,
|
||||
Hexagon,
|
||||
ListTree,
|
||||
MessageSquare,
|
||||
@@ -4041,6 +4040,8 @@ export function IssueDetail() {
|
||||
issue={issue}
|
||||
project={resolvedProject}
|
||||
onUpdate={(data) => updateIssue.mutate(data)}
|
||||
onBrowseFiles={() => setFileViewerPromptOpen(true)}
|
||||
onOpenFileByPath={() => setFileViewerPromptOpen(true)}
|
||||
/>
|
||||
|
||||
{issue.workProducts && issue.workProducts.length > 0 && (() => {
|
||||
@@ -4068,19 +4069,6 @@ export function IssueDetail() {
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="flex justify-start">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 px-2 text-xs text-muted-foreground"
|
||||
onClick={() => setFileViewerPromptOpen(true)}
|
||||
title="Browse and search files in this issue's workspace"
|
||||
>
|
||||
<FolderSearch className="h-3.5 w-3.5" />
|
||||
Browse workspace
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Tabs value={detailTab} onValueChange={setDetailTab} className="space-y-3">
|
||||
@@ -4418,8 +4406,8 @@ function IssueFileViewer({
|
||||
onPromptOpenChange: (next: boolean) => void;
|
||||
}) {
|
||||
const viewer = useRequiredFileViewer();
|
||||
const open = viewer.state !== null || promptOpen;
|
||||
const showPromptWhenEmpty = promptOpen && viewer.state === null;
|
||||
const open = viewer.state !== null || viewer.browse || promptOpen;
|
||||
const showPromptWhenEmpty = (promptOpen || viewer.browse) && viewer.state === null;
|
||||
return (
|
||||
<FileViewerSheet
|
||||
issueId={issueId}
|
||||
@@ -4428,7 +4416,8 @@ function IssueFileViewer({
|
||||
onOpenChange={(next) => {
|
||||
if (!next) {
|
||||
onPromptOpenChange(false);
|
||||
if (viewer.state !== null) viewer.close();
|
||||
// Clears any file view and browse state from the URL.
|
||||
viewer.close();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,66 +1,66 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { WorkspaceFileListItem, WorkspaceFileListResponse } from "@paperclipai/shared";
|
||||
import { WorkspaceFileBrowser } from "@/components/WorkspaceFileBrowser";
|
||||
import type {
|
||||
ResolvedWorkspaceResource,
|
||||
WorkspaceFileContent,
|
||||
WorkspaceFileListItem,
|
||||
WorkspaceFileListResponse,
|
||||
} from "@paperclipai/shared";
|
||||
import { FileViewerProvider, useRequiredFileViewer } from "@/context/FileViewerContext";
|
||||
import { FileViewerSheet } from "@/components/FileViewerSheet";
|
||||
import { IssueWorkspaceCard } from "@/components/IssueWorkspaceCard";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
/**
|
||||
* Screenshot review surface for PAP-10511 — the issue-page "Browse workspace"
|
||||
* fallback. Each story seeds the react-query cache for the default browse query
|
||||
* (workspace=auto, mode=changed) so the component renders without a backend.
|
||||
* Screenshot-review surface for PAP-10511, rendering the REAL FileViewerSheet /
|
||||
* IssueWorkspaceCard (not a static frame) so the captures are visual truth.
|
||||
* Stories seed the react-query cache so the components render without a backend.
|
||||
* Capture each at desktop + 360px and in both themes via the Storybook theme global.
|
||||
*/
|
||||
|
||||
const ISSUE_ID = "issue-browse-demo";
|
||||
|
||||
function seedKey(issueId: string) {
|
||||
return queryKeys.issues.fileResources(issueId, {
|
||||
workspace: "auto",
|
||||
mode: "changed",
|
||||
q: null,
|
||||
limit: 100,
|
||||
});
|
||||
function listKey(issueId: string) {
|
||||
return queryKeys.issues.fileResources(issueId, { workspace: "auto", mode: "changed", q: null, limit: 100 });
|
||||
}
|
||||
|
||||
function item(relativePath: string, overrides: Partial<WorkspaceFileListItem> = {}): WorkspaceFileListItem {
|
||||
function item(relativePath: string, minutesAgo: number, overrides: Partial<WorkspaceFileListItem> = {}): WorkspaceFileListItem {
|
||||
return {
|
||||
kind: "file",
|
||||
provider: "git_worktree",
|
||||
title: relativePath.split("/").pop() ?? relativePath,
|
||||
relativePath,
|
||||
displayPath: relativePath,
|
||||
workspaceLabel: "Isolated workspace",
|
||||
workspaceLabel: "issue execution workspace · PAP-1953",
|
||||
workspaceKind: "execution_workspace",
|
||||
workspaceId: "ws-1",
|
||||
contentType: "text/plain; charset=utf-8",
|
||||
byteSize: 2048,
|
||||
modifiedAt: null,
|
||||
modifiedAt: new Date(Date.now() - minutesAgo * 60_000).toISOString(),
|
||||
previewKind: "text",
|
||||
capabilities: { preview: true, download: false, listChildren: false },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const availableData: WorkspaceFileListResponse = {
|
||||
const recentList: WorkspaceFileListResponse = {
|
||||
kind: "workspace_file_list",
|
||||
state: "available",
|
||||
workspace: {
|
||||
provider: "git_worktree",
|
||||
workspaceLabel: "Isolated workspace",
|
||||
workspaceLabel: "issue execution workspace · PAP-1953",
|
||||
workspaceKind: "execution_workspace",
|
||||
workspaceId: "ws-1",
|
||||
},
|
||||
query: { workspace: "auto", mode: "changed", q: null, limit: 100 },
|
||||
items: [
|
||||
item("ui/src/components/WorkspaceFileBrowser.tsx"),
|
||||
item("ui/src/components/FileViewerSheet.tsx", { byteSize: 28_400 }),
|
||||
item("server/src/routes/file-resources.ts", { byteSize: 8_120 }),
|
||||
item("packages/shared/src/types/workspace-file-resource.ts", { byteSize: 3_200 }),
|
||||
item("docs/screenshots/preview.png", { previewKind: "image", contentType: "image/png", byteSize: 102_400 }),
|
||||
item(
|
||||
"server/src/services/very/deeply/nested/directory/structure/that/keeps/going/workspace-file-resources.ts",
|
||||
{ byteSize: 16_900 },
|
||||
),
|
||||
item("ui/src/components/WorkspaceFileBrowser.tsx", 2),
|
||||
item("ui/src/components/FileViewerSheet.tsx", 2),
|
||||
item("server/src/routes/file-resources.ts", 14),
|
||||
item("doc/PRODUCT.md", 60),
|
||||
item("packages/shared/src/types/workspace-file-resource.ts", 64),
|
||||
item("server/src/services/very/deeply/nested/structure/workspace-file-resources.ts", 180),
|
||||
],
|
||||
scannedCount: 412,
|
||||
truncated: true,
|
||||
@@ -79,45 +79,128 @@ function unavailable(reason: string): WorkspaceFileListResponse {
|
||||
};
|
||||
}
|
||||
|
||||
function SheetFrame({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-[560px] w-[420px] flex-col overflow-hidden rounded-lg border border-border bg-background">
|
||||
<div className="border-b border-border p-3">
|
||||
<p className="text-sm font-medium text-foreground">Browse workspace</p>
|
||||
<p className="text-xs text-muted-foreground">Search and preview files from this issue's workspace.</p>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col p-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const viewedResource: ResolvedWorkspaceResource = {
|
||||
kind: "file",
|
||||
provider: "git_worktree",
|
||||
title: "FileViewerSheet.tsx",
|
||||
displayPath: "ui/src/components/FileViewerSheet.tsx",
|
||||
workspaceLabel: "issue execution workspace · PAP-1953",
|
||||
workspaceKind: "execution_workspace",
|
||||
workspaceId: "ws-1",
|
||||
contentType: "text/plain; charset=utf-8",
|
||||
byteSize: 2048,
|
||||
previewKind: "text",
|
||||
capabilities: { preview: true, download: false, listChildren: false },
|
||||
};
|
||||
|
||||
function Demo({ data }: { data: WorkspaceFileListResponse }) {
|
||||
const viewedContent: WorkspaceFileContent = {
|
||||
resource: viewedResource,
|
||||
content: {
|
||||
encoding: "utf8",
|
||||
data: [
|
||||
"export function FileViewerSheet({ issueId, state, open }: FileViewerSheetProps) {",
|
||||
" const viewer = useRequiredFileViewer();",
|
||||
" const browseMode = state === null && (showPromptWhenEmpty || viewer.browse);",
|
||||
" const cameFromBrowse = state !== null && viewer.browse;",
|
||||
" // … single sheet, two modes: browse ⇄ view",
|
||||
" return <Sheet open={open}>…</Sheet>;",
|
||||
"}",
|
||||
].join("\n"),
|
||||
},
|
||||
};
|
||||
|
||||
function BrowseSheet({ data }: { data: WorkspaceFileListResponse }) {
|
||||
const queryClient = useQueryClient();
|
||||
useMemo(() => {
|
||||
queryClient.setQueryData(seedKey(ISSUE_ID), data);
|
||||
}, [queryClient, data]);
|
||||
queryClient.setQueryData(listKey(ISSUE_ID), data);
|
||||
return (
|
||||
<SheetFrame>
|
||||
<WorkspaceFileBrowser
|
||||
issueId={ISSUE_ID}
|
||||
onOpen={(ref) => console.log("open", ref)}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
</SheetFrame>
|
||||
<FileViewerProvider issueId={ISSUE_ID}>
|
||||
<FileViewerSheet issueId={ISSUE_ID} showPromptWhenEmpty />
|
||||
</FileViewerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof Demo> = {
|
||||
function ViewFromBrowseInner() {
|
||||
const viewer = useRequiredFileViewer();
|
||||
useEffect(() => {
|
||||
viewer.open(
|
||||
{ path: "ui/src/components/FileViewerSheet.tsx", line: 3, column: null, workspace: "auto" },
|
||||
{ fromBrowse: true },
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return <FileViewerSheet issueId={ISSUE_ID} />;
|
||||
}
|
||||
|
||||
function ViewFromBrowse() {
|
||||
const queryClient = useQueryClient();
|
||||
queryClient.setQueryData(listKey(ISSUE_ID), recentList);
|
||||
queryClient.setQueryData(
|
||||
queryKeys.issues.fileResource(ISSUE_ID, "ui/src/components/FileViewerSheet.tsx", "auto"),
|
||||
viewedResource,
|
||||
);
|
||||
queryClient.setQueryData(
|
||||
queryKeys.issues.fileResourceContent(ISSUE_ID, "ui/src/components/FileViewerSheet.tsx", "auto"),
|
||||
viewedContent,
|
||||
);
|
||||
return (
|
||||
<FileViewerProvider issueId={ISSUE_ID}>
|
||||
<ViewFromBrowseInner />
|
||||
</FileViewerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Placement() {
|
||||
const queryClient = useQueryClient();
|
||||
queryClient.setQueryData(queryKeys.instance.experimentalSettings, {
|
||||
enableEnvironments: false,
|
||||
enableIsolatedWorkspaces: true,
|
||||
});
|
||||
return (
|
||||
<FileViewerProvider issueId={ISSUE_ID}>
|
||||
<div className="mx-auto max-w-3xl space-y-3 p-6">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Workspace</h3>
|
||||
<IssueWorkspaceCard
|
||||
issue={{
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "pw-1",
|
||||
executionWorkspaceId: "ws-1",
|
||||
executionWorkspacePreference: "isolated_workspace",
|
||||
executionWorkspaceSettings: { mode: "isolated_workspace", environmentId: null },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
currentExecutionWorkspace: {
|
||||
id: "ws-1",
|
||||
mode: "isolated_workspace",
|
||||
status: "active",
|
||||
branchName: "PAP-1953-plan-a-file-viewer",
|
||||
cwd: "/srv/paperclip/.../worktrees/PAP-1953",
|
||||
repoUrl: null,
|
||||
projectWorkspaceId: "pw-1",
|
||||
name: "PAP-1953",
|
||||
config: {},
|
||||
} as any,
|
||||
}}
|
||||
project={{ id: "project-1", executionWorkspacePolicy: { enabled: true, defaultMode: "isolated_workspace" } }}
|
||||
onUpdate={() => {}}
|
||||
onBrowseFiles={() => {}}
|
||||
onOpenFileByPath={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</FileViewerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Issue/Workspace File Browser",
|
||||
component: Demo,
|
||||
parameters: { layout: "centered" },
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Demo>;
|
||||
type Story = StoryObj;
|
||||
|
||||
export const RecentChanges: Story = { args: { data: availableData } };
|
||||
export const NoWorkspace: Story = { args: { data: unavailable("no_workspace") } };
|
||||
export const RemoteWorkspace: Story = { args: { data: unavailable("remote_workspace") } };
|
||||
export const CleanedUpWorkspace: Story = { args: { data: unavailable("workspace_unavailable") } };
|
||||
export const RecentChangesUnavailable: Story = { args: { data: unavailable("changed_unavailable") } };
|
||||
export const BrowseRecent: Story = { render: () => <BrowseSheet data={recentList} /> };
|
||||
export const BrowseNoWorkspace: Story = { render: () => <BrowseSheet data={unavailable("no_workspace")} /> };
|
||||
export const BrowseRemoteWorkspace: Story = { render: () => <BrowseSheet data={unavailable("remote_workspace")} /> };
|
||||
export const BrowseCleanedUpWorkspace: Story = { render: () => <BrowseSheet data={unavailable("workspace_unavailable")} /> };
|
||||
export const ViewModeFromBrowse: Story = { render: () => <ViewFromBrowse /> };
|
||||
export const CardPlacement: Story = { render: () => <Placement /> };
|
||||
Reference in new issue
Block a user