Wire cross-project file refs into viewer UI

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-09 21:35:48 +00:00
1 parent 4f42079974
commit 1019e011c0
15 files changed
+536 -43

No files matched your search

+8
View File
@@ -10,10 +10,14 @@ import { api } from "./client";
export interface FileResourceQuery {
path: string;
workspace?: WorkspaceFileSelector;
projectId?: string | null;
workspaceId?: string | null;
}
export interface FileResourceListQuery {
workspace?: WorkspaceFileSelector;
projectId?: string | null;
workspaceId?: string | null;
mode?: WorkspaceFileListMode;
q?: string | null;
limit?: number;
@@ -21,6 +25,10 @@ export interface FileResourceListQuery {
function buildQuery(query: FileResourceQuery | FileResourceListQuery): string {
const params = new URLSearchParams();
if (query.projectId && query.workspaceId) {
params.set("projectId", query.projectId);
params.set("workspaceId", query.workspaceId);
}
if ("path" in query) params.set("path", query.path);
if (query.workspace && query.workspace !== "auto") {
params.set("workspace", query.workspace);
+9 -1
View File
@@ -15,6 +15,12 @@ export interface ArtifactFileChipProps {
title?: string;
}
function artifactFileDisplay(ref: WorkspaceFileRef) {
if (!ref.projectName) return ref.displayPath;
const prefix = `${ref.projectName} / `;
return ref.displayPath.startsWith(prefix) ? ref.displayPath : `${prefix}${ref.displayPath}`;
}
export function ArtifactFileChip({
workspaceFileRef,
label,
@@ -24,7 +30,7 @@ export function ArtifactFileChip({
title,
}: ArtifactFileChipProps) {
const viewer = useFileViewer();
const display = typeof label !== "undefined" ? label : workspaceFileRef.displayPath;
const display = typeof label !== "undefined" ? label : artifactFileDisplay(workspaceFileRef);
const canOpen = !!(onOpen || viewer);
const lineSuffix = workspaceFileRef.line
? ` line ${workspaceFileRef.line}${workspaceFileRef.column ? ` column ${workspaceFileRef.column}` : ""}`
@@ -61,6 +67,8 @@ export function ArtifactFileChip({
line: workspaceFileRef.line ?? null,
column: workspaceFileRef.column ?? null,
workspace,
projectId: workspaceFileRef.projectId ?? null,
workspaceId: workspaceFileRef.projectId ? workspaceFileRef.workspaceId : null,
});
}
};
+29 -8
View File
@@ -342,6 +342,7 @@ function LoadingView({ elapsedMs }: { elapsedMs: number }) {
interface FileViewerSheetProps {
issueId: string;
companyId?: string | null;
/** When not provided, the sheet defaults to the context state. */
state?: FileViewerUrlState | null;
/** When true, renders the "Open file" prompt when no file is selected but sheet is open. */
@@ -353,6 +354,7 @@ interface FileViewerSheetProps {
export function FileViewerSheet({
issueId,
companyId,
state: stateProp,
showPromptWhenEmpty = false,
open: openProp,
@@ -383,13 +385,13 @@ export function FileViewerSheet({
}, 75);
return () => window.clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state?.path, state?.workspace]);
}, [state?.path, state?.workspace, state?.projectId, state?.workspaceId]);
const resolveQuery = useQuery({
queryKey: state
? queryKeys.issues.fileResource(issueId, state.path, state.workspace)
? queryKeys.issues.fileResource(issueId, state)
: ["issues", "file-resources", issueId, "resolve", "__closed__"],
queryFn: () => fileResourcesApi.resolve(issueId, { path: state!.path, workspace: state!.workspace }),
queryFn: () => fileResourcesApi.resolve(issueId, state!),
enabled: !!state && computedOpen,
retry: false,
staleTime: 30_000,
@@ -400,9 +402,9 @@ export function FileViewerSheet({
const contentQuery = useQuery({
queryKey: state
? queryKeys.issues.fileResourceContent(issueId, state.path, state.workspace)
? queryKeys.issues.fileResourceContent(issueId, state)
: ["issues", "file-resources", issueId, "content", "__closed__"],
queryFn: () => fileResourcesApi.content(issueId, { path: state!.path, workspace: state!.workspace }),
queryFn: () => fileResourcesApi.content(issueId, state!),
enabled: !!state && computedOpen && canPreview,
retry: false,
staleTime: 30_000,
@@ -434,9 +436,23 @@ export function FileViewerSheet({
);
const handleBrowseOpen = useCallback(
(ref: { path: string; workspace: WorkspaceFileSelector; line?: number | null; column?: number | null }) => {
(ref: {
path: string;
workspace: WorkspaceFileSelector;
line?: number | null;
column?: number | null;
projectId?: string | null;
workspaceId?: string | null;
}) => {
viewer.open(
{ path: ref.path, line: ref.line ?? null, column: ref.column ?? null, workspace: ref.workspace },
{
path: ref.path,
line: ref.line ?? null,
column: ref.column ?? null,
workspace: ref.workspace,
projectId: ref.projectId ?? null,
workspaceId: ref.workspaceId ?? null,
},
{ fromBrowse: true },
);
},
@@ -619,13 +635,15 @@ export function FileViewerSheet({
onRetry={handleRetry}
onSetAnnouncement={setAnnouncement}
onFallbackToProject={
state.workspace !== "project"
state.workspace !== "project" && !state.projectId && !state.workspaceId
? () =>
viewer.open({
path: state.path,
line: state.line,
column: state.column,
workspace: "project",
projectId: null,
workspaceId: null,
})
: null
}
@@ -633,8 +651,11 @@ export function FileViewerSheet({
) : browseMode ? (
<WorkspaceFileBrowser
issueId={issueId}
companyId={companyId}
onOpen={handleBrowseOpen}
initialQuery={viewer.query}
initialProjectId={viewer.browseProjectId}
initialWorkspaceId={viewer.browseWorkspaceId}
className="min-h-0 flex-1 p-4"
/>
) : null}
+113 -4
View File
@@ -1,9 +1,10 @@
// @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 { WorkspaceFileListItem, WorkspaceFileListResponse } from "@paperclipai/shared";
import type { Project, ProjectWorkspace, WorkspaceFileListItem, WorkspaceFileListResponse } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WorkspaceFileBrowser, describeUnavailable } from "./WorkspaceFileBrowser";
@@ -64,6 +65,72 @@ function availableResponse(items: WorkspaceFileListItem[], truncated = false): W
};
}
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",
@@ -77,7 +144,7 @@ function unavailableResponse(reason: string): WorkspaceFileListResponse {
};
}
function ok(data: WorkspaceFileListResponse) {
function ok<T>(data: T) {
return { data, isFetching: false, isError: false, error: null, refetch: vi.fn() };
}
@@ -100,11 +167,11 @@ describe("WorkspaceFileBrowser", () => {
container.remove();
});
function renderBrowser(onOpen = vi.fn()) {
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} />);
root.render(<WorkspaceFileBrowser issueId="issue-1" onOpen={onOpen} {...props} />);
});
return { root, onOpen };
}
@@ -169,6 +236,48 @@ describe("WorkspaceFileBrowser", () => {
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).toContain("Other project");
expect(container.textContent).toContain("Browsing 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 = container.querySelector('[role="option"]')!;
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",
});
});
});
describe("describeUnavailable", () => {
+231 -12
View File
@@ -10,13 +10,16 @@ import {
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, Cloud, FileCode2, FolderOpen, Loader2, Search } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { timeAgo } from "@/lib/timeAgo";
import { fileResourcesApi } from "@/api/file-resources";
import { projectsApi } from "@/api/projects";
import { ApiError } from "@/api/client";
import { queryKeys } from "@/lib/queryKeys";
import { parseWorkspaceFileRef } from "@/lib/workspace-file-parser";
import type {
Project,
WorkspaceFileListItem,
WorkspaceFileListMode,
WorkspaceFileSelector,
@@ -28,6 +31,8 @@ const WORKSPACE_OPTIONS: ReadonlyArray<{ value: WorkspaceFileSelector; label: st
{ value: "project", label: "Project" },
];
type BrowserSource = "current" | "other";
// Hard list cap. The spec called out ~50 to keep reads cheap; 100 trades a bit
// more scan for fewer "refine to narrow" dead-ends on large trees. Footer always
// discloses truncation so the cap is never silent.
@@ -118,6 +123,36 @@ function WorkspaceSelector({
);
}
function SourceSelector({
value,
onChange,
}: {
value: BrowserSource;
onChange: (next: BrowserSource) => void;
}) {
return (
<div role="group" aria-label="File source" className="inline-flex shrink-0 rounded-md border border-border p-0.5">
{[
{ value: "current" as const, label: "Current workspace" },
{ value: "other" as const, label: "Other project" },
].map((option) => (
<button
key={option.value}
type="button"
aria-pressed={value === option.value}
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",
)}
>
{option.label}
</button>
))}
</div>
);
}
interface WorkspaceFileRowProps {
item: WorkspaceFileListItem;
optionId: string;
@@ -170,19 +205,37 @@ function WorkspaceFileRow({ item, optionId, selected, showTimestamp, onOpen, onH
export interface WorkspaceFileBrowserProps {
issueId: string;
companyId?: string | null;
onOpen: (ref: {
path: string;
workspace: WorkspaceFileSelector;
line?: number | null;
column?: number | null;
projectId?: string | null;
workspaceId?: string | null;
}) => void;
/** Seed the search field (e.g. from a URL-backed deep link). */
initialQuery?: string | null;
initialProjectId?: string | null;
initialWorkspaceId?: string | null;
className?: string;
}
export function WorkspaceFileBrowser({ issueId, onOpen, initialQuery, className }: WorkspaceFileBrowserProps) {
export function WorkspaceFileBrowser({
issueId,
companyId,
onOpen,
initialQuery,
initialProjectId,
initialWorkspaceId,
className,
}: WorkspaceFileBrowserProps) {
const [source, setSource] = useState<BrowserSource>(
initialProjectId && initialWorkspaceId ? "other" : "current",
);
const [workspace, setWorkspace] = useState<WorkspaceFileSelector>("auto");
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(initialProjectId ?? null);
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | null>(initialWorkspaceId ?? null);
const [searchInput, setSearchInput] = useState(initialQuery ?? "");
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery?.trim() ?? "");
// When the workspace has no git change-tracking we silently fall back to a full
@@ -193,6 +246,49 @@ export function WorkspaceFileBrowser({ issueId, onOpen, initialQuery, className
const listboxId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const projectsQuery = useQuery({
queryKey: companyId ? queryKeys.projects.list(companyId) : ["projects", "__none__"],
queryFn: () => projectsApi.list(companyId!),
enabled: source === "other" && !!companyId,
retry: false,
staleTime: 30_000,
});
const projectRows = useMemo(
() => Array.isArray(projectsQuery.data) ? projectsQuery.data : [],
[projectsQuery.data],
);
const projectsWithWorkspaces = useMemo(
() => projectRows.filter((project: Project) => project.workspaces.length > 0),
[projectRows],
);
const selectedProject = useMemo(
() => projectsWithWorkspaces.find((project) => project.id === selectedProjectId) ?? null,
[projectsWithWorkspaces, selectedProjectId],
);
const selectedWorkspace = useMemo(
() => selectedProject?.workspaces.find((item) => item.id === selectedWorkspaceId) ?? null,
[selectedProject, selectedWorkspaceId],
);
useEffect(() => {
if (source !== "other" || !projectsQuery.data) return;
const nextProject = selectedProject ?? projectsWithWorkspaces[0] ?? null;
if (!nextProject) {
setSelectedProjectId(null);
setSelectedWorkspaceId(null);
return;
}
if (selectedProjectId !== nextProject.id) {
setSelectedProjectId(nextProject.id);
}
const nextWorkspace = nextProject.workspaces.find((item) => item.id === selectedWorkspaceId)
?? nextProject.primaryWorkspace
?? nextProject.workspaces[0]
?? null;
setSelectedWorkspaceId(nextWorkspace?.id ?? null);
}, [projectsQuery.data, projectsWithWorkspaces, selectedProject, selectedProjectId, selectedWorkspaceId, source]);
useEffect(() => {
const handle = window.setTimeout(() => setDebouncedQuery(searchInput.trim()), 150);
return () => window.clearTimeout(handle);
@@ -201,15 +297,37 @@ export function WorkspaceFileBrowser({ issueId, onOpen, initialQuery, className
// A new search or workspace should re-attempt recent-change tracking.
useEffect(() => {
setRecentUnavailable(false);
}, [workspace]);
}, [workspace, source, selectedProjectId, selectedWorkspaceId]);
const q = debouncedQuery || null;
const isSearch = q !== null;
const mode: WorkspaceFileListMode = isSearch ? "all" : recentUnavailable ? "all" : "changed";
const targetProjectId = source === "other" ? selectedProjectId : null;
const targetWorkspaceId = source === "other" ? selectedWorkspaceId : null;
const effectiveWorkspace: WorkspaceFileSelector = source === "other" ? "project" : workspace;
const canListFiles = source === "current" || Boolean(targetProjectId && targetWorkspaceId);
const targetRef = targetProjectId && targetWorkspaceId
? { projectId: targetProjectId, workspaceId: targetWorkspaceId }
: {};
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: effectiveWorkspace,
projectId: targetProjectId,
workspaceId: targetWorkspaceId,
mode,
q,
limit: LIST_LIMIT,
}),
queryFn: () => fileResourcesApi.list(issueId, {
workspace: effectiveWorkspace,
projectId: targetProjectId,
workspaceId: targetWorkspaceId,
mode,
q,
limit: LIST_LIMIT,
}),
enabled: canListFiles,
retry: false,
staleTime: 15_000,
});
@@ -228,7 +346,7 @@ export function WorkspaceFileBrowser({ issueId, onOpen, initialQuery, className
// Keep the highlighted option valid as results change.
useEffect(() => {
setHighlightedIndex(items.length > 0 ? 0 : -1);
}, [items, q, workspace]);
}, [items, q, workspace, source, selectedProjectId, selectedWorkspaceId]);
const announcement = useMemo(() => {
if (listQuery.isFetching) return "Loading workspace files…";
@@ -242,8 +360,9 @@ export function WorkspaceFileBrowser({ issueId, onOpen, initialQuery, className
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 });
const target = { workspace: effectiveWorkspace, ...targetRef };
if (parsed) onOpen({ path: parsed.path, ...target, line: parsed.line, column: parsed.column });
else onOpen({ path: value, ...target });
}
function handleSearchKeyDown(event: KeyboardEvent<HTMLInputElement>) {
@@ -256,15 +375,54 @@ export function WorkspaceFileBrowser({ issueId, onOpen, initialQuery, className
} else if (event.key === "Enter") {
event.preventDefault();
const item = highlightedIndex >= 0 ? items[highlightedIndex] : undefined;
if (item) onOpen({ path: item.relativePath, workspace });
if (item) {
const itemTarget = item.projectId
? { projectId: item.projectId, workspaceId: item.workspaceId }
: targetRef;
onOpen({
path: item.relativePath,
workspace: effectiveWorkspace,
...itemTarget,
});
}
else openTypedPath();
}
}
function handleProjectChange(projectId: string) {
const project = projectsWithWorkspaces.find((item) => item.id === projectId) ?? null;
setSelectedProjectId(project?.id ?? null);
setSelectedWorkspaceId(project?.primaryWorkspace?.id ?? project?.workspaces[0]?.id ?? null);
}
const activeOptionId = highlightedIndex >= 0 ? `${listboxId}-opt-${highlightedIndex}` : undefined;
let body: ReactNode;
if (listQuery.isFetching && !data) {
if (source === "other" && !companyId) {
body = (
<StateMessage
icon={<FolderOpen aria-hidden="true" className="h-5 w-5 text-muted-foreground" />}
title="No company selected"
body="Choose a company before browsing another project workspace."
/>
);
} else if (source === "other" && projectsQuery.isFetching && projectsWithWorkspaces.length === 0) {
body = (
<StateMessage
icon={<Loader2 aria-hidden="true" className="h-5 w-5 animate-spin text-muted-foreground" />}
title="Loading project workspaces"
body="Registered workspaces will appear here."
/>
);
} else if (source === "other" && !canListFiles) {
body = (
<StateMessage
icon={<FolderOpen aria-hidden="true" className="h-5 w-5 text-muted-foreground" />}
title="No project workspaces"
body="No same-company project has a registered workspace to browse."
/>
);
} else if (listQuery.isFetching && !data) {
body = (
<div className="space-y-1.5 py-2" aria-busy="true">
{Array.from({ length: 6 }).map((_, index) => (
@@ -309,7 +467,16 @@ export function WorkspaceFileBrowser({ issueId, onOpen, initialQuery, className
optionId={`${listboxId}-opt-${index}`}
selected={index === highlightedIndex}
showTimestamp={!isSearch}
onOpen={() => onOpen({ path: item.relativePath, workspace })}
onOpen={() => {
const itemTarget = item.projectId
? { projectId: item.projectId, workspaceId: item.workspaceId }
: targetRef;
onOpen({
path: item.relativePath,
workspace: effectiveWorkspace,
...itemTarget,
});
}}
onHover={() => setHighlightedIndex(index)}
/>
))}
@@ -344,10 +511,62 @@ export function WorkspaceFileBrowser({ issueId, onOpen, initialQuery, className
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">Workspace</span>
<WorkspaceSelector value={workspace} onChange={setWorkspace} />
<span className="text-xs text-muted-foreground">Source</span>
<SourceSelector value={source} onChange={setSource} />
{source === "current" ? (
<>
<span className="text-xs text-muted-foreground">Workspace</span>
<WorkspaceSelector value={workspace} onChange={setWorkspace} />
</>
) : null}
</div>
{source === "other" ? (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">Project</span>
<Select
value={selectedProjectId ?? undefined}
onValueChange={handleProjectChange}
disabled={!companyId || projectsQuery.isFetching || projectsWithWorkspaces.length === 0}
>
<SelectTrigger size="sm" className="h-8 max-w-[260px] text-xs">
<SelectValue placeholder={projectsQuery.isFetching ? "Loading projects" : "Choose project"} />
</SelectTrigger>
<SelectContent>
{projectsWithWorkspaces.map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">Workspace</span>
<Select
value={selectedWorkspaceId ?? undefined}
onValueChange={setSelectedWorkspaceId}
disabled={!selectedProject || selectedProject.workspaces.length === 0}
>
<SelectTrigger size="sm" className="h-8 max-w-[260px] text-xs">
<SelectValue placeholder="Choose workspace" />
</SelectTrigger>
<SelectContent>
{(selectedProject?.workspaces ?? []).map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
{source === "other" && selectedProject && selectedWorkspace ? (
<div className="truncate text-[11px] text-muted-foreground" title={`${selectedProject.name} / ${selectedWorkspace.name}`}>
Browsing {selectedProject.name} / {selectedWorkspace.name}
</div>
) : null}
<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"}
+2
View File
@@ -47,6 +47,8 @@ export function WorkspaceFileLink({
line: workspaceFileRef.line ?? null,
column: workspaceFileRef.column ?? null,
workspace: "auto",
projectId: workspaceFileRef.projectId ?? null,
workspaceId: workspaceFileRef.workspaceId ?? null,
});
const href = canOpen
? `${location.pathname}${deepLinkSearch}${location.hash}`
+37 -3
View File
@@ -27,6 +27,8 @@ describe("readFileViewerStateFromSearch", () => {
line: 42,
column: 3,
workspace: "project",
projectId: null,
workspaceId: null,
});
});
@@ -51,7 +53,14 @@ describe("writeFileViewerStateToSearch", () => {
it("sets all params when opening", () => {
const next = writeFileViewerStateToSearch(
"?existing=1",
{ path: "ui/src/a.ts", line: 42, column: 3, workspace: "project" },
{
path: "ui/src/a.ts",
line: 42,
column: 3,
workspace: "project",
projectId: null,
workspaceId: null,
},
);
const params = new URLSearchParams(next);
expect(params.get("file")).toBe("ui/src/a.ts");
@@ -64,14 +73,37 @@ describe("writeFileViewerStateToSearch", () => {
it("omits workspace when auto", () => {
const next = writeFileViewerStateToSearch(
"",
{ path: "a.ts", line: null, column: null, workspace: "auto" },
{ path: "a.ts", line: null, column: null, workspace: "auto", projectId: null, workspaceId: null },
);
expect(next.includes("workspace")).toBe(false);
});
it("round-trips explicit target project workspace params", () => {
const next = writeFileViewerStateToSearch(
"?existing=1",
{
path: "content-os/cases/readme.md",
line: 7,
column: null,
workspace: "auto",
projectId: "17acae7d-9d0c-46bf-9c82-be9694ac3461",
workspaceId: "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2",
},
);
const state = readFileViewerStateFromSearch(next);
expect(state).toEqual({
path: "content-os/cases/readme.md",
line: 7,
column: null,
workspace: "auto",
projectId: "17acae7d-9d0c-46bf-9c82-be9694ac3461",
workspaceId: "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2",
});
});
it("clears viewer params when closing", () => {
const next = writeFileViewerStateToSearch(
"?file=a.ts&line=1&column=2&workspace=project&keep=yes",
"?file=a.ts&line=1&column=2&workspace=project&projectId=project-1&workspaceId=workspace-1&keep=yes",
null,
);
const params = new URLSearchParams(next);
@@ -79,6 +111,8 @@ describe("writeFileViewerStateToSearch", () => {
expect(params.get("line")).toBeNull();
expect(params.get("column")).toBeNull();
expect(params.get("workspace")).toBeNull();
expect(params.get("projectId")).toBeNull();
expect(params.get("workspaceId")).toBeNull();
expect(params.get("keep")).toBe("yes");
});
+31 -2
View File
@@ -8,6 +8,8 @@ export interface FileViewerUrlState {
line: number | null;
column: number | null;
workspace: WorkspaceFileSelector;
projectId: string | null;
workspaceId: string | null;
}
export interface FileViewerContextValue {
@@ -18,8 +20,12 @@ export interface FileViewerContextValue {
browse: boolean;
/** The active browse search query (URL `q`), or null. */
query: string | null;
browseProjectId: string | null;
browseWorkspaceId: string | null;
open(
ref: Pick<ParsedWorkspaceFileRef, "path" | "line" | "column"> & { workspace?: WorkspaceFileSelector },
ref: Pick<ParsedWorkspaceFileRef, "path" | "line" | "column" | "projectId" | "workspaceId"> & {
workspace?: WorkspaceFileSelector;
},
opts?: { fromBrowse?: boolean },
): void;
/** Open (or stay in) browse mode, optionally seeding the search query. */
@@ -43,6 +49,9 @@ export function readFileViewerStateFromSearch(search: string): FileViewerUrlStat
const lineRaw = params.get("line");
const columnRaw = params.get("column");
const workspaceRaw = params.get("workspace");
const projectIdRaw = params.get("projectId");
const workspaceIdRaw = params.get("workspaceId");
const hasExplicitTarget = Boolean(projectIdRaw && workspaceIdRaw);
const line = lineRaw ? Number.parseInt(lineRaw, 10) : NaN;
const column = columnRaw ? Number.parseInt(columnRaw, 10) : NaN;
const workspace = (workspaceRaw === "execution" || workspaceRaw === "project")
@@ -53,6 +62,8 @@ export function readFileViewerStateFromSearch(search: string): FileViewerUrlStat
line: Number.isFinite(line) && line > 0 ? line : null,
column: Number.isFinite(column) && column > 0 ? column : null,
workspace,
projectId: hasExplicitTarget ? projectIdRaw : null,
workspaceId: hasExplicitTarget ? workspaceIdRaw : null,
};
}
@@ -66,6 +77,8 @@ export function writeFileViewerStateToSearch(current: string, next: FileViewerUr
params.delete("line");
params.delete("column");
params.delete("workspace");
params.delete("projectId");
params.delete("workspaceId");
} else {
params.set("file", next.path);
if (next.line !== null) params.set("line", String(next.line));
@@ -74,6 +87,10 @@ export function writeFileViewerStateToSearch(current: string, next: FileViewerUr
else params.delete("column");
if (next.workspace && next.workspace !== "auto") params.set("workspace", next.workspace);
else params.delete("workspace");
if (next.projectId) params.set("projectId", next.projectId);
else params.delete("projectId");
if (next.workspaceId) params.set("workspaceId", next.workspaceId);
else params.delete("workspaceId");
}
const str = params.toString();
return str ? `?${str}` : "";
@@ -81,13 +98,21 @@ export function writeFileViewerStateToSearch(current: string, next: FileViewerUr
export interface FileViewerBrowseState {
q: string | null;
projectId: string | null;
workspaceId: 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 };
const projectId = params.get("projectId");
const workspaceId = params.get("workspaceId");
return {
q: q && q.length > 0 ? q : null,
projectId: projectId || null,
workspaceId: workspaceId || null,
};
}
interface FileViewerProviderProps {
@@ -124,6 +149,8 @@ function EnabledFileViewerProvider({ issueId, children }: Omit<FileViewerProvide
line: ref.line ?? null,
column: ref.column ?? null,
workspace: ref.workspace ?? "auto",
projectId: ref.projectId ?? null,
workspaceId: ref.workspaceId ?? null,
});
if (opts?.fromBrowse) {
const params = new URLSearchParams(nextSearch);
@@ -174,6 +201,8 @@ function EnabledFileViewerProvider({ issueId, children }: Omit<FileViewerProvide
state,
browse: browseState !== null,
query: browseState?.q ?? null,
browseProjectId: browseState?.projectId ?? null,
browseWorkspaceId: browseState?.workspaceId ?? null,
open,
openBrowse,
backToFiles,
+21 -5
View File
@@ -87,12 +87,28 @@ export const queryKeys = {
liveRuns: (issueId: string) => ["issues", "live-runs", issueId] as const,
activeRun: (issueId: string) => ["issues", "active-run", issueId] as const,
workProducts: (issueId: string) => ["issues", "work-products", issueId] as const,
fileResources: (issueId: string, options: { workspace?: string; mode?: string; q?: string | null; limit?: number } = {}) =>
fileResources: (
issueId: string,
options: {
workspace?: string;
projectId?: string | null;
workspaceId?: string | null;
mode?: string;
q?: string | null;
limit?: number;
} = {},
) =>
["issues", "file-resources", issueId, "list", options] as const,
fileResource: (issueId: string, path: string, workspace: string) =>
["issues", "file-resources", issueId, "resolve", path, workspace] as const,
fileResourceContent: (issueId: string, path: string, workspace: string) =>
["issues", "file-resources", issueId, "content", path, workspace] as const,
fileResource: (
issueId: string,
query: { path: string; workspace?: string; projectId?: string | null; workspaceId?: string | null },
) =>
["issues", "file-resources", issueId, "resolve", query] as const,
fileResourceContent: (
issueId: string,
query: { path: string; workspace?: string; projectId?: string | null; workspaceId?: string | null },
) =>
["issues", "file-resources", issueId, "content", query] as const,
},
routines: {
list: (companyId: string, filters?: { projectId?: string | null }) =>
@@ -68,6 +68,27 @@ describe("remarkWorkspaceFileRefs", () => {
expect(parsed?.column).toBe(2);
});
it("round-trips explicit project workspace identity", () => {
const href = buildWorkspaceFileHref({
path: "content-os/cases/readme.md",
line: 5,
column: null,
raw: "content-os/cases/readme.md:5",
projectId: "17acae7d-9d0c-46bf-9c82-be9694ac3461",
workspaceId: "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2",
projectName: "Paperclip Content",
});
const parsed = parseWorkspaceFileHref(href);
expect(parsed).toMatchObject({
path: "content-os/cases/readme.md",
line: 5,
column: null,
projectId: "17acae7d-9d0c-46bf-9c82-be9694ac3461",
workspaceId: "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2",
projectName: "Paperclip Content",
});
});
it("converts linked inline-code file paths into workspace-file links", () => {
const tree: MarkdownNode = {
type: "paragraph",
+10
View File
@@ -11,9 +11,12 @@ type MarkdownNode = {
export function buildWorkspaceFileHref(ref: ParsedWorkspaceFileRef): string {
const params = new URLSearchParams();
if (ref.projectId) params.set("projectId", ref.projectId);
if (ref.workspaceId) params.set("workspaceId", ref.workspaceId);
params.set("path", ref.path);
if (ref.line !== null) params.set("line", String(ref.line));
if (ref.column !== null) params.set("column", String(ref.column));
if (ref.projectName) params.set("projectName", ref.projectName);
return `${WORKSPACE_FILE_HREF_SCHEME}?${params.toString()}`;
}
@@ -25,6 +28,10 @@ export function parseWorkspaceFileHref(href: string | null | undefined): ParsedW
const params = new URLSearchParams(withoutLeadingQuestion);
const path = params.get("path");
if (!path) return null;
const projectIdRaw = params.get("projectId");
const workspaceIdRaw = params.get("workspaceId");
const hasExplicitTarget = Boolean(projectIdRaw && workspaceIdRaw);
const projectName = params.get("projectName");
const lineRaw = params.get("line");
const columnRaw = params.get("column");
const line = lineRaw ? Number.parseInt(lineRaw, 10) : NaN;
@@ -33,6 +40,9 @@ export function parseWorkspaceFileHref(href: string | null | undefined): ParsedW
path,
line: Number.isFinite(line) && line > 0 ? line : null,
column: Number.isFinite(column) && column > 0 ? column : null,
projectId: hasExplicitTarget ? projectIdRaw : null,
workspaceId: hasExplicitTarget ? workspaceIdRaw : null,
projectName: projectName || null,
raw: path,
};
}
+9 -3
View File
@@ -2,6 +2,9 @@ export interface ParsedWorkspaceFileRef {
path: string;
line: number | null;
column: number | null;
projectId?: string | null;
projectName?: string | null;
workspaceId?: string | null;
/** The original matched text (useful for rendering) */
raw: string;
}
@@ -77,7 +80,10 @@ export function parseWorkspaceFileRef(input: string): ParsedWorkspaceFileRef | n
}
export function formatWorkspaceFileRefDisplay(ref: ParsedWorkspaceFileRef): string {
if (ref.line && ref.column) return `${ref.path}:${ref.line}:${ref.column}`;
if (ref.line) return `${ref.path}:${ref.line}`;
return ref.path;
const path = ref.line && ref.column
? `${ref.path}:${ref.line}:${ref.column}`
: ref.line
? `${ref.path}:${ref.line}`
: ref.path;
return ref.projectName ? `${ref.projectName} / ${path}` : path;
}
+4
View File
@@ -4405,6 +4405,7 @@ export function IssueDetail() {
{fileViewerEnabled ? (
<IssueFileViewer
issueId={issue.id}
companyId={issue.companyId}
promptOpen={fileViewerPromptOpen}
onPromptOpenChange={setFileViewerPromptOpen}
/>
@@ -4417,10 +4418,12 @@ export function IssueDetail() {
function IssueFileViewer({
issueId,
companyId,
promptOpen,
onPromptOpenChange,
}: {
issueId: string;
companyId: string;
promptOpen: boolean;
onPromptOpenChange: (next: boolean) => void;
}) {
@@ -4430,6 +4433,7 @@ function IssueFileViewer({
return (
<FileViewerSheet
issueId={issueId}
companyId={companyId}
open={open}
showPromptWhenEmpty={showPromptWhenEmpty}
onOpenChange={(next) => {
+3 -3
View File
@@ -157,7 +157,7 @@ function FileViewerShell({
state={
showPromptWhenEmpty
? null
: { path, line, column, workspace }
: { path, line, column, workspace, projectId: null, workspaceId: null }
}
showPromptWhenEmpty={showPromptWhenEmpty}
open
@@ -176,12 +176,12 @@ function seedContent(
content: WorkspaceFileContent | null,
) {
queryClient.setQueryData(
queryKeys.issues.fileResource(ISSUE_ID, path, workspace),
queryKeys.issues.fileResource(ISSUE_ID, { path, workspace }),
resource,
);
if (content) {
queryClient.setQueryData(
queryKeys.issues.fileResourceContent(ISSUE_ID, path, workspace),
queryKeys.issues.fileResourceContent(ISSUE_ID, { path, workspace }),
content,
);
}
@@ -135,11 +135,17 @@ function ViewFromBrowse() {
const queryClient = useQueryClient();
queryClient.setQueryData(listKey(ISSUE_ID), recentList);
queryClient.setQueryData(
queryKeys.issues.fileResource(ISSUE_ID, "ui/src/components/FileViewerSheet.tsx", "auto"),
queryKeys.issues.fileResource(ISSUE_ID, {
path: "ui/src/components/FileViewerSheet.tsx",
workspace: "auto",
}),
viewedResource,
);
queryClient.setQueryData(
queryKeys.issues.fileResourceContent(ISSUE_ID, "ui/src/components/FileViewerSheet.tsx", "auto"),
queryKeys.issues.fileResourceContent(ISSUE_ID, {
path: "ui/src/components/FileViewerSheet.tsx",
workspace: "auto",
}),
viewedContent,
);
return (