Add workspace file viewer and artifact links (#7681)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent work is issue-centered, and reviewers often need to inspect files, artifacts, and path references produced during that work. > - Before this branch, workspace-relative paths and artifact file references were not first-class inspectable objects in the board UI. > - Safe file viewing needs shared resource contracts, server-side workspace boundary checks, and UI that opens files without exposing arbitrary host paths. > - The workspace file viewer branch needed to stay as one active PR and be rebased onto current `paperclipai/paperclip:master` for review. > - This pull request adds the workspace file resource API, issue-page file viewer and browser, markdown file-reference links, and artifact file chips. > - The benefit is that board users can inspect relevant files from issue context while preserving workspace boundaries and auditability. ## Linked Issues or Issue Description No public GitHub issue exists for this branch. Internal Paperclip issues: `PAP-1953`, `PAP-10539`, `PAP-10733`. Problem / motivation: - Board users need to open workspace-relative files mentioned by agents or attached as work-product metadata without switching to a terminal. - The UI needs to support both direct file-path opening and workspace browsing/searching from an issue page. - The server must enforce company access, workspace boundaries, size limits, rate limits, and safe audit logging. Related PR: - Prior closed attempt: #4442 - Single active PR for this branch: #7681 ## What Changed - Added shared workspace file resource types, validators, and workspace-file `resourceRef` metadata validation for work products. - Added server routes/services for resolving, listing, and previewing workspace-relative files with access checks, scan caps, list-specific limits, and audit logging. - Added the issue file viewer provider, sheet, workspace browser, command-palette action, markdown workspace-file autolinks, and artifact file chips. - Updated issue workspace UI and stories/tests for file browsing and workspace file opening. - Rebased the branch onto current `paperclipai/paperclip:master` and updated the existing single PR branch. - Addressed current-head Greptile follow-ups by applying `offset` consistently across search/recent/changed file listings, restoring stopped-service port ownership checks before auto-port reuse, and stabilizing the workspace browser pagination test. ## Verification Current local verification after rebase to `public/master`: - `pnpm exec vitest run packages/shared/src/work-product.test.ts server/src/__tests__/file-resources.test.ts server/src/__tests__/instance-settings-routes.test.ts server/src/__tests__/instance-settings-service.test.ts server/src/__tests__/workspace-runtime.test.ts ui/src/components/FileViewerSheet.test.tsx ui/src/components/FileViewerSheet.copy.test.tsx ui/src/components/WorkspaceFileBrowser.test.tsx ui/src/components/WorkspaceFileMarkdownBody.test.tsx ui/src/context/FileViewerContext.test.ts ui/src/lib/remark-workspace-file-refs.test.ts ui/src/lib/workspace-file-parser.test.ts ui/src/components/IssueWorkspaceCard.test.tsx` - 13 files passed, 197 tests passed. - `pnpm -r --filter @paperclipai/shared --filter @paperclipai/server --filter @paperclipai/ui typecheck` - passed. - `pnpm exec vitest run ui/src/components/WorkspaceFileBrowser.test.tsx` - 1 file passed, 25 tests passed. - `pnpm exec vitest run server/src/__tests__/file-resources.test.ts server/src/__tests__/workspace-runtime.test.ts` - 2 files passed, 90 tests passed. - `pnpm -r --filter @paperclipai/server typecheck` - passed. - Confirmed branch is `0` behind and `46` ahead of current `public/master` after rebase and follow-up commits. - Confirmed the PR diff does not include `pnpm-lock.yaml`. - Confirmed the PR diff does not include `.github/workflows` changes. - Searched GitHub for duplicate or related workspace file viewer PRs/issues; #4442 is the prior closed attempt and this PR is the single active PR for the branch. - No screenshots were committed; the task explicitly asked not to add design screenshots or images unless they were part of the work. Current remote verification on head `a698a7bc10137baf7d25bd5722e1d6e0343387c1`: - Greptile Review - success, 64 files reviewed, 0 comments added, no unresolved Greptile review threads. - PR workflow `verify` - success. - Typecheck + Release Registry, General tests, workspace test shards, serialized server suites, Build, Canary Dry Run, e2e, Socket, and Snyk - success. - `security-review` - neutral, with output saying a draft advisory was filed for maintainer review and is not a merge block. - `commitperclip PR Review / review` - cancelled after the security gate detected flags and timed out while creating/reviewing the advisory. I reran it once and it cancelled the same way; no actionable code/test failure was exposed in the job logs. ## Risks - This is a broad UI/server feature PR, so review needs to pay attention to route authorization, workspace boundary handling, and markdown autolink false positives. - Workspace browsing intentionally caps list results and scan depth; very large workspaces may require users to refine search terms. - Remote workspace preview remains unavailable until remote file-access support is implemented. - The neutral commitperclip security-review advisory needs maintainer review, but the check output says it is not a merge block. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected - check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent in a Paperclip/Codex local tool-use environment, medium reasoning, with shell/GitHub CLI tool use for branch inspection, verification, rebase, PR update, Greptile review, and CI inspection. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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,30 @@ export const queryKeys = {
|
||||
liveRuns: (issueId: string) => ["issues", "live-runs", issueId] as const,
|
||||
activeRun: (issueId: string) => ["issues", "active-run", issueId] as const,
|
||||
workProducts: (issueId: string) => ["issues", "work-products", issueId] as const,
|
||||
fileResources: (
|
||||
issueId: string,
|
||||
options: {
|
||||
workspace?: string;
|
||||
projectId?: string | null;
|
||||
workspaceId?: string | null;
|
||||
path?: string | null;
|
||||
mode?: string;
|
||||
q?: string | null;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {},
|
||||
) =>
|
||||
["issues", "file-resources", issueId, "list", options] as const,
|
||||
fileResource: (
|
||||
issueId: string,
|
||||
query: { path: string; workspace?: string; projectId?: string | null; workspaceId?: string | null },
|
||||
) =>
|
||||
["issues", "file-resources", issueId, "resolve", query] as const,
|
||||
fileResourceContent: (
|
||||
issueId: string,
|
||||
query: { path: string; workspace?: string; projectId?: string | null; workspaceId?: string | null },
|
||||
) =>
|
||||
["issues", "file-resources", issueId, "content", query] as const,
|
||||
},
|
||||
routines: {
|
||||
list: (companyId: string, filters?: { projectId?: string | null }) =>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
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?.resourceKind).toBe("file");
|
||||
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("round-trips workspace folder hrefs", () => {
|
||||
const targetPath = "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/";
|
||||
const href = buildWorkspaceFileHref({
|
||||
path: targetPath,
|
||||
resourceKind: "directory",
|
||||
line: null,
|
||||
column: null,
|
||||
raw: targetPath,
|
||||
projectId: "17acae7d-9d0c-46bf-9c82-be9694ac3461",
|
||||
workspaceId: "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2",
|
||||
});
|
||||
const parsed = parseWorkspaceFileHref(href);
|
||||
expect(parsed).toMatchObject({
|
||||
path: targetPath,
|
||||
resourceKind: "directory",
|
||||
line: null,
|
||||
column: null,
|
||||
projectId: "17acae7d-9d0c-46bf-9c82-be9694ac3461",
|
||||
workspaceId: "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2",
|
||||
});
|
||||
});
|
||||
|
||||
it("converts trailing-slash inline code into workspace folder links", () => {
|
||||
const tree = paragraph([
|
||||
textNode("Open "),
|
||||
inlineCode("content-os/cases/active/2026-06-06-pap-10199-bundled-skills/"),
|
||||
textNode("."),
|
||||
]);
|
||||
runPlugin(tree);
|
||||
const link = tree.children![1];
|
||||
expect(link.type).toBe("link");
|
||||
expect(parseWorkspaceFileHref(link.url)).toMatchObject({
|
||||
path: "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/",
|
||||
resourceKind: "directory",
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips explicit project workspace identity", () => {
|
||||
const targetPath = "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/README.md";
|
||||
const href = buildWorkspaceFileHref({
|
||||
path: targetPath,
|
||||
line: 5,
|
||||
column: null,
|
||||
raw: `${targetPath}:5`,
|
||||
projectId: "17acae7d-9d0c-46bf-9c82-be9694ac3461",
|
||||
workspaceId: "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2",
|
||||
projectName: "Paperclip Content",
|
||||
});
|
||||
const parsed = parseWorkspaceFileHref(href);
|
||||
expect(parsed).toMatchObject({
|
||||
path: targetPath,
|
||||
line: 5,
|
||||
column: null,
|
||||
projectId: "17acae7d-9d0c-46bf-9c82-be9694ac3461",
|
||||
workspaceId: "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2",
|
||||
projectName: "Paperclip Content",
|
||||
});
|
||||
});
|
||||
|
||||
it("converts linked inline-code file paths into workspace-file links", () => {
|
||||
const tree: MarkdownNode = {
|
||||
type: "paragraph",
|
||||
children: [
|
||||
{
|
||||
type: "link",
|
||||
url: "/PAP/issues/PAP-10306",
|
||||
children: [inlineCode("ui/src/a.ts:1")],
|
||||
},
|
||||
],
|
||||
};
|
||||
runPlugin(tree);
|
||||
const link = tree.children![0];
|
||||
expect(link.type).toBe("link");
|
||||
expect(link.url?.startsWith("workspace-file:")).toBe(true);
|
||||
expect(parseWorkspaceFileHref(link.url)?.path).toBe("ui/src/a.ts");
|
||||
});
|
||||
|
||||
it("does not descend into existing links with mixed labels", () => {
|
||||
const tree: MarkdownNode = {
|
||||
type: "paragraph",
|
||||
children: [
|
||||
{
|
||||
type: "link",
|
||||
url: "https://example.com",
|
||||
children: [textNode("see "), inlineCode("ui/src/a.ts:1")],
|
||||
},
|
||||
],
|
||||
};
|
||||
runPlugin(tree);
|
||||
const link = tree.children![0];
|
||||
expect(link.url).toBe("https://example.com");
|
||||
expect(link.children![1]?.type).toBe("inlineCode");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
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();
|
||||
if (ref.projectId) params.set("projectId", ref.projectId);
|
||||
if (ref.workspaceId) params.set("workspaceId", ref.workspaceId);
|
||||
if (ref.resourceKind === "directory") params.set("kind", "directory");
|
||||
params.set("path", ref.path);
|
||||
if (ref.line !== null) params.set("line", String(ref.line));
|
||||
if (ref.column !== null) params.set("column", String(ref.column));
|
||||
if (ref.projectName) params.set("projectName", ref.projectName);
|
||||
return `${WORKSPACE_FILE_HREF_SCHEME}?${params.toString()}`;
|
||||
}
|
||||
|
||||
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 projectIdRaw = params.get("projectId");
|
||||
const workspaceIdRaw = params.get("workspaceId");
|
||||
const hasExplicitTarget = Boolean(projectIdRaw && workspaceIdRaw);
|
||||
const projectName = params.get("projectName");
|
||||
const kindRaw = params.get("kind");
|
||||
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,
|
||||
resourceKind: kindRaw === "directory" || path.endsWith("/") ? "directory" : "file",
|
||||
line: Number.isFinite(line) && line > 0 ? line : null,
|
||||
column: Number.isFinite(column) && column > 0 ? column : null,
|
||||
projectId: hasExplicitTarget ? projectIdRaw : null,
|
||||
workspaceId: hasExplicitTarget ? workspaceIdRaw : null,
|
||||
projectName: projectName || null,
|
||||
raw: path,
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkspaceFileLinkNode(ref: ParsedWorkspaceFileRef): MarkdownNode {
|
||||
return {
|
||||
type: "link",
|
||||
url: buildWorkspaceFileHref(ref),
|
||||
children: [{ type: "inlineCode", value: ref.raw }],
|
||||
};
|
||||
}
|
||||
|
||||
function parseSingleInlineCodeFileRef(node: MarkdownNode): ParsedWorkspaceFileRef | null {
|
||||
if (!Array.isArray(node.children) || node.children.length !== 1) return null;
|
||||
const [child] = node.children;
|
||||
if (child?.type !== "inlineCode" || typeof child.value !== "string") return null;
|
||||
return parseWorkspaceFileRef(child.value);
|
||||
}
|
||||
|
||||
function rewriteMarkdownTree(node: MarkdownNode) {
|
||||
if (!Array.isArray(node.children) || node.children.length === 0) return;
|
||||
// Existing links whose whole label is a workspace-file code span should become
|
||||
// file-viewer links instead of issue/external links.
|
||||
if (node.type === "link") {
|
||||
const ref = parseSingleInlineCodeFileRef(node);
|
||||
if (ref) {
|
||||
node.url = buildWorkspaceFileHref(ref);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Don't descend into other link-like or code blocks; only rewrite inlineCode within flowing text.
|
||||
if (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;
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseWorkspaceFileRef, formatWorkspaceFileRefDisplay } from "./workspace-file-parser";
|
||||
|
||||
describe("parseWorkspaceFileRef", () => {
|
||||
it("parses a simple workspace-relative path", () => {
|
||||
const ref = parseWorkspaceFileRef("ui/src/pages/IssueDetail.tsx");
|
||||
expect(ref).toEqual({
|
||||
path: "ui/src/pages/IssueDetail.tsx",
|
||||
resourceKind: "file",
|
||||
line: null,
|
||||
column: null,
|
||||
raw: "ui/src/pages/IssueDetail.tsx",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses path:line suffixes", () => {
|
||||
const ref = parseWorkspaceFileRef("ui/src/pages/IssueDetail.tsx:42");
|
||||
expect(ref?.path).toBe("ui/src/pages/IssueDetail.tsx");
|
||||
expect(ref?.line).toBe(42);
|
||||
expect(ref?.column).toBe(null);
|
||||
});
|
||||
|
||||
it("parses path:line:column suffixes", () => {
|
||||
const ref = parseWorkspaceFileRef("ui/src/pages/IssueDetail.tsx:42:3");
|
||||
expect(ref?.line).toBe(42);
|
||||
expect(ref?.column).toBe(3);
|
||||
});
|
||||
|
||||
it("parses #L42 style anchors", () => {
|
||||
const ref = parseWorkspaceFileRef("packages/shared/src/index.ts#L42");
|
||||
expect(ref?.path).toBe("packages/shared/src/index.ts");
|
||||
expect(ref?.line).toBe(42);
|
||||
});
|
||||
|
||||
it("parses #L42C3 style anchors", () => {
|
||||
const ref = parseWorkspaceFileRef("packages/shared/src/index.ts#L42C3");
|
||||
expect(ref?.line).toBe(42);
|
||||
expect(ref?.column).toBe(3);
|
||||
});
|
||||
|
||||
it("rejects absolute unix paths", () => {
|
||||
expect(parseWorkspaceFileRef("/etc/passwd")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects absolute windows paths", () => {
|
||||
expect(parseWorkspaceFileRef("C:\\Users\\foo\\file.txt")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects paths that escape with ..", () => {
|
||||
expect(parseWorkspaceFileRef("../secrets/file.env")).toBeNull();
|
||||
expect(parseWorkspaceFileRef("foo/../secrets.txt")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects home-relative paths", () => {
|
||||
expect(parseWorkspaceFileRef("~/.ssh/id_rsa")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects paths with null bytes or backslashes", () => {
|
||||
expect(parseWorkspaceFileRef("foo\\bar.txt")).toBeNull();
|
||||
expect(parseWorkspaceFileRef("foo\0bar.txt")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects plain prose words with an extension", () => {
|
||||
expect(parseWorkspaceFileRef("README.md")).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts paths without extension when deeply nested", () => {
|
||||
const ref = parseWorkspaceFileRef("scripts/setup/install");
|
||||
expect(ref?.path).toBe("scripts/setup/install");
|
||||
});
|
||||
|
||||
it("parses trailing-slash directory refs", () => {
|
||||
const ref = parseWorkspaceFileRef("content-os/cases/active/2026-06-06-pap-10199-bundled-skills/");
|
||||
expect(ref).toEqual({
|
||||
path: "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/",
|
||||
resourceKind: "directory",
|
||||
line: null,
|
||||
column: null,
|
||||
raw: "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects one-segment directory refs because they are too ambiguous to route", () => {
|
||||
expect(parseWorkspaceFileRef("sources/")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects unsafe directory refs", () => {
|
||||
expect(parseWorkspaceFileRef("../secrets/")).toBeNull();
|
||||
expect(parseWorkspaceFileRef("foo/../secrets/")).toBeNull();
|
||||
expect(parseWorkspaceFileRef("~/secrets/")).toBeNull();
|
||||
expect(parseWorkspaceFileRef("C:/Users/foo/")).toBeNull();
|
||||
expect(parseWorkspaceFileRef("foo\\bar/")).toBeNull();
|
||||
});
|
||||
|
||||
it("formats the display string with line and column", () => {
|
||||
expect(formatWorkspaceFileRefDisplay({ path: "a/b.ts", resourceKind: "file", line: 5, column: 10, raw: "a/b.ts:5:10" })).toBe("a/b.ts:5:10");
|
||||
expect(formatWorkspaceFileRefDisplay({ path: "a/b.ts", resourceKind: "file", line: 5, column: null, raw: "a/b.ts:5" })).toBe("a/b.ts:5");
|
||||
expect(formatWorkspaceFileRefDisplay({ path: "a/b.ts", resourceKind: "file", line: null, column: null, raw: "a/b.ts" })).toBe("a/b.ts");
|
||||
});
|
||||
|
||||
it("rejects line numbers that are zero or negative", () => {
|
||||
const ref = parseWorkspaceFileRef("ui/a.ts:0");
|
||||
expect(ref).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty or whitespace input", () => {
|
||||
expect(parseWorkspaceFileRef("")).toBeNull();
|
||||
expect(parseWorkspaceFileRef(" ")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
export interface ParsedWorkspaceFileRef {
|
||||
path: string;
|
||||
resourceKind?: "file" | "directory";
|
||||
line: number | null;
|
||||
column: number | null;
|
||||
projectId?: string | null;
|
||||
projectName?: string | null;
|
||||
workspaceId?: string | 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 WORKSPACE_DIRECTORY_REF_RE = /^([A-Za-z0-9_.\-+][A-Za-z0-9_./\-+]*\/)$/;
|
||||
|
||||
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 looksLikeWorkspacePath(input: string, opts: { allowTrailingSlash: boolean }): boolean {
|
||||
if (!input || input.length > 512) return false;
|
||||
if (input.includes("\\") || input.includes("\0")) return false;
|
||||
if (input.startsWith("/") || input.startsWith("~") || /^[A-Za-z]:/.test(input)) return false;
|
||||
if (input.includes("//")) return false;
|
||||
if (INVALID_PREFIXES.some((prefix) => input === prefix.slice(0, -1))) return false;
|
||||
const path = opts.allowTrailingSlash && input.endsWith("/") ? input.slice(0, -1) : input;
|
||||
if (!path || /^\.+$/.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 directoryMatch = trimmed.match(WORKSPACE_DIRECTORY_REF_RE);
|
||||
if (directoryMatch) {
|
||||
const [, rawPath] = directoryMatch;
|
||||
if (!rawPath || !looksLikeWorkspacePath(rawPath, { allowTrailingSlash: true })) return null;
|
||||
if (!rawPath.slice(0, -1).includes("/")) return null;
|
||||
return {
|
||||
path: rawPath,
|
||||
resourceKind: "directory",
|
||||
line: null,
|
||||
column: null,
|
||||
raw: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
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 (!looksLikeWorkspacePath(rawPath, { allowTrailingSlash: false })) 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,
|
||||
resourceKind: "file",
|
||||
line,
|
||||
column,
|
||||
raw: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatWorkspaceFileRefDisplay(ref: ParsedWorkspaceFileRef): string {
|
||||
const path = ref.line && ref.column
|
||||
? `${ref.path}:${ref.line}:${ref.column}`
|
||||
: ref.line
|
||||
? `${ref.path}:${ref.line}`
|
||||
: ref.path;
|
||||
return ref.projectName ? `${ref.projectName} / ${path}` : path;
|
||||
}
|
||||
Reference in New Issue
Block a user