Build issue file viewer sheet UI (PAP-1962)

Adds the Phase 3 UI for the workspace file viewer:

- FileViewerProvider/FileViewerSheet pair, URL-addressable via
  ?file=/?line=/?column=/?workspace= so browser back closes or
  walks back through sheet history.
- Remark plugin + parser that only auto-link path refs inside
  inline code spans (requires a slash or a line anchor to avoid
  prose matches like README.md).
- Shared WorkspaceFileLink primitive used by markdown output; same
  primitive will be reusable for run output and work-product chips.
- Issue toolbar "Open file" action, Command Palette entry
  "Open file in this issue...", and the `g f` keyboard route.
- Designed state views for not found / outside workspace / denied /
  too large / binary / remote-only / workspace-archived / loading
  progressive states, with polite live-region announcements and a
  keyboard-scrollable code viewport.

Tests cover the parser, the remark plugin, URL state reading/writing,
the keyboard shortcut, and markdown-body workspace link rendering.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-09 21:35:48 +00:00
1 parent e11a5d1b2d
commit 4bad0cf17f
17 files changed
+1484 -2

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
import type {
ResolvedWorkspaceResource,
WorkspaceFileContent,
WorkspaceFileSelector,
} from "@paperclipai/shared";
import { api } from "./client";
export interface FileResourceQuery {
path: string;
workspace?: WorkspaceFileSelector;
}
function buildQuery(query: FileResourceQuery): string {
const params = new URLSearchParams();
params.set("path", query.path);
if (query.workspace && query.workspace !== "auto") {
params.set("workspace", query.workspace);
}
return params.toString();
}
export const fileResourcesApi = {
resolve(issueId: string, query: FileResourceQuery): Promise<ResolvedWorkspaceResource> {
return api.get<ResolvedWorkspaceResource>(
`/issues/${encodeURIComponent(issueId)}/file-resources/resolve?${buildQuery(query)}`,
);
},
content(issueId: string, query: FileResourceQuery): Promise<WorkspaceFileContent> {
return api.get<WorkspaceFileContent>(
`/issues/${encodeURIComponent(issueId)}/file-resources/content?${buildQuery(query)}`,
);
},
};
+22 -1
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useMemo } from "react";
import { useNavigate } from "@/lib/router";
import { useLocation, useNavigate } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { useCompany } from "../context/CompanyContext";
import { useDialogActions } from "../context/DialogContext";
@@ -27,6 +27,7 @@ import {
DollarSign,
History,
SquarePen,
FileCode2,
Plus,
Search,
} from "lucide-react";
@@ -40,14 +41,22 @@ export function buildFullSearchPath(query: string) {
return trimmed.length === 0 ? "/search" : `/search?q=${encodeURIComponent(trimmed)}`;
}
const ISSUE_DETAIL_PATH_RE = /\/issues\/[^/?#]+(?:$|\?|#|\/)/;
function isOnIssueDetail(pathname: string): boolean {
return ISSUE_DETAIL_PATH_RE.test(pathname);
}
export function CommandPalette() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const navigate = useNavigate();
const location = useLocation();
const { selectedCompanyId } = useCompany();
const { openNewIssue, openNewAgent } = useDialogActions();
const { isMobile, setSidebarOpen } = useSidebar();
const searchQuery = query.trim();
const onIssueDetail = isOnIssueDetail(location.pathname);
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
@@ -177,6 +186,18 @@ export function CommandPalette() {
Create new task
<span className="ml-auto text-xs text-muted-foreground">C</span>
</CommandItem>
{onIssueDetail && (
<CommandItem
onSelect={() => {
setOpen(false);
window.dispatchEvent(new CustomEvent("paperclip:open-file-viewer"));
}}
>
<FileCode2 className="mr-2 h-4 w-4" />
Open file in this issue...
<span className="ml-auto text-xs text-muted-foreground">g f</span>
</CommandItem>
)}
<CommandItem
onSelect={() => {
setOpen(false);
+783
View File
@@ -0,0 +1,783 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowLeft,
Ban,
Check,
Copy,
FileCode2,
FileSearch,
FolderOpen,
Loader2,
Lock,
RefreshCcw,
} from "lucide-react";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { fileResourcesApi } from "@/api/file-resources";
import { ApiError } from "@/api/client";
import { queryKeys } from "@/lib/queryKeys";
import {
useRequiredFileViewer,
type FileViewerUrlState,
} from "@/context/FileViewerContext";
import type {
ResolvedWorkspaceResource,
WorkspaceFileContent,
WorkspaceFileSelector,
} from "@paperclipai/shared";
const FILE_VIEWER_LABELLED_BY_ID = "paperclip-file-viewer-title";
const FILE_VIEWER_DESCRIBED_BY_ID = "paperclip-file-viewer-description";
interface FileViewerErrorShape {
status: number;
code: string;
message: string;
}
function normalizeError(error: unknown): FileViewerErrorShape {
if (error instanceof ApiError) {
const body = (error.body ?? null) as { error?: string; code?: string } | null;
const code = typeof body?.code === "string" ? body.code : "";
return {
status: error.status,
code,
message: typeof body?.error === "string" ? body.error : error.message,
};
}
if (error instanceof Error) {
return { status: 0, code: "", message: error.message };
}
return { status: 0, code: "", message: "Something went wrong." };
}
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 decodeBase64ToBytes(data: string): Uint8Array {
const raw = globalThis.atob ? globalThis.atob(data) : "";
const bytes = new Uint8Array(raw.length);
for (let index = 0; index < raw.length; index += 1) {
bytes[index] = raw.charCodeAt(index);
}
return bytes;
}
function bytesToDataUrl(bytes: Uint8Array, contentType: string | null | undefined): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
const encoded = globalThis.btoa ? globalThis.btoa(binary) : "";
return `data:${contentType ?? "application/octet-stream"};base64,${encoded}`;
}
function splitContentIntoLines(data: string): string[] {
if (data === "") return [""];
const normalized = data.replace(/\r\n?/g, "\n");
const lines = normalized.split("\n");
if (lines.length > 0 && lines[lines.length - 1] === "") {
lines.pop();
}
return lines.length > 0 ? lines : [""];
}
function basename(path: string): string {
const idx = path.lastIndexOf("/");
return idx < 0 ? path : path.slice(idx + 1);
}
function middleTruncatePath(path: string, maxLen = 80): string {
if (path.length <= maxLen) return path;
const head = path.slice(0, Math.floor(maxLen / 2) - 1);
const tail = path.slice(path.length - (maxLen - head.length - 1));
return `${head}…${tail}`;
}
function describeDenial(code: string, fallback: string): { title: string; body: string; icon: ReactNode } {
const lower = code.toLowerCase();
if (lower.includes("policy") || lower.includes("denied") || lower.includes("sensitive")) {
return {
icon: <Lock aria-hidden="true" className="h-6 w-6 text-amber-500" />,
title: "Viewer blocked for this file",
body: "This file is not available through the viewer because it may contain sensitive data.",
};
}
if (lower.includes("outside") || lower.includes("traversal")) {
return {
icon: <Ban aria-hidden="true" className="h-6 w-6 text-red-500" />,
title: "Path is outside the workspace",
body: "The viewer can only open files that live under the issue's workspace.",
};
}
if (lower.includes("archive") || lower.includes("cleaned")) {
return {
icon: <FolderOpen aria-hidden="true" className="h-6 w-6 text-muted-foreground" />,
title: "Workspace is no longer available",
body: "The isolated worktree for this issue has been cleaned up, so files cannot be previewed.",
};
}
if (lower.includes("remote")) {
return {
icon: <AlertTriangle aria-hidden="true" className="h-6 w-6 text-amber-500" />,
title: "Remote workspace preview not supported",
body: "This workspace is hosted remotely and is not available for inline preview yet.",
};
}
if (lower.includes("too_large") || lower.includes("size")) {
return {
icon: <AlertTriangle aria-hidden="true" className="h-6 w-6 text-amber-500" />,
title: "File is too large to preview",
body: fallback || "This file exceeds the supported preview size.",
};
}
if (lower.includes("binary") || lower.includes("unsupported")) {
return {
icon: <AlertTriangle aria-hidden="true" className="h-6 w-6 text-amber-500" />,
title: "Preview not supported for this file type",
body: "This file does not have a text or image preview available.",
};
}
return {
icon: <Ban aria-hidden="true" className="h-6 w-6 text-red-500" />,
title: "Can't preview this file",
body: fallback || "The viewer was unable to load this file.",
};
}
function FileViewerStateView({
icon,
title,
body,
secondary,
actions,
}: {
icon: ReactNode;
title: string;
body?: string;
secondary?: ReactNode;
actions?: ReactNode;
}) {
return (
<div className="flex flex-col items-start gap-3 p-6 text-sm">
<div className="flex items-start gap-3">
{icon}
<div className="flex-1 space-y-1">
<p className="font-medium text-foreground">{title}</p>
{body ? <p className="text-muted-foreground">{body}</p> : null}
{secondary}
</div>
</div>
{actions ? <div className="flex flex-wrap gap-2">{actions}</div> : null}
</div>
);
}
interface OpenFilePromptProps {
onSubmit: (path: string, workspace: WorkspaceFileSelector) => 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;
onSubmit(trimmed, workspace);
};
return (
<form className="space-y-4 p-6" onSubmit={handleSubmit}>
<div className="space-y-1">
<label htmlFor="paperclip-file-viewer-input" className="text-sm font-medium text-foreground">
Open file in this issue
</label>
<p className="text-xs text-muted-foreground">
Type a workspace-relative path, for example <code className="font-mono text-[0.78em]">ui/src/pages/IssueDetail.tsx:42</code>.
</p>
</div>
<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="path/to/file.ext or path/to/file.ext: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 FileContentViewerProps {
content: WorkspaceFileContent;
highlightedLine: number | null;
onLoaded?: (summary: string) => void;
}
function FileContentViewer({ content, highlightedLine, onLoaded }: FileContentViewerProps) {
const { resource } = content;
const lines = useMemo(() => {
if (resource.previewKind === "text") {
return splitContentIntoLines(content.content.data);
}
return null;
}, [content.content.data, resource.previewKind]);
const codeScrollRef = useRef<HTMLDivElement>(null);
const highlightedLineRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!lines) return;
onLoaded?.(`File loaded, ${lines.length} ${lines.length === 1 ? "line" : "lines"}.`);
}, [lines, onLoaded]);
useEffect(() => {
if (!highlightedLine || !highlightedLineRef.current) return;
highlightedLineRef.current.scrollIntoView({ block: "center", behavior: "auto" });
}, [highlightedLine]);
if (resource.previewKind === "image") {
const encoded = content.content.encoding === "base64" ? content.content.data : content.content.data;
const dataUrl = content.content.encoding === "base64"
? bytesToDataUrl(decodeBase64ToBytes(encoded), resource.contentType)
: null;
if (!dataUrl) {
return (
<FileViewerStateView
icon={<AlertTriangle aria-hidden="true" className="h-6 w-6 text-amber-500" />}
title="Image preview unavailable"
/>
);
}
return (
<div className="flex items-center justify-center overflow-auto bg-muted/40 p-4">
<img
src={dataUrl}
alt={resource.title}
className="max-h-full max-w-full rounded border border-border object-contain"
/>
</div>
);
}
if (resource.previewKind === "unsupported" || !lines) {
return (
<FileViewerStateView
icon={<AlertTriangle aria-hidden="true" className="h-6 w-6 text-amber-500" />}
title="Preview not supported for this file type"
body={resource.contentType ? `Content type: ${resource.contentType}` : undefined}
/>
);
}
const gutterWidth = `${Math.max(2, String(lines.length).length)}ch`;
return (
<div
ref={codeScrollRef}
role="region"
aria-label={`${resource.title} source`}
tabIndex={0}
className="paperclip-file-viewer-code flex-1 overflow-auto bg-[var(--paperclip-code-bg,theme(colors.muted.DEFAULT))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
<pre className="m-0 font-mono text-[12.5px] leading-5">
{lines.map((lineText, index) => {
const lineNumber = index + 1;
const isHighlighted = lineNumber === highlightedLine;
return (
<div
key={lineNumber}
ref={isHighlighted ? highlightedLineRef : undefined}
data-line-number={lineNumber}
className={cn(
"relative flex whitespace-pre",
isHighlighted && "bg-[var(--paperclip-code-highlight-bg,rgba(250,204,21,0.12))]",
)}
>
{isHighlighted ? (
<span
aria-hidden="true"
className="absolute left-0 top-0 bottom-0 w-0.5 bg-[var(--paperclip-code-highlight-border,rgb(234,179,8))]"
/>
) : null}
<span
aria-hidden="true"
className={cn(
"shrink-0 select-none px-3 text-right text-[var(--paperclip-code-gutter-fg,theme(colors.muted.foreground))] opacity-70",
isHighlighted && "opacity-100",
)}
style={{ width: gutterWidth }}
>
{lineNumber}
</span>
<code className="flex-1 pr-4">{lineText.length === 0 ? "​" : lineText}</code>
</div>
);
})}
</pre>
</div>
);
}
function LoadingView({ elapsedMs }: { elapsedMs: number }) {
if (elapsedMs < 100) {
return <div className="flex-1" aria-hidden="true" />;
}
if (elapsedMs < 400) {
return (
<div className="flex-1 space-y-2 p-6" aria-busy="true" aria-live="polite">
<span className="sr-only">Loading file preview</span>
{Array.from({ length: 10 }).map((_, index) => (
<div key={index} className="h-3 rounded bg-muted animate-pulse" style={{ width: `${90 - index * 6}%` }} />
))}
</div>
);
}
return (
<div
className="flex flex-1 flex-col items-start justify-start gap-3 p-6 text-sm"
role="status"
aria-live="polite"
>
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 aria-hidden="true" className="h-4 w-4 animate-spin" />
Loading file preview...
</div>
</div>
);
}
interface FileViewerSheetProps {
issueId: string;
/** 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. */
showPromptWhenEmpty?: boolean;
/** Whether the sheet is open. Defaults to `state !== null`. */
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function FileViewerSheet({
issueId,
state: stateProp,
showPromptWhenEmpty = false,
open: openProp,
onOpenChange,
}: FileViewerSheetProps) {
const viewer = useRequiredFileViewer();
const state = typeof stateProp !== "undefined" ? stateProp : viewer.state;
const computedOpen = typeof openProp === "boolean" ? openProp : state !== null || showPromptWhenEmpty;
const [loadStart, setLoadStart] = useState<number>(() => Date.now());
const [elapsedMs, setElapsedMs] = useState(0);
const [copiedField, setCopiedField] = useState<"path" | "link" | null>(null);
const [announcement, setAnnouncement] = useState<string>("");
useEffect(() => {
if (!state) {
setElapsedMs(0);
return;
}
setLoadStart(Date.now());
setElapsedMs(0);
const interval = window.setInterval(() => {
setElapsedMs(Date.now() - loadStart);
}, 75);
return () => window.clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state?.path, state?.workspace]);
const resolveQuery = useQuery({
queryKey: state
? queryKeys.issues.fileResource(issueId, state.path, state.workspace)
: ["issues", "file-resources", issueId, "resolve", "__closed__"],
queryFn: () => fileResourcesApi.resolve(issueId, { path: state!.path, workspace: state!.workspace }),
enabled: !!state && computedOpen,
retry: false,
staleTime: 30_000,
});
const resolvedResource: ResolvedWorkspaceResource | undefined = resolveQuery.data;
const canPreview = resolvedResource?.capabilities.preview ?? false;
const contentQuery = useQuery({
queryKey: state
? queryKeys.issues.fileResourceContent(issueId, state.path, state.workspace)
: ["issues", "file-resources", issueId, "content", "__closed__"],
queryFn: () => fileResourcesApi.content(issueId, { path: state!.path, workspace: state!.workspace }),
enabled: !!state && computedOpen && canPreview,
retry: false,
staleTime: 30_000,
});
useEffect(() => {
if (resolveQuery.isError) {
const normalized = normalizeError(resolveQuery.error);
setAnnouncement(normalized.message || "Unable to load file.");
}
}, [resolveQuery.isError, resolveQuery.error]);
useEffect(() => {
if (contentQuery.isError) {
const normalized = normalizeError(contentQuery.error);
setAnnouncement(normalized.message || "Unable to load file content.");
}
}, [contentQuery.isError, contentQuery.error]);
const handleOpenChange = useCallback(
(next: boolean) => {
if (onOpenChange) {
onOpenChange(next);
return;
}
if (!next) viewer.close();
},
[onOpenChange, viewer],
);
const handlePromptSubmit = useCallback(
(path: string, workspace: WorkspaceFileSelector) => {
viewer.open({ path, line: null, column: null, workspace });
},
[viewer],
);
const copyToClipboard = useCallback(async (value: string, field: "path" | "link") => {
try {
await navigator.clipboard.writeText(value);
setCopiedField(field);
window.setTimeout(() => setCopiedField((current) => (current === field ? null : current)), 1500);
} catch {
setAnnouncement("Unable to copy to clipboard.");
}
}, []);
const handleCopyPath = useCallback(() => {
if (state) void copyToClipboard(state.path, "path");
}, [copyToClipboard, state]);
const handleCopyLink = useCallback(() => {
if (typeof window === "undefined") return;
void copyToClipboard(window.location.href, "link");
}, [copyToClipboard]);
const handleRetry = useCallback(() => {
void resolveQuery.refetch();
if (canPreview) void contentQuery.refetch();
}, [canPreview, contentQuery, resolveQuery]);
const title = state ? basename(state.path) : "Open file";
const description = state
? middleTruncatePath(state.path)
: "Enter a workspace-relative path to preview.";
return (
<Sheet open={computedOpen} onOpenChange={handleOpenChange}>
<SheetContent
side="right"
className="flex h-full w-full flex-col gap-0 p-0 sm:max-w-[min(900px,90vw)]"
aria-labelledby={FILE_VIEWER_LABELLED_BY_ID}
aria-describedby={FILE_VIEWER_DESCRIBED_BY_ID}
showCloseButton={false}
>
<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" />
<div className="min-w-0 flex-1">
<SheetTitle id={FILE_VIEWER_LABELLED_BY_ID} className="truncate text-sm">
{title}
</SheetTitle>
<SheetDescription
id={FILE_VIEWER_DESCRIBED_BY_ID}
className="truncate font-mono text-[11px]"
title={state?.path}
>
{description}
</SheetDescription>
</div>
<div className="flex shrink-0 items-center gap-1">
{state ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={handleCopyPath}
aria-label="Copy path"
title="Copy path"
className="h-7 w-7"
>
{copiedField === "path" ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
</Button>
) : null}
{state ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={handleCopyLink}
aria-label="Copy link to this file view"
title="Copy link"
className="h-7 w-7"
>
{copiedField === "link" ? <Check className="h-4 w-4 text-green-500" /> : <FileSearch 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>
</div>
</div>
{resolvedResource ? (
<div className="flex flex-wrap items-center gap-1 text-[11px] text-muted-foreground">
<span
className="inline-flex items-center rounded-full border border-border bg-background px-2 py-0.5"
title={resolvedResource.workspaceLabel}
>
From {resolvedResource.workspaceLabel}
</span>
{resolvedResource.previewKind ? (
<span className="inline-flex items-center rounded-full border border-border bg-background px-2 py-0.5 capitalize">
{resolvedResource.previewKind}
</span>
) : null}
{formatBytes(resolvedResource.byteSize) ? (
<span className="inline-flex items-center rounded-full border border-border bg-background px-2 py-0.5">
{formatBytes(resolvedResource.byteSize)}
</span>
) : null}
{state?.line ? (
<span className="inline-flex items-center rounded-full border border-border bg-background px-2 py-0.5">
Line {state.line}
{state.column ? `, Col ${state.column}` : ""}
</span>
) : null}
</div>
) : null}
</SheetHeader>
<div className="relative flex min-h-0 flex-1 flex-col">
<div aria-live="polite" className="sr-only">
{announcement}
</div>
{state ? (
<FileViewerBody
resolveQuery={resolveQuery}
contentQuery={contentQuery}
elapsedMs={elapsedMs}
canPreview={canPreview}
highlightedLine={state.line ?? null}
onRetry={handleRetry}
onSetAnnouncement={setAnnouncement}
onFallbackToProject={
state.workspace !== "project"
? () =>
viewer.open({
path: state.path,
line: state.line,
column: state.column,
workspace: "project",
})
: null
}
/>
) : showPromptWhenEmpty ? (
<OpenFilePrompt onSubmit={handlePromptSubmit} />
) : null}
</div>
</SheetContent>
</Sheet>
);
}
interface FileViewerBodyProps {
resolveQuery: UseQueryResult<ResolvedWorkspaceResource, unknown>;
contentQuery: UseQueryResult<WorkspaceFileContent, unknown>;
elapsedMs: number;
canPreview: boolean;
highlightedLine: number | null;
onRetry: () => void;
onSetAnnouncement: (message: string) => void;
onFallbackToProject: null | (() => void);
}
function FileViewerBody({
resolveQuery,
contentQuery,
elapsedMs,
canPreview,
highlightedLine,
onRetry,
onSetAnnouncement,
onFallbackToProject,
}: FileViewerBodyProps) {
if (resolveQuery.isFetching && !resolveQuery.data) {
return <LoadingView elapsedMs={elapsedMs} />;
}
if (resolveQuery.isError) {
const normalized = normalizeError(resolveQuery.error);
if (normalized.status === 404) {
return (
<FileViewerStateView
icon={<FileSearch aria-hidden="true" className="h-6 w-6 text-muted-foreground" />}
title="File not found"
body={
onFallbackToProject
? "That file was not found in the active workspace."
: "That file was not found in this workspace."
}
actions={
<>
{onFallbackToProject ? (
<Button type="button" variant="secondary" size="sm" onClick={onFallbackToProject}>
Try project workspace
</Button>
) : null}
<Button type="button" variant="ghost" size="sm" onClick={onRetry}>
<RefreshCcw aria-hidden="true" className="mr-1 h-3 w-3" /> Retry
</Button>
</>
}
/>
);
}
if (normalized.status === 422) {
return (
<FileViewerStateView
icon={<FolderOpen aria-hidden="true" className="h-6 w-6 text-muted-foreground" />}
title="No workspace available"
body="This issue does not have a workspace that supports preview yet."
/>
);
}
const denial = describeDenial(normalized.code, normalized.message);
return (
<FileViewerStateView
icon={denial.icon}
title={denial.title}
body={denial.body}
actions={
<Button type="button" variant="ghost" size="sm" onClick={onRetry}>
<RefreshCcw aria-hidden="true" className="mr-1 h-3 w-3" /> Retry
</Button>
}
/>
);
}
const resource = resolveQuery.data;
if (!resource) return null;
if (resource.kind === "remote_resource") {
return (
<FileViewerStateView
icon={<ArrowLeft aria-hidden="true" className="h-6 w-6 text-muted-foreground" />}
title="Remote workspace preview coming soon"
body="This workspace is hosted remotely; inline previews are not supported yet."
/>
);
}
if (!canPreview) {
const denial = describeDenial(resource.denialReason ?? "", resource.denialReason ?? "");
return <FileViewerStateView icon={denial.icon} title={denial.title} body={denial.body} />;
}
if (contentQuery.isFetching && !contentQuery.data) {
return <LoadingView elapsedMs={elapsedMs} />;
}
if (contentQuery.isError) {
const normalized = normalizeError(contentQuery.error);
const denial = describeDenial(normalized.code, normalized.message);
return (
<FileViewerStateView
icon={denial.icon}
title={denial.title}
body={denial.body}
actions={
<Button type="button" variant="ghost" size="sm" onClick={onRetry}>
<RefreshCcw aria-hidden="true" className="mr-1 h-3 w-3" /> Retry
</Button>
}
/>
);
}
if (!contentQuery.data) return null;
return (
<FileContentViewer
content={contentQuery.data}
highlightedLine={highlightedLine}
onLoaded={onSetAnnouncement}
/>
);
}
+1
View File
@@ -673,6 +673,7 @@ const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed }: {
className="text-sm leading-6"
style={recessed ? { opacity: 0.55 } : undefined}
softBreaks
linkWorkspaceFileReferences
onImageClick={onImageClick}
>
{text}
+19
View File
@@ -532,4 +532,23 @@ describe("MarkdownBody", () => {
expect(html).toContain('href="/issues/ACME-1"');
});
it("renders workspace file refs in inline code as workspace file links when enabled", () => {
const html = renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<ThemeProvider>
<MarkdownBody linkWorkspaceFileReferences>
{"Check `ui/src/pages/IssueDetail.tsx:42` please."}
</MarkdownBody>
</ThemeProvider>
</QueryClientProvider>,
);
expect(html).toContain('data-workspace-file-link="true"');
expect(html).toContain("ui/src/pages/IssueDetail.tsx:42");
});
it("does not create workspace file links when the flag is off", () => {
const html = renderMarkdown("See `ui/src/pages/IssueDetail.tsx:42` for details.");
expect(html).not.toContain("data-workspace-file-link");
});
});
+24
View File
@@ -12,7 +12,13 @@ import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys";
import { parseIssueReferenceFromHref, remarkLinkIssueReferences } from "../lib/issue-reference";
import { remarkSoftBreaks } from "../lib/remark-soft-breaks";
import {
parseWorkspaceFileHref,
remarkWorkspaceFileRefs,
WORKSPACE_FILE_HREF_PREFIX,
} from "../lib/remark-workspace-file-refs";
import { StatusIcon } from "./StatusIcon";
import { WorkspaceFileLink } from "./WorkspaceFileLink";
interface MarkdownBodyProps {
children: string;
@@ -26,6 +32,12 @@ interface MarkdownBodyProps {
wikiLinkRoot?: string;
/** Optional href resolver for wikilinks. Return null to leave a token as plain text. */
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
/**
* When true, auto-link workspace file references inside inline code spans
* (e.g. `ui/src/pages/IssueDetail.tsx:42`). Requires a FileViewerProvider
* ancestor so that clicking a link opens the viewer.
*/
linkWorkspaceFileReferences?: boolean;
/** Optional resolver for relative image paths (e.g. within export packages) */
resolveImageSrc?: (src: string) => string | null;
/** Called when a user clicks an inline image */
@@ -160,6 +172,7 @@ function extractMermaidSource(children: ReactNode): string | null {
}
function safeMarkdownUrlTransform(url: string): string {
if (url.startsWith(WORKSPACE_FILE_HREF_PREFIX)) return url;
return parseMentionChipHref(url) ? url : defaultUrlTransform(url);
}
@@ -567,6 +580,7 @@ export function MarkdownBody({
enableWikiLinks = false,
wikiLinkRoot,
resolveWikiLinkHref,
linkWorkspaceFileReferences = false,
resolveImageSrc,
onImageClick,
}: MarkdownBodyProps) {
@@ -585,6 +599,9 @@ export function MarkdownBody({
if (linkIssueReferences) {
remarkPlugins.push([remarkLinkIssueReferences, { knownPrefixes }]);
}
if (linkWorkspaceFileReferences) {
remarkPlugins.push(remarkWorkspaceFileRefs);
}
if (softBreaks) {
remarkPlugins.push(remarkSoftBreaks);
}
@@ -636,6 +653,13 @@ export function MarkdownBody({
a: ({ node: _node, href, style: linkStyle, children: linkChildren, ...anchorProps }) => {
const dataProps = anchorProps as Record<string, unknown>;
const isWikiLink = dataProps["data-paperclip-wiki-link"] === "true";
if (linkWorkspaceFileReferences) {
const workspaceFileRef = parseWorkspaceFileHref(href);
if (workspaceFileRef) {
return <WorkspaceFileLink workspaceFileRef={workspaceFileRef} />;
}
}
if (isWikiLink && href && !/^[a-z][a-z\d+.-]*:/i.test(href) && !href.startsWith("//")) {
return (
<Link
+68
View File
@@ -0,0 +1,68 @@
import type { MouseEvent, ReactNode } from "react";
import { FileCode2 } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ParsedWorkspaceFileRef } from "@/lib/workspace-file-parser";
import { formatWorkspaceFileRefDisplay } from "@/lib/workspace-file-parser";
import { useFileViewer } from "@/context/FileViewerContext";
export interface WorkspaceFileLinkProps {
workspaceFileRef: ParsedWorkspaceFileRef;
/** Override the rendered label. Defaults to `path:line:col`. */
label?: ReactNode;
className?: string;
/** Optional override if the consumer wants to customize activation. */
onOpen?: (ref: ParsedWorkspaceFileRef) => void;
showIcon?: boolean;
title?: string;
}
export function WorkspaceFileLink({
workspaceFileRef,
label,
className,
onOpen,
showIcon = true,
title,
}: WorkspaceFileLinkProps) {
const viewer = useFileViewer();
const display = typeof label !== "undefined" ? label : formatWorkspaceFileRefDisplay(workspaceFileRef);
const canOpen = !!(onOpen || viewer);
const lineSuffix = workspaceFileRef.line
? ` line ${workspaceFileRef.line}${workspaceFileRef.column ? ` column ${workspaceFileRef.column}` : ""}`
: "";
const ariaLabel = canOpen
? `Open ${workspaceFileRef.path}${lineSuffix} in the file viewer`
: `Workspace file ${workspaceFileRef.path}${lineSuffix}`;
const tooltip = title ?? (canOpen
? `Open ${workspaceFileRef.path}${lineSuffix} in the file viewer`
: `Workspace file ${workspaceFileRef.path}${lineSuffix}`);
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
if (!canOpen) return;
event.preventDefault();
if (onOpen) onOpen(workspaceFileRef);
else viewer?.open(workspaceFileRef);
};
return (
<a
href="#"
role={canOpen ? "button" : undefined}
data-workspace-file-link="true"
data-workspace-file-path={workspaceFileRef.path}
aria-label={ariaLabel}
title={tooltip}
className={cn(
"paperclip-workspace-file-link inline-flex items-center gap-1 rounded-sm border border-border bg-muted/60 px-1.5 py-0.5 font-mono text-[0.78em] leading-tight text-foreground/90 align-baseline no-underline hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
className,
)}
onClick={handleClick}
>
{showIcon ? <FileCode2 aria-hidden="true" className="h-3 w-3 shrink-0 opacity-70" /> : null}
<span className="truncate max-w-[38ch]">{display}</span>
</a>
);
}
+81
View File
@@ -0,0 +1,81 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import {
readFileViewerStateFromSearch,
writeFileViewerStateToSearch,
} from "./FileViewerContext";
describe("readFileViewerStateFromSearch", () => {
it("returns null when no file param is present", () => {
expect(readFileViewerStateFromSearch("")).toBeNull();
expect(readFileViewerStateFromSearch("?other=1")).toBeNull();
});
it("reads file, line, column, workspace from the search", () => {
const state = readFileViewerStateFromSearch("?file=ui/src/a.ts&line=42&column=3&workspace=project");
expect(state).toEqual({
path: "ui/src/a.ts",
line: 42,
column: 3,
workspace: "project",
});
});
it("defaults to auto workspace when param missing", () => {
const state = readFileViewerStateFromSearch("?file=ui/src/a.ts");
expect(state?.workspace).toBe("auto");
});
it("clamps invalid workspace to auto", () => {
const state = readFileViewerStateFromSearch("?file=ui/src/a.ts&workspace=bogus");
expect(state?.workspace).toBe("auto");
});
it("treats invalid line/column as null", () => {
const state = readFileViewerStateFromSearch("?file=x.ts&line=abc&column=-1");
expect(state?.line).toBeNull();
expect(state?.column).toBeNull();
});
});
describe("writeFileViewerStateToSearch", () => {
it("sets all params when opening", () => {
const next = writeFileViewerStateToSearch(
"?existing=1",
{ path: "ui/src/a.ts", line: 42, column: 3, workspace: "project" },
);
const params = new URLSearchParams(next);
expect(params.get("file")).toBe("ui/src/a.ts");
expect(params.get("line")).toBe("42");
expect(params.get("column")).toBe("3");
expect(params.get("workspace")).toBe("project");
expect(params.get("existing")).toBe("1");
});
it("omits workspace when auto", () => {
const next = writeFileViewerStateToSearch(
"",
{ path: "a.ts", line: null, column: null, workspace: "auto" },
);
expect(next.includes("workspace")).toBe(false);
});
it("clears viewer params when closing", () => {
const next = writeFileViewerStateToSearch(
"?file=a.ts&line=1&column=2&workspace=project&keep=yes",
null,
);
const params = new URLSearchParams(next);
expect(params.get("file")).toBeNull();
expect(params.get("line")).toBeNull();
expect(params.get("column")).toBeNull();
expect(params.get("workspace")).toBeNull();
expect(params.get("keep")).toBe("yes");
});
it("returns empty string when no params remain", () => {
const next = writeFileViewerStateToSearch("?file=a.ts", null);
expect(next).toBe("");
});
});
+115
View File
@@ -0,0 +1,115 @@
import { createContext, useContext, useCallback, useMemo, type ReactNode } from "react";
import { useLocation, useNavigate } from "@/lib/router";
import type { WorkspaceFileSelector } from "@paperclipai/shared";
import type { ParsedWorkspaceFileRef } from "@/lib/workspace-file-parser";
export interface FileViewerUrlState {
path: string;
line: number | null;
column: number | null;
workspace: WorkspaceFileSelector;
}
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;
close(): void;
}
const FileViewerContext = createContext<FileViewerContextValue | null>(null);
export function readFileViewerStateFromSearch(search: string): FileViewerUrlState | null {
const params = new URLSearchParams(search);
const path = params.get("file");
if (!path) return null;
const lineRaw = params.get("line");
const columnRaw = params.get("column");
const workspaceRaw = params.get("workspace");
const line = lineRaw ? Number.parseInt(lineRaw, 10) : NaN;
const column = columnRaw ? Number.parseInt(columnRaw, 10) : NaN;
const workspace = (workspaceRaw === "execution" || workspaceRaw === "project")
? workspaceRaw
: "auto";
return {
path,
line: Number.isFinite(line) && line > 0 ? line : null,
column: Number.isFinite(column) && column > 0 ? column : null,
workspace,
};
}
export function writeFileViewerStateToSearch(current: string, next: FileViewerUrlState | null): string {
const params = new URLSearchParams(current);
if (!next) {
params.delete("file");
params.delete("line");
params.delete("column");
params.delete("workspace");
} else {
params.set("file", next.path);
if (next.line !== null) params.set("line", String(next.line));
else params.delete("line");
if (next.column !== null) params.set("column", String(next.column));
else params.delete("column");
if (next.workspace && next.workspace !== "auto") params.set("workspace", next.workspace);
else params.delete("workspace");
}
const str = params.toString();
return str ? `?${str}` : "";
}
interface FileViewerProviderProps {
issueId: string;
children: ReactNode;
}
export function FileViewerProvider({ issueId, children }: FileViewerProviderProps) {
const location = useLocation();
const navigate = useNavigate();
const state = useMemo(() => readFileViewerStateFromSearch(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",
});
navigate(
{ pathname: location.pathname, hash: location.hash, search: nextSearch },
{ state: location.state, replace: false },
);
},
[location.hash, location.pathname, location.search, location.state, navigate],
);
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 value = useMemo<FileViewerContextValue>(
() => ({ issueId, state, open, close }),
[issueId, state, open, close],
);
return <FileViewerContext.Provider value={value}>{children}</FileViewerContext.Provider>;
}
export function useFileViewer(): FileViewerContextValue | null {
return useContext(FileViewerContext);
}
export function useRequiredFileViewer(): FileViewerContextValue {
const ctx = useContext(FileViewerContext);
if (!ctx) {
throw new Error("useRequiredFileViewer must be used within a FileViewerProvider");
}
return ctx;
}
+15
View File
@@ -257,6 +257,21 @@ describe("keyboardShortcuts helpers", () => {
})).toBe("focus_comment");
});
it("opens the file viewer on f after g", () => {
const button = document.createElement("button");
expect(resolveIssueDetailGoKeyAction({
armed: true,
defaultPrevented: false,
key: "f",
metaKey: false,
ctrlKey: false,
altKey: false,
target: button,
hasOpenDialog: false,
})).toBe("open_file_viewer");
});
it("disarms go-to-inbox instead of firing from an editor", () => {
const input = document.createElement("textarea");
+8 -1
View File
@@ -13,7 +13,13 @@ const MODIFIER_ONLY_KEYS = new Set(["Shift", "Meta", "Control", "Alt"]);
export type InboxQuickArchiveKeyAction = "ignore" | "archive" | "disarm";
export type InboxUndoArchiveKeyAction = "ignore" | "undo_archive";
export type IssueDetailGoKeyAction = "ignore" | "arm" | "navigate_inbox" | "focus_comment" | "disarm";
export type IssueDetailGoKeyAction =
| "ignore"
| "arm"
| "navigate_inbox"
| "focus_comment"
| "open_file_viewer"
| "disarm";
export function isKeyboardShortcutTextInputTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
@@ -162,6 +168,7 @@ export function resolveIssueDetailGoKeyAction({
if (!armed) return normalizedKey === "g" ? "arm" : "ignore";
if (normalizedKey === "i") return "navigate_inbox";
if (normalizedKey === "c") return "focus_comment";
if (normalizedKey === "f") return "open_file_viewer";
if (normalizedKey === "g") return "arm";
return "disarm";
}
+4
View File
@@ -87,6 +87,10 @@ 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,
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,
},
routines: {
list: (companyId: string, filters?: { projectId?: string | null }) =>
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import {
buildWorkspaceFileHref,
parseWorkspaceFileHref,
remarkWorkspaceFileRefs,
} from "./remark-workspace-file-refs";
type MarkdownNode = {
type: string;
value?: string;
url?: string;
children?: MarkdownNode[];
};
function textNode(value: string): MarkdownNode {
return { type: "text", value };
}
function inlineCode(value: string): MarkdownNode {
return { type: "inlineCode", value };
}
function paragraph(children: MarkdownNode[]): MarkdownNode {
return { type: "paragraph", children };
}
function runPlugin(tree: MarkdownNode): MarkdownNode {
const transform = remarkWorkspaceFileRefs();
transform(tree);
return tree;
}
describe("remarkWorkspaceFileRefs", () => {
it("converts a matching inline code span into a workspace-file link", () => {
const tree = paragraph([
textNode("Check "),
inlineCode("ui/src/pages/IssueDetail.tsx:42"),
textNode(" please."),
]);
runPlugin(tree);
expect(tree.children).toHaveLength(3);
const link = tree.children![1];
expect(link.type).toBe("link");
expect(link.url?.startsWith("workspace-file:")).toBe(true);
const parsed = parseWorkspaceFileHref(link.url);
expect(parsed?.path).toBe("ui/src/pages/IssueDetail.tsx");
expect(parsed?.line).toBe(42);
});
it("does not linkify plain text path mentions outside inline code", () => {
const tree = paragraph([textNode("see ui/src/pages/IssueDetail.tsx for details")]);
runPlugin(tree);
expect(tree.children).toHaveLength(1);
expect(tree.children![0].type).toBe("text");
});
it("does not linkify prose words that look like filenames", () => {
const tree = paragraph([inlineCode("README.md")]);
runPlugin(tree);
expect(tree.children![0].type).toBe("inlineCode");
});
it("round-trips workspace file hrefs", () => {
const href = buildWorkspaceFileHref({ path: "a/b.ts", line: 5, column: 2, raw: "a/b.ts:5:2" });
const parsed = parseWorkspaceFileHref(href);
expect(parsed?.path).toBe("a/b.ts");
expect(parsed?.line).toBe(5);
expect(parsed?.column).toBe(2);
});
it("does not descend into existing links", () => {
const tree: MarkdownNode = {
type: "paragraph",
children: [
{
type: "link",
url: "https://example.com",
children: [inlineCode("ui/src/a.ts:1")],
},
],
};
runPlugin(tree);
const inner = tree.children![0].children![0];
expect(inner.type).toBe("inlineCode");
});
});
+76
View File
@@ -0,0 +1,76 @@
import { parseWorkspaceFileRef, type ParsedWorkspaceFileRef } from "./workspace-file-parser";
const WORKSPACE_FILE_HREF_SCHEME = "workspace-file:";
type MarkdownNode = {
type: string;
value?: string;
url?: string;
children?: MarkdownNode[];
};
export function buildWorkspaceFileHref(ref: ParsedWorkspaceFileRef): string {
const params = new URLSearchParams();
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));
return `${WORKSPACE_FILE_HREF_SCHEME}?${params.toString()}`;
}
export function parseWorkspaceFileHref(href: string | null | undefined): ParsedWorkspaceFileRef | null {
if (!href || typeof href !== "string") return null;
if (!href.startsWith(WORKSPACE_FILE_HREF_SCHEME)) return null;
const rest = href.slice(WORKSPACE_FILE_HREF_SCHEME.length);
const withoutLeadingQuestion = rest.startsWith("?") ? rest.slice(1) : rest;
const params = new URLSearchParams(withoutLeadingQuestion);
const path = params.get("path");
if (!path) return null;
const lineRaw = params.get("line");
const columnRaw = params.get("column");
const line = lineRaw ? Number.parseInt(lineRaw, 10) : NaN;
const column = columnRaw ? Number.parseInt(columnRaw, 10) : NaN;
return {
path,
line: Number.isFinite(line) && line > 0 ? line : null,
column: Number.isFinite(column) && column > 0 ? column : null,
raw: path,
};
}
function createWorkspaceFileLinkNode(ref: ParsedWorkspaceFileRef): MarkdownNode {
return {
type: "link",
url: buildWorkspaceFileHref(ref),
children: [{ type: "inlineCode", value: ref.raw }],
};
}
function rewriteMarkdownTree(node: MarkdownNode) {
if (!Array.isArray(node.children) || node.children.length === 0) return;
// Don't descend into existing links or code blocks; only rewrite inlineCode within flowing text.
if (node.type === "link" || node.type === "linkReference" || node.type === "code" || node.type === "definition" || node.type === "html") {
return;
}
const nextChildren: MarkdownNode[] = [];
for (const child of node.children) {
if (child.type === "inlineCode" && typeof child.value === "string") {
const ref = parseWorkspaceFileRef(child.value);
if (ref) {
nextChildren.push(createWorkspaceFileLinkNode(ref));
continue;
}
}
rewriteMarkdownTree(child);
nextChildren.push(child);
}
node.children = nextChildren;
}
export function remarkWorkspaceFileRefs() {
return (tree: MarkdownNode) => {
rewriteMarkdownTree(tree);
};
}
export const WORKSPACE_FILE_HREF_PREFIX = WORKSPACE_FILE_HREF_SCHEME;
Binary file not shown.
+83
View File
@@ -0,0 +1,83 @@
export interface ParsedWorkspaceFileRef {
path: string;
line: number | null;
column: number | null;
/** The original matched text (useful for rendering) */
raw: string;
}
/**
* Match a workspace file reference inside an inline code span.
*
* Accepts POSIX-style relative paths with at least one slash or a recognizable
* file extension. Supports optional line/column suffixes:
*
* - `path/to/file.ext`
* - `path/to/file.ext:42`
* - `path/to/file.ext:42:3`
* - `path/to/file.ext#L42`
* - `path/to/file.ext#L42C3`
*/
const WORKSPACE_FILE_REF_RE =
/^([A-Za-z0-9_.\-+][A-Za-z0-9_./\-+]*\.[A-Za-z0-9_+\-]{1,10})(?::([1-9]\d*)(?::([1-9]\d*))?|#L([1-9]\d*)(?:C([1-9]\d*))?)?$/;
const BARE_NO_EXT_RE = /^([A-Za-z0-9_.\-+][A-Za-z0-9_./\-+]+\/[A-Za-z0-9_.\-+]+)(?::([1-9]\d*)(?::([1-9]\d*))?|#L([1-9]\d*)(?:C([1-9]\d*))?)?$/;
const INVALID_PREFIXES = ["/", "./", "../", "~/"];
function toPositiveInt(value: string | undefined): number | null {
if (!value) return null;
const n = Number.parseInt(value, 10);
if (!Number.isFinite(n) || n <= 0) return null;
return n;
}
function looksLikeWorkspaceFilePath(path: string): boolean {
if (!path || path.length > 512) return false;
if (path.includes("\\") || path.includes("\0")) return false;
if (path.startsWith("/") || path.startsWith("~") || /^[A-Za-z]:/.test(path)) return false;
if (path.includes("//")) return false;
if (INVALID_PREFIXES.some((prefix) => path === prefix.slice(0, -1))) return false;
if (/^\.+$/.test(path)) return false;
const segments = path.split("/");
for (const segment of segments) {
if (segment === "" || segment === "." || segment === "..") return false;
}
return true;
}
/**
* Attempt to parse the given text as a workspace file reference.
* Returns null if the text does not look like one.
*/
export function parseWorkspaceFileRef(input: string): ParsedWorkspaceFileRef | null {
if (typeof input !== "string") return null;
const trimmed = input.trim();
if (!trimmed) return null;
const match = trimmed.match(WORKSPACE_FILE_REF_RE) ?? trimmed.match(BARE_NO_EXT_RE);
if (!match) return null;
const [, rawPath, colonLine, colonCol, hashLine, hashCol] = match;
if (!rawPath) return null;
if (!looksLikeWorkspaceFilePath(rawPath)) return null;
const line = toPositiveInt(colonLine) ?? toPositiveInt(hashLine);
const column = toPositiveInt(colonCol) ?? toPositiveInt(hashCol);
// Disambiguate against plain prose filenames like `README.md`:
// require either a slash in the path or a line anchor.
if (!rawPath.includes("/") && line === null) return null;
return {
path: rawPath,
line,
column,
raw: trimmed,
};
}
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;
}
+65
View File
@@ -87,6 +87,8 @@ import { IssueRunLedger } from "../components/IssueRunLedger";
import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard";
import type { MentionOption } from "../components/MarkdownEditor";
import { ImageGalleryModal } from "../components/ImageGalleryModal";
import { FileViewerProvider, useRequiredFileViewer } from "../context/FileViewerContext";
import { FileViewerSheet } from "../components/FileViewerSheet";
import { ScrollToBottom } from "../components/ScrollToBottom";
import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon";
@@ -132,6 +134,7 @@ import {
Eye,
EyeOff,
Flag,
FileCode2,
Hexagon,
ListTree,
MessageSquare,
@@ -1276,6 +1279,7 @@ export function IssueDetail() {
const [moreOpen, setMoreOpen] = useState(false);
const [copied, setCopied] = useState(false);
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
const [fileViewerPromptOpen, setFileViewerPromptOpen] = useState(false);
const [detailTab, setDetailTab] = useState("chat");
const [handoffFocusSignal, setHandoffFocusSignal] = useState(0);
const [pendingApprovalAction, setPendingApprovalAction] = useState<{
@@ -2923,6 +2927,11 @@ export function IssueDetail() {
setDetailTab("chat");
setPendingCommentComposerFocusKey((current) => current + 1);
}
if (action === "open_file_viewer") {
event.preventDefault();
event.stopPropagation();
setFileViewerPromptOpen(true);
}
};
document.addEventListener("pointerdown", handlePointerDown, true);
@@ -2982,6 +2991,19 @@ export function IssueDetail() {
commentComposerRef.current?.focus();
}, [detailTab, pendingCommentComposerFocusKey]);
useEffect(() => {
const handleOpenFileViewer = () => {
setFileViewerPromptOpen(true);
};
window.addEventListener("paperclip:open-file-viewer", handleOpenFileViewer as EventListener);
return () => {
window.removeEventListener(
"paperclip:open-file-viewer",
handleOpenFileViewer as EventListener,
);
};
}, []);
const promotedOutputAttachmentIds = useMemo(() => getPromotedOutputAttachmentIds(workProducts), [workProducts]);
const attachmentList = useMemo(
() => (attachments ?? []).filter((attachment) => !promotedOutputAttachmentIds.has(attachment.id)),
@@ -3423,6 +3445,7 @@ export function IssueDetail() {
);
return (
<FileViewerProvider issueId={issue.id}>
<div className="max-w-3xl space-y-6">
{/* Parent chain breadcrumb */}
{ancestors.length > 0 && (
@@ -3672,6 +3695,15 @@ export function IssueDetail() {
<Archive className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="icon-xs"
onClick={() => setFileViewerPromptOpen(true)}
title="Open file... (g f)"
aria-label="Open file in this issue"
>
<FileCode2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-xs"
@@ -4314,7 +4346,40 @@ export function IssueDetail() {
</ScrollArea>
</SheetContent>
</Sheet>
<IssueFileViewer
issueId={issue.id}
promptOpen={fileViewerPromptOpen}
onPromptOpenChange={setFileViewerPromptOpen}
/>
<ScrollToBottom />
</div>
</FileViewerProvider>
);
}
function IssueFileViewer({
issueId,
promptOpen,
onPromptOpenChange,
}: {
issueId: string;
promptOpen: boolean;
onPromptOpenChange: (next: boolean) => void;
}) {
const viewer = useRequiredFileViewer();
const open = viewer.state !== null || promptOpen;
const showPromptWhenEmpty = promptOpen && viewer.state === null;
return (
<FileViewerSheet
issueId={issueId}
open={open}
showPromptWhenEmpty={showPromptWhenEmpty}
onOpenChange={(next) => {
if (!next) {
onPromptOpenChange(false);
if (viewer.state !== null) viewer.close();
}
}}
/>
);
}