mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
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:
1 parent
e11a5d1b2d
commit
4bad0cf17f
17 files changed
+1484
-2
No files matched your search
@@ -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)}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user