Support line ranges in assistant file links (#1067)
This commit is contained in:
@@ -1,495 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createAssistantFileLinkResolver,
|
||||
getAssistantFileLinkToken,
|
||||
type AssistantFileLinkContext,
|
||||
type DirectorySuggestionResult,
|
||||
} from "./assistant-file-link-resolver";
|
||||
|
||||
const CONTEXT: AssistantFileLinkContext = {
|
||||
serverId: "server-1",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
};
|
||||
|
||||
function resolvedSuggestions(
|
||||
entries: DirectorySuggestionResult["entries"],
|
||||
): DirectorySuggestionResult {
|
||||
return { entries, error: null };
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe("assistant file link resolver", () => {
|
||||
it("dedupes in-flight prefetches and serves the click from cache", async () => {
|
||||
const suggestions = vi.fn(async () =>
|
||||
resolvedSuggestions([{ path: "src/dumm.md", kind: "file" }]),
|
||||
);
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const openExternalUrl = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: suggestions,
|
||||
openWorkspaceFile,
|
||||
openExternalUrl,
|
||||
});
|
||||
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
|
||||
|
||||
await Promise.all([
|
||||
resolver.prefetch({ context: CONTEXT, source }),
|
||||
resolver.prefetch({ context: CONTEXT, source }),
|
||||
]);
|
||||
const result = await resolver.open({ context: CONTEXT, source, disposition: "main" });
|
||||
|
||||
expect(suggestions).toHaveBeenCalledTimes(1);
|
||||
expect(suggestions).toHaveBeenCalledWith({
|
||||
query: "dumm.md",
|
||||
cwd: "/Users/test/project",
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
limit: 1,
|
||||
});
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
||||
{
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/project/src/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
"main",
|
||||
);
|
||||
expect(openExternalUrl).not.toHaveBeenCalled();
|
||||
expect(result.opened).toBe(true);
|
||||
});
|
||||
|
||||
it("click consumes an in-flight hover resolution", async () => {
|
||||
const deferred = createDeferred<DirectorySuggestionResult>();
|
||||
const suggestions = vi.fn(() => deferred.promise);
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: suggestions,
|
||||
openWorkspaceFile,
|
||||
openExternalUrl: vi.fn(),
|
||||
});
|
||||
const source = { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" };
|
||||
|
||||
const prefetch = resolver.prefetch({ context: CONTEXT, source });
|
||||
const opened = resolver.open({ context: CONTEXT, source, disposition: "main" });
|
||||
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
||||
|
||||
await prefetch;
|
||||
const result = await opened;
|
||||
|
||||
expect(suggestions).toHaveBeenCalledTimes(1);
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
||||
{
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/project/docs/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
"main",
|
||||
);
|
||||
expect(result.opened).toBe(true);
|
||||
});
|
||||
|
||||
it("retries a click after hover prefetch fails to query suggestions", async () => {
|
||||
const suggestions = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("daemon unavailable"))
|
||||
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const openExternalUrl = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: suggestions,
|
||||
openWorkspaceFile,
|
||||
openExternalUrl,
|
||||
});
|
||||
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
|
||||
|
||||
const prefetchResult = await resolver.prefetch({ context: CONTEXT, source });
|
||||
const openResult = await resolver.open({ context: CONTEXT, source, disposition: "main" });
|
||||
|
||||
expect(prefetchResult).toEqual({
|
||||
kind: "unresolvedFileCandidate",
|
||||
token: "dumm.md",
|
||||
});
|
||||
expect(suggestions).toHaveBeenCalledTimes(2);
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
||||
{
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/project/docs/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
"main",
|
||||
);
|
||||
expect(openExternalUrl).not.toHaveBeenCalled();
|
||||
expect(openResult.opened).toBe(true);
|
||||
});
|
||||
|
||||
it("does not cache unresolved candidates", async () => {
|
||||
const suggestions = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(resolvedSuggestions([]))
|
||||
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const openExternalUrl = vi.fn();
|
||||
const onUnresolvedFileCandidate = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: suggestions,
|
||||
openWorkspaceFile,
|
||||
openExternalUrl,
|
||||
onUnresolvedFileCandidate,
|
||||
});
|
||||
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
|
||||
|
||||
const first = await resolver.open({ context: CONTEXT, source, disposition: "main" });
|
||||
const second = await resolver.open({ context: CONTEXT, source, disposition: "main" });
|
||||
|
||||
expect(first).toEqual({
|
||||
kind: "unresolvedFileCandidate",
|
||||
token: "dumm.md",
|
||||
opened: false,
|
||||
});
|
||||
expect(second.opened).toBe(true);
|
||||
expect(suggestions).toHaveBeenCalledTimes(2);
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
||||
{
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/project/docs/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
"main",
|
||||
);
|
||||
expect(openExternalUrl).not.toHaveBeenCalled();
|
||||
expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keys cache entries by server, workspace, and token", async () => {
|
||||
const suggestions = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(resolvedSuggestions([{ path: "one/dumm.md", kind: "file" }]))
|
||||
.mockResolvedValueOnce(resolvedSuggestions([{ path: "two/dumm.md", kind: "file" }]));
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: suggestions,
|
||||
openWorkspaceFile,
|
||||
openExternalUrl: vi.fn(),
|
||||
});
|
||||
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
|
||||
|
||||
await resolver.open({ context: CONTEXT, source, disposition: "main" });
|
||||
await resolver.open({
|
||||
context: { serverId: "server-1", workspaceRoot: "/Users/test/other" },
|
||||
source,
|
||||
disposition: "main",
|
||||
});
|
||||
|
||||
expect(suggestions).toHaveBeenCalledTimes(2);
|
||||
expect(openWorkspaceFile).toHaveBeenLastCalledWith(
|
||||
{
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/other/two/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
"main",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not apply stale async results after the active context changes", async () => {
|
||||
const deferred = createDeferred<DirectorySuggestionResult>();
|
||||
let isCurrent = true;
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: vi.fn(() => deferred.promise),
|
||||
openWorkspaceFile,
|
||||
openExternalUrl: vi.fn(),
|
||||
isCurrentContext: () => isCurrent,
|
||||
});
|
||||
|
||||
const opened = resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" },
|
||||
disposition: "main",
|
||||
});
|
||||
isCurrent = false;
|
||||
deferred.resolve(resolvedSuggestions([{ path: "dumm.md", kind: "file" }]));
|
||||
const result = await opened;
|
||||
|
||||
expect(openWorkspaceFile).not.toHaveBeenCalled();
|
||||
expect(result.opened).toBe(false);
|
||||
expect(result.kind).toBe("file");
|
||||
});
|
||||
|
||||
it("opens direct workspace file links without querying suggestions", async () => {
|
||||
const suggestions = vi.fn(async () => resolvedSuggestions([]));
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: suggestions,
|
||||
openWorkspaceFile,
|
||||
openExternalUrl: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "src/components/message.tsx#L33" },
|
||||
disposition: "main",
|
||||
});
|
||||
|
||||
expect(suggestions).not.toHaveBeenCalled();
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
||||
{
|
||||
raw: "src/components/message.tsx#L33",
|
||||
path: "/Users/test/project/src/components/message.tsx",
|
||||
lineStart: 33,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
"main",
|
||||
);
|
||||
expect(result.opened).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves inline-code basename line refs through directory suggestions", async () => {
|
||||
const suggestions = vi.fn(async () =>
|
||||
resolvedSuggestions([
|
||||
{ path: "packages/server/src/server/workspace-git-service.ts", kind: "file" },
|
||||
]),
|
||||
);
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: suggestions,
|
||||
openWorkspaceFile,
|
||||
openExternalUrl: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await resolver.open({
|
||||
context: { ...CONTEXT, workspaceRoot: "/Users/test/project" },
|
||||
source: {
|
||||
href: "workspace-git-service.ts:1553",
|
||||
text: "workspace-git-service.ts:1553",
|
||||
sourceType: "inline-code",
|
||||
},
|
||||
disposition: "main",
|
||||
});
|
||||
|
||||
expect(suggestions).toHaveBeenCalledWith({
|
||||
query: "workspace-git-service.ts",
|
||||
cwd: "/Users/test/project",
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
limit: 1,
|
||||
});
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
||||
{
|
||||
raw: "workspace-git-service.ts:1553",
|
||||
path: "/Users/test/project/packages/server/src/server/workspace-git-service.ts",
|
||||
lineStart: 1553,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
"main",
|
||||
);
|
||||
expect(result.opened).toBe(true);
|
||||
});
|
||||
|
||||
it("passes side open disposition to workspace file links", async () => {
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
|
||||
openWorkspaceFile,
|
||||
openExternalUrl: vi.fn(),
|
||||
});
|
||||
|
||||
await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "src/components/message.tsx#L33" },
|
||||
disposition: "side",
|
||||
});
|
||||
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
||||
{
|
||||
raw: "src/components/message.tsx#L33",
|
||||
path: "/Users/test/project/src/components/message.tsx",
|
||||
lineStart: 33,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
"side",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps explicit external URLs external", async () => {
|
||||
const openExternalUrl = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
|
||||
openWorkspaceFile: vi.fn(),
|
||||
openExternalUrl,
|
||||
});
|
||||
|
||||
const result = await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "http://dumm.md", text: "dumm.md" },
|
||||
disposition: "main",
|
||||
});
|
||||
|
||||
expect(openExternalUrl).toHaveBeenCalledWith("http://dumm.md");
|
||||
expect(result).toEqual({
|
||||
kind: "external",
|
||||
url: "http://dumm.md",
|
||||
opened: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps auto-linkified normal domains external", async () => {
|
||||
const suggestions = vi.fn(async () => resolvedSuggestions([]));
|
||||
const openExternalUrl = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: suggestions,
|
||||
openWorkspaceFile: vi.fn(),
|
||||
openExternalUrl,
|
||||
});
|
||||
|
||||
const result = await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "http://google.com", text: "google.com", markup: "linkify" },
|
||||
disposition: "main",
|
||||
});
|
||||
|
||||
expect(suggestions).not.toHaveBeenCalled();
|
||||
expect(openExternalUrl).toHaveBeenCalledWith("http://google.com");
|
||||
expect(result).toEqual({
|
||||
kind: "external",
|
||||
url: "http://google.com",
|
||||
opened: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps auto-linkified normal domain paths external", async () => {
|
||||
const suggestions = vi.fn(async () => resolvedSuggestions([]));
|
||||
const openExternalUrl = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: suggestions,
|
||||
openWorkspaceFile: vi.fn(),
|
||||
openExternalUrl,
|
||||
});
|
||||
|
||||
const result = await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "http://openai.com/path", text: "openai.com/path", sourceInfo: "auto" },
|
||||
disposition: "main",
|
||||
});
|
||||
|
||||
expect(suggestions).not.toHaveBeenCalled();
|
||||
expect(openExternalUrl).toHaveBeenCalledWith("http://openai.com/path");
|
||||
expect(result).toEqual({
|
||||
kind: "external",
|
||||
url: "http://openai.com/path",
|
||||
opened: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not open unresolved linkified markdown filenames in the browser", async () => {
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const openExternalUrl = vi.fn();
|
||||
const onUnresolvedFileCandidate = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
|
||||
openWorkspaceFile,
|
||||
openExternalUrl,
|
||||
onUnresolvedFileCandidate,
|
||||
});
|
||||
|
||||
const prefetchResult = await resolver.prefetch({
|
||||
context: CONTEXT,
|
||||
source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" },
|
||||
});
|
||||
const result = await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" },
|
||||
disposition: "main",
|
||||
});
|
||||
|
||||
expect(openWorkspaceFile).not.toHaveBeenCalled();
|
||||
expect(openExternalUrl).not.toHaveBeenCalled();
|
||||
expect(prefetchResult).toEqual({
|
||||
kind: "unresolvedFileCandidate",
|
||||
token: "dumm.md",
|
||||
});
|
||||
expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1);
|
||||
expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md");
|
||||
expect(result).toEqual({
|
||||
kind: "unresolvedFileCandidate",
|
||||
token: "dumm.md",
|
||||
opened: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps failed ambiguous resolution out of the browser", async () => {
|
||||
const openExternalUrl = vi.fn();
|
||||
const onUnresolvedFileCandidate = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: vi.fn(async () => {
|
||||
throw new Error("daemon unavailable");
|
||||
}),
|
||||
openWorkspaceFile: vi.fn(),
|
||||
openExternalUrl,
|
||||
onUnresolvedFileCandidate,
|
||||
});
|
||||
|
||||
const result = await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" },
|
||||
disposition: "main",
|
||||
});
|
||||
|
||||
expect(openExternalUrl).not.toHaveBeenCalled();
|
||||
expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md");
|
||||
expect(result).toEqual({
|
||||
kind: "unresolvedFileCandidate",
|
||||
token: "dumm.md",
|
||||
opened: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => {
|
||||
expect(
|
||||
getAssistantFileLinkToken({
|
||||
href: "http://dumm.md",
|
||||
text: "dumm.md",
|
||||
markup: "linkify",
|
||||
sourceInfo: "auto",
|
||||
}),
|
||||
).toBe("dumm.md");
|
||||
expect(
|
||||
getAssistantFileLinkToken({
|
||||
href: "http://google.com",
|
||||
text: "google.com",
|
||||
markup: "linkify",
|
||||
sourceInfo: "auto",
|
||||
}),
|
||||
).toBe("http://google.com");
|
||||
expect(
|
||||
getAssistantFileLinkToken({
|
||||
href: "http://dumm.md",
|
||||
text: "dumm.md",
|
||||
markup: "",
|
||||
sourceInfo: "",
|
||||
}),
|
||||
).toBe("http://dumm.md");
|
||||
expect(
|
||||
getAssistantFileLinkToken({
|
||||
href: "workspace-git-service.ts:1553",
|
||||
text: "workspace-git-service.ts:1553",
|
||||
sourceType: "inline-code",
|
||||
}),
|
||||
).toBe("workspace-git-service.ts:1553");
|
||||
});
|
||||
});
|
||||
@@ -1,265 +0,0 @@
|
||||
import {
|
||||
classifyAssistantFileLink,
|
||||
isFileLookingAssistantToken,
|
||||
type AssistantFileLinkClassification,
|
||||
type InlinePathTarget,
|
||||
} from "./inline-path";
|
||||
import type { OpenFileDisposition } from "./workspace-file-open";
|
||||
|
||||
export interface AssistantFileLinkContext {
|
||||
serverId?: string;
|
||||
workspaceRoot?: string;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkSource {
|
||||
href: string;
|
||||
text?: string;
|
||||
markup?: string;
|
||||
sourceInfo?: string;
|
||||
sourceType?: "inline-code";
|
||||
}
|
||||
|
||||
export interface DirectorySuggestionEntry {
|
||||
path: string;
|
||||
kind: "file" | "directory";
|
||||
}
|
||||
|
||||
export interface DirectorySuggestionResult {
|
||||
entries: DirectorySuggestionEntry[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkResolverDependencies {
|
||||
getDirectorySuggestions: (input: {
|
||||
query: string;
|
||||
cwd: string;
|
||||
includeFiles: true;
|
||||
includeDirectories: false;
|
||||
limit: number;
|
||||
}) => Promise<DirectorySuggestionResult>;
|
||||
openWorkspaceFile: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
|
||||
openExternalUrl: (url: string) => void | Promise<void>;
|
||||
onUnresolvedFileCandidate?: (token: string) => void;
|
||||
isCurrentContext?: (context: AssistantFileLinkContext) => boolean;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkResolver {
|
||||
prefetch(input: AssistantFileLinkPrefetchInput): Promise<ResolvedAssistantFileLink>;
|
||||
open(input: AssistantFileLinkOpenInput): Promise<AssistantFileLinkOpenResult>;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkPrefetchInput {
|
||||
context: AssistantFileLinkContext;
|
||||
source: AssistantFileLinkSource;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkOpenInput extends AssistantFileLinkPrefetchInput {
|
||||
disposition: OpenFileDisposition;
|
||||
}
|
||||
|
||||
export type ResolvedAssistantFileLink =
|
||||
| {
|
||||
kind: "external";
|
||||
url: string;
|
||||
}
|
||||
| {
|
||||
kind: "file";
|
||||
target: InlinePathTarget;
|
||||
}
|
||||
| {
|
||||
kind: "unresolvedFileCandidate";
|
||||
token: string;
|
||||
}
|
||||
| {
|
||||
kind: "ignored";
|
||||
};
|
||||
|
||||
export type AssistantFileLinkOpenResult = ResolvedAssistantFileLink & {
|
||||
opened: boolean;
|
||||
};
|
||||
|
||||
type CachedAssistantFileLink = Exclude<ResolvedAssistantFileLink, { kind: "external" }>;
|
||||
|
||||
interface ParsedAssistantFileLinkInteraction {
|
||||
token: string;
|
||||
classification: AssistantFileLinkClassification;
|
||||
}
|
||||
|
||||
export function createAssistantFileLinkResolver(
|
||||
dependencies: AssistantFileLinkResolverDependencies,
|
||||
): AssistantFileLinkResolver {
|
||||
const cache = new Map<string, CachedAssistantFileLink>();
|
||||
const inFlight = new Map<string, Promise<CachedAssistantFileLink>>();
|
||||
|
||||
async function resolve(
|
||||
input: AssistantFileLinkPrefetchInput,
|
||||
): Promise<ResolvedAssistantFileLink> {
|
||||
const parsed = parseInteraction(input);
|
||||
if (!parsed) {
|
||||
return { kind: "ignored" };
|
||||
}
|
||||
|
||||
if (parsed.classification.kind === "external") {
|
||||
return { kind: "external", url: parsed.classification.raw };
|
||||
}
|
||||
|
||||
if (parsed.classification.kind === "directFile") {
|
||||
return { kind: "file", target: parsed.classification.target };
|
||||
}
|
||||
|
||||
const key = getResolutionKey(input.context, parsed.token);
|
||||
const cached = cache.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const active = inFlight.get(key);
|
||||
if (active) {
|
||||
return active;
|
||||
}
|
||||
|
||||
const request = resolveAmbiguousCandidate({
|
||||
context: input.context,
|
||||
token: parsed.token,
|
||||
target: parsed.classification.target,
|
||||
getDirectorySuggestions: dependencies.getDirectorySuggestions,
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.kind === "file") {
|
||||
cache.set(key, result);
|
||||
}
|
||||
inFlight.delete(key);
|
||||
return result;
|
||||
})
|
||||
.catch((): CachedAssistantFileLink => {
|
||||
inFlight.delete(key);
|
||||
return { kind: "unresolvedFileCandidate", token: parsed.token };
|
||||
});
|
||||
|
||||
inFlight.set(key, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
return {
|
||||
prefetch(input) {
|
||||
return resolve(input);
|
||||
},
|
||||
async open(input) {
|
||||
const resolved = await resolve(input);
|
||||
if (!canApplyResult(input.context, dependencies.isCurrentContext)) {
|
||||
return { ...resolved, opened: false };
|
||||
}
|
||||
|
||||
if (resolved.kind === "file") {
|
||||
dependencies.openWorkspaceFile(resolved.target, input.disposition);
|
||||
return { ...resolved, opened: true };
|
||||
}
|
||||
|
||||
if (resolved.kind === "external") {
|
||||
await dependencies.openExternalUrl(resolved.url);
|
||||
return { ...resolved, opened: true };
|
||||
}
|
||||
|
||||
if (resolved.kind === "unresolvedFileCandidate") {
|
||||
dependencies.onUnresolvedFileCandidate?.(resolved.token);
|
||||
}
|
||||
|
||||
return { ...resolved, opened: false };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getAssistantFileLinkToken(source: AssistantFileLinkSource): string {
|
||||
if (isLinkifiedSource(source) || source.sourceType === "inline-code") {
|
||||
const text = source.text?.trim();
|
||||
if (text && isFileLookingAssistantToken(text)) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
return source.href;
|
||||
}
|
||||
|
||||
function parseInteraction(
|
||||
input: AssistantFileLinkPrefetchInput,
|
||||
): ParsedAssistantFileLinkInteraction | null {
|
||||
const token = getAssistantFileLinkToken(input.source).trim();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const classification = classifyAssistantFileLink(token, {
|
||||
workspaceRoot: input.context.workspaceRoot,
|
||||
});
|
||||
if (!classification) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { token, classification };
|
||||
}
|
||||
|
||||
async function resolveAmbiguousCandidate(input: {
|
||||
context: AssistantFileLinkContext;
|
||||
token: string;
|
||||
target: InlinePathTarget;
|
||||
getDirectorySuggestions: AssistantFileLinkResolverDependencies["getDirectorySuggestions"];
|
||||
}): Promise<CachedAssistantFileLink> {
|
||||
const workspaceRoot = input.context.workspaceRoot?.trim();
|
||||
if (!workspaceRoot) {
|
||||
return { kind: "unresolvedFileCandidate", token: input.token };
|
||||
}
|
||||
|
||||
const query = getAmbiguousSuggestionQuery(input.target, workspaceRoot);
|
||||
const suggestions = await input.getDirectorySuggestions({
|
||||
query,
|
||||
cwd: workspaceRoot,
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
limit: 1,
|
||||
});
|
||||
const match = suggestions.entries.find((entry) => entry.kind === "file");
|
||||
if (!match || suggestions.error) {
|
||||
return { kind: "unresolvedFileCandidate", token: input.token };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "file",
|
||||
target: {
|
||||
...input.target,
|
||||
path: joinWorkspacePath(workspaceRoot, match.path),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getAmbiguousSuggestionQuery(target: InlinePathTarget, workspaceRoot: string): string {
|
||||
const normalizedRoot = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const normalizedPath = target.path.replace(/\\/g, "/");
|
||||
const prefix = `${normalizedRoot}/`;
|
||||
if (normalizedPath.startsWith(prefix)) {
|
||||
return normalizedPath.slice(prefix.length);
|
||||
}
|
||||
|
||||
const lastSlash = normalizedPath.lastIndexOf("/");
|
||||
return lastSlash >= 0 ? normalizedPath.slice(lastSlash + 1) : normalizedPath;
|
||||
}
|
||||
|
||||
function getResolutionKey(context: AssistantFileLinkContext, token: string): string {
|
||||
return [context.serverId ?? "", context.workspaceRoot ?? "", token].join("\0");
|
||||
}
|
||||
|
||||
function isLinkifiedSource(source: AssistantFileLinkSource): boolean {
|
||||
return source.markup === "linkify" || source.sourceInfo === "auto";
|
||||
}
|
||||
|
||||
function canApplyResult(
|
||||
context: AssistantFileLinkContext,
|
||||
isCurrentContext: AssistantFileLinkResolverDependencies["isCurrentContext"],
|
||||
): boolean {
|
||||
return isCurrentContext ? isCurrentContext(context) : true;
|
||||
}
|
||||
|
||||
function joinWorkspacePath(workspaceRoot: string, relativePath: string): string {
|
||||
const root = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const child = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
return root ? `${root}/${child}` : child;
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
classifyAssistantFileLink,
|
||||
normalizeInlinePathTarget,
|
||||
parseAssistantFileLink,
|
||||
parseFileProtocolUrl,
|
||||
parseInlinePathToken,
|
||||
} from "./inline-path";
|
||||
|
||||
describe("parseInlinePathToken", () => {
|
||||
it("returns null for plain paths without a line number", () => {
|
||||
expect(parseInlinePathToken("src/app.ts")).toBeNull();
|
||||
expect(parseInlinePathToken("README.md")).toBeNull();
|
||||
});
|
||||
|
||||
it("parses filename:line", () => {
|
||||
expect(parseInlinePathToken("src/app.ts:12")).toEqual({
|
||||
raw: "src/app.ts:12",
|
||||
path: "src/app.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses filename:lineStart-lineEnd", () => {
|
||||
expect(parseInlinePathToken("src/app.ts:12-20")).toEqual({
|
||||
raw: "src/app.ts:12-20",
|
||||
path: "src/app.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects range-only :line tokens", () => {
|
||||
expect(parseInlinePathToken(":12")).toBeNull();
|
||||
expect(parseInlinePathToken(":12-20")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFileProtocolUrl", () => {
|
||||
it("parses file URLs with line fragments", () => {
|
||||
expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L81")).toEqual({
|
||||
raw: "file:///Users/test/project/src/app.tsx#L81",
|
||||
path: "/Users/test/project/src/app.tsx",
|
||||
lineStart: 81,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses file URLs without line fragments", () => {
|
||||
expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx")).toEqual({
|
||||
raw: "file:///Users/test/project/src/app.tsx",
|
||||
path: "/Users/test/project/src/app.tsx",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses windows file URLs and line ranges", () => {
|
||||
expect(parseFileProtocolUrl("file:///C:/Users/test/project/src/app.tsx#L12-L20")).toEqual({
|
||||
raw: "file:///C:/Users/test/project/src/app.tsx#L12-L20",
|
||||
path: "C:/Users/test/project/src/app.tsx",
|
||||
lineStart: 12,
|
||||
lineEnd: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-file URLs and invalid ranges", () => {
|
||||
expect(parseFileProtocolUrl("https://example.com/test.ts#L10")).toBeNull();
|
||||
expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L20-L12")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyAssistantFileLink", () => {
|
||||
it("keeps explicit external URLs out of file parsing", () => {
|
||||
expect(
|
||||
classifyAssistantFileLink("http://dumm.md", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "external",
|
||||
raw: "http://dumm.md",
|
||||
});
|
||||
expect(classifyAssistantFileLink("mailto:test@example.com")).toEqual({
|
||||
kind: "external",
|
||||
raw: "mailto:test@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies bare workspace candidates separately from direct relative files", () => {
|
||||
expect(
|
||||
classifyAssistantFileLink("dumm.md", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "ambiguousFileCandidate",
|
||||
target: {
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/project/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
classifyAssistantFileLink("message-renderer.tsx", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "ambiguousFileCandidate",
|
||||
target: {
|
||||
raw: "message-renderer.tsx",
|
||||
path: "/Users/test/project/message-renderer.tsx",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
classifyAssistantFileLink("src/components/message.tsx#L33", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "directFile",
|
||||
target: {
|
||||
raw: "src/components/message.tsx#L33",
|
||||
path: "/Users/test/project/src/components/message.tsx",
|
||||
lineStart: 33,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not classify normal bare domains as file candidates", () => {
|
||||
expect(
|
||||
classifyAssistantFileLink("google.com", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
classifyAssistantFileLink("example.com", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
classifyAssistantFileLink("openai.com/path", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAssistantFileLink", () => {
|
||||
it("resolves bare markdown filenames against the active workspace", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("dumm.md", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/project/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves bare source filenames with line suffixes against the active workspace", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("file.ts:12", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "file.ts:12",
|
||||
path: "/Users/test/project/file.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects bare domains and domain-like paths", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("google.com", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseAssistantFileLink("google.com:80", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseAssistantFileLink("openai.com/path", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves relative paths against the active workspace", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("src/components/message.tsx#L33", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "src/components/message.tsx#L33",
|
||||
path: "/Users/test/project/src/components/message.tsx",
|
||||
lineStart: 33,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses absolute POSIX hrefs inside the active workspace", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("/Users/test/project/src/app.tsx#L33", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "/Users/test/project/src/app.tsx#L33",
|
||||
path: "/Users/test/project/src/app.tsx",
|
||||
lineStart: 33,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses absolute POSIX hrefs with VS Code-style line suffixes inside the active workspace", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("/Users/test/project/src/app.tsx:33", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "/Users/test/project/src/app.tsx:33",
|
||||
path: "/Users/test/project/src/app.tsx",
|
||||
lineStart: 33,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses absolute Windows hrefs inside the active workspace", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("C:/repo/src/app.tsx#L12-L20", {
|
||||
workspaceRoot: "C:/repo",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "C:/repo/src/app.tsx#L12-L20",
|
||||
path: "C:/repo/src/app.tsx",
|
||||
lineStart: 12,
|
||||
lineEnd: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses absolute Windows hrefs with VS Code-style line suffixes inside the active workspace", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("C:/repo/src/app.tsx:12-20", {
|
||||
workspaceRoot: "C:/repo",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "C:/repo/src/app.tsx:12-20",
|
||||
path: "C:/repo/src/app.tsx",
|
||||
lineStart: 12,
|
||||
lineEnd: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows file URLs even when they are outside the workspace root", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("file:///tmp/outside.txt", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "file:///tmp/outside.txt",
|
||||
path: "/tmp/outside.txt",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects absolute hrefs outside the workspace root", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("/tmp/outside.txt", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects external URLs", () => {
|
||||
expect(parseAssistantFileLink("https://example.com/Users/test/project/src/app.tsx")).toBeNull();
|
||||
expect(
|
||||
parseAssistantFileLink("http://dumm.md", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects invalid line fragments", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("/Users/test/project/src/app.tsx#L20-L12", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeInlinePathTarget", () => {
|
||||
it("keeps relative file paths as file targets", () => {
|
||||
expect(normalizeInlinePathTarget("packages/app/src/components/message.tsx")).toEqual({
|
||||
directory: "packages/app/src/components",
|
||||
file: "packages/app/src/components/message.tsx",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves absolute paths under cwd back to workspace-relative paths", () => {
|
||||
expect(
|
||||
normalizeInlinePathTarget(
|
||||
"/Users/test/project/packages/app/src/components/message.tsx",
|
||||
"/Users/test/project",
|
||||
),
|
||||
).toEqual({
|
||||
directory: "packages/app/src/components",
|
||||
file: "packages/app/src/components/message.tsx",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps absolute paths outside cwd as absolute file targets", () => {
|
||||
expect(normalizeInlinePathTarget("/tmp/message.tsx", "/Users/test/project")).toEqual({
|
||||
directory: "/tmp",
|
||||
file: "/tmp/message.tsx",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats cwd itself as the workspace root directory", () => {
|
||||
expect(normalizeInlinePathTarget("/Users/test/project", "/Users/test/project")).toEqual({
|
||||
directory: ".",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps trailing-slash paths as directories", () => {
|
||||
expect(
|
||||
normalizeInlinePathTarget("/Users/test/project/packages/app/", "/Users/test/project"),
|
||||
).toEqual({
|
||||
directory: "packages/app",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,691 +0,0 @@
|
||||
import { isAbsolutePath } from "./path";
|
||||
|
||||
export interface InlinePathTarget {
|
||||
raw: string;
|
||||
path: string;
|
||||
lineStart?: number;
|
||||
lineEnd?: number;
|
||||
}
|
||||
|
||||
const FILE_PROTOCOL = "file:";
|
||||
const INLINE_LINE_FRAGMENT = /^L([0-9]+)(?:-L?([0-9]+))?$/i;
|
||||
const ASSISTANT_FILE_EXTENSIONS = new Set([
|
||||
"astro",
|
||||
"bash",
|
||||
"c",
|
||||
"cc",
|
||||
"cjs",
|
||||
"cpp",
|
||||
"cs",
|
||||
"css",
|
||||
"cts",
|
||||
"cxx",
|
||||
"env",
|
||||
"fish",
|
||||
"go",
|
||||
"gql",
|
||||
"gradle",
|
||||
"graphql",
|
||||
"h",
|
||||
"hpp",
|
||||
"htm",
|
||||
"html",
|
||||
"ini",
|
||||
"java",
|
||||
"js",
|
||||
"json",
|
||||
"jsonc",
|
||||
"jsx",
|
||||
"kt",
|
||||
"kts",
|
||||
"less",
|
||||
"lock",
|
||||
"lua",
|
||||
"md",
|
||||
"mdx",
|
||||
"mjs",
|
||||
"mts",
|
||||
"php",
|
||||
"proto",
|
||||
"py",
|
||||
"rb",
|
||||
"rs",
|
||||
"sass",
|
||||
"scss",
|
||||
"sh",
|
||||
"sql",
|
||||
"svelte",
|
||||
"swift",
|
||||
"toml",
|
||||
"ts",
|
||||
"tsx",
|
||||
"txt",
|
||||
"vue",
|
||||
"xml",
|
||||
"yaml",
|
||||
"yml",
|
||||
"zsh",
|
||||
]);
|
||||
|
||||
export interface AssistantHrefParseOptions {
|
||||
workspaceRoot?: string;
|
||||
}
|
||||
|
||||
export type AssistantFileLinkClassification =
|
||||
| {
|
||||
kind: "external";
|
||||
raw: string;
|
||||
}
|
||||
| {
|
||||
kind: "directFile";
|
||||
target: InlinePathTarget;
|
||||
}
|
||||
| {
|
||||
kind: "ambiguousFileCandidate";
|
||||
target: InlinePathTarget;
|
||||
};
|
||||
|
||||
export interface NormalizedInlinePathTarget {
|
||||
directory: string;
|
||||
file?: string;
|
||||
}
|
||||
|
||||
function normalizePathToken(value: string): string | null {
|
||||
const trimmed = value
|
||||
.trim()
|
||||
.replace(/^['"`]/, "")
|
||||
.replace(/['"`]$/, "");
|
||||
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function parseLineFragment(value: string): Pick<InlinePathTarget, "lineStart" | "lineEnd"> | null {
|
||||
const rawFragment = value.startsWith("#") ? value.slice(1) : value;
|
||||
if (!rawFragment) {
|
||||
return { lineStart: undefined, lineEnd: undefined };
|
||||
}
|
||||
|
||||
const lineMatch = rawFragment.match(INLINE_LINE_FRAGMENT);
|
||||
const lineStart = lineMatch?.[1] ? parseInt(lineMatch[1], 10) : undefined;
|
||||
const lineEnd = lineMatch?.[2] ? parseInt(lineMatch[2], 10) : undefined;
|
||||
|
||||
if (
|
||||
(lineStart !== undefined && (!Number.isFinite(lineStart) || lineStart <= 0)) ||
|
||||
(lineEnd !== undefined && (!Number.isFinite(lineEnd) || lineEnd <= 0)) ||
|
||||
(lineStart !== undefined && lineEnd !== undefined && lineEnd < lineStart)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { lineStart, lineEnd };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict VSCode-style markers only.
|
||||
*
|
||||
* Supported:
|
||||
* - `filename:linenumber`
|
||||
* - `filename:lineStart-lineEnd`
|
||||
*
|
||||
* Not supported (by design):
|
||||
* - plain `filename` (no line)
|
||||
* - `:linenumber` (range-only)
|
||||
*/
|
||||
export function parseInlinePathToken(value: string): InlinePathTarget | null {
|
||||
const rawValue = value ?? "";
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = trimmed.match(/^(.+?):([0-9]+)(?:-([0-9]+))?$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const basePathRaw = match[1]?.trim();
|
||||
if (!basePathRaw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Avoid accidentally treating URLs as file paths.
|
||||
if (basePathRaw.includes("://")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedPath = normalizePathToken(basePathRaw);
|
||||
if (!normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lineStart = parseInt(match[2], 10);
|
||||
if (!Number.isFinite(lineStart) || lineStart <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lineEnd = match[3] ? parseInt(match[3], 10) : undefined;
|
||||
if (lineEnd !== undefined) {
|
||||
if (!Number.isFinite(lineEnd) || lineEnd <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (lineEnd < lineStart) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
raw: rawValue,
|
||||
path: normalizedPath,
|
||||
lineStart,
|
||||
lineEnd,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseFileProtocolUrl(value: string): InlinePathTarget | null {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parsedUrl.protocol !== FILE_PROTOCOL) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedPath = normalizeFileUrlPath(parsedUrl.pathname);
|
||||
if (!normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = parseLineFragment(parsedUrl.hash);
|
||||
if (!lines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
raw: value,
|
||||
path: normalizedPath,
|
||||
...lines,
|
||||
};
|
||||
}
|
||||
|
||||
function parseAssistantInlinePathLink(
|
||||
value: string,
|
||||
options: AssistantHrefParseOptions,
|
||||
): InlinePathTarget | null {
|
||||
const inlinePathTarget = parseInlinePathToken(value);
|
||||
if (!inlinePathTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedPath = normalizePathToken(inlinePathTarget.path);
|
||||
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isAllowedAbsolutePath(normalizedPath, options.workspaceRoot)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...inlinePathTarget,
|
||||
path: normalizedPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function classifyAssistantFileLink(
|
||||
value: string,
|
||||
options: AssistantHrefParseOptions = {},
|
||||
): AssistantFileLinkClassification | null {
|
||||
const raw = value ?? "";
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isExternalHref(trimmed)) {
|
||||
return {
|
||||
kind: "external",
|
||||
raw,
|
||||
};
|
||||
}
|
||||
|
||||
const target = parseAssistantFileLink(trimmed, options);
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isAmbiguousWorkspaceCandidate(trimmed, target, options.workspaceRoot)) {
|
||||
return {
|
||||
kind: "ambiguousFileCandidate",
|
||||
target,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "directFile",
|
||||
target,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAssistantFileLink(
|
||||
value: string,
|
||||
options: AssistantHrefParseOptions = {},
|
||||
): InlinePathTarget | null {
|
||||
const fileUrlTarget = parseFileProtocolUrl(value);
|
||||
if (fileUrlTarget) {
|
||||
return fileUrlTarget;
|
||||
}
|
||||
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isExternalHref(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inlinePathTarget = parseAssistantInlinePathLink(trimmed, {
|
||||
workspaceRoot: options.workspaceRoot,
|
||||
});
|
||||
if (inlinePathTarget) {
|
||||
return inlinePathTarget;
|
||||
}
|
||||
|
||||
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
|
||||
if (windowsPathMatch) {
|
||||
const normalizedPath = normalizePathToken(windowsPathMatch[1] ?? "");
|
||||
if (!normalizedPath || !isAllowedAbsolutePath(normalizedPath, options.workspaceRoot)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = parseLineFragment(windowsPathMatch[2] ?? "");
|
||||
if (!lines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
raw: value,
|
||||
path: normalizedPath,
|
||||
...lines,
|
||||
};
|
||||
}
|
||||
|
||||
const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, {
|
||||
workspaceRoot: options.workspaceRoot,
|
||||
});
|
||||
if (relativeTarget) {
|
||||
return relativeTarget;
|
||||
}
|
||||
|
||||
if (!isAbsolutePath(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(trimmed, "http://paseo.invalid");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedPath = normalizePathToken(decodeURIComponent(parsedUrl.pathname));
|
||||
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isAllowedAbsolutePath(normalizedPath, options.workspaceRoot)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = parseLineFragment(parsedUrl.hash);
|
||||
if (!lines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
raw: value,
|
||||
path: normalizedPath,
|
||||
...lines,
|
||||
};
|
||||
}
|
||||
|
||||
export function isFileLookingAssistantToken(value: string): boolean {
|
||||
const normalized = normalizePathToken(value);
|
||||
if (!normalized || normalized.includes("?") || normalized.includes("://")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const path = getHeuristicLocalPath(normalized);
|
||||
if (!path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isPlausibleAssistantLocalPath(path);
|
||||
}
|
||||
|
||||
function parseWorkspaceRelativeFileLink(
|
||||
value: string,
|
||||
options: AssistantHrefParseOptions,
|
||||
): InlinePathTarget | null {
|
||||
const workspaceRoot = normalizePathInput(options.workspaceRoot);
|
||||
if (!workspaceRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = parseLocalPathParts(value);
|
||||
if (!parsed || isAbsolutePath(parsed.path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedPath = resolveRelativePathUnderRoot(parsed.path, workspaceRoot);
|
||||
if (!normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
raw: value,
|
||||
path: normalizedPath,
|
||||
...parsed.lines,
|
||||
};
|
||||
}
|
||||
|
||||
function parseLocalPathParts(
|
||||
value: string,
|
||||
): { path: string; lines: Pick<InlinePathTarget, "lineStart" | "lineEnd"> } | null {
|
||||
const normalized = normalizePathToken(value);
|
||||
if (!normalized || normalized.includes("?")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hashIndex = normalized.indexOf("#");
|
||||
const beforeHash = hashIndex >= 0 ? normalized.slice(0, hashIndex) : normalized;
|
||||
const hash = hashIndex >= 0 ? normalized.slice(hashIndex) : "";
|
||||
const fragmentLines = parseLineFragment(hash);
|
||||
if (!fragmentLines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inlinePathTarget = parseInlinePathToken(beforeHash);
|
||||
if (inlinePathTarget) {
|
||||
if (!isPlausibleAssistantLocalPath(inlinePathTarget.path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path: inlinePathTarget.path,
|
||||
lines: {
|
||||
lineStart: inlinePathTarget.lineStart,
|
||||
lineEnd: inlinePathTarget.lineEnd,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!beforeHash || beforeHash.includes(":")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isPlausibleAssistantLocalPath(beforeHash)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path: beforeHash,
|
||||
lines: fragmentLines,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeInlinePathTarget(
|
||||
rawPath: string,
|
||||
cwd?: string,
|
||||
): NormalizedInlinePathTarget | null {
|
||||
if (!rawPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedInput = normalizePathInput(rawPath);
|
||||
if (!normalizedInput) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let normalized = normalizedInput;
|
||||
const cwdRelative = resolvePathAgainstCwd(normalized, cwd);
|
||||
if (cwdRelative) {
|
||||
normalized = cwdRelative;
|
||||
}
|
||||
|
||||
if (normalized.startsWith("./")) {
|
||||
normalized = normalized.slice(2) || ".";
|
||||
}
|
||||
|
||||
if (!normalized.length) {
|
||||
normalized = ".";
|
||||
}
|
||||
|
||||
if (normalized === ".") {
|
||||
return { directory: "." };
|
||||
}
|
||||
|
||||
if (normalized.endsWith("/")) {
|
||||
const dir = normalized.replace(/\/+$/, "");
|
||||
return { directory: dir.length > 0 ? dir : "." };
|
||||
}
|
||||
|
||||
const lastSlash = normalized.lastIndexOf("/");
|
||||
const directory = lastSlash >= 0 ? normalized.slice(0, lastSlash) : ".";
|
||||
|
||||
return {
|
||||
directory: directory.length > 0 ? directory : ".",
|
||||
file: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
function isAllowedAbsolutePath(pathValue: string, workspaceRoot?: string): boolean {
|
||||
const normalizedWorkspaceRoot = normalizePathInput(workspaceRoot);
|
||||
if (!normalizedWorkspaceRoot) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const comparePath = normalizePathForCompare(pathValue);
|
||||
const compareWorkspaceRoot = normalizePathForCompare(
|
||||
normalizedWorkspaceRoot.replace(/\/+$/, "") || "/",
|
||||
);
|
||||
const comparePrefix = compareWorkspaceRoot === "/" ? "/" : `${compareWorkspaceRoot}/`;
|
||||
|
||||
return comparePath === compareWorkspaceRoot || comparePath.startsWith(comparePrefix);
|
||||
}
|
||||
|
||||
function isExternalHref(value: string): boolean {
|
||||
if (value.includes("://")) {
|
||||
return !value.toLowerCase().startsWith(`${FILE_PROTOCOL}//`);
|
||||
}
|
||||
|
||||
const inlinePathTarget = parseInlinePathToken(value);
|
||||
if (inlinePathTarget) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /^[A-Za-z][A-Za-z0-9+.-]*:/.test(value) && !/^[A-Za-z]:[\\/]/.test(value);
|
||||
}
|
||||
|
||||
function isAmbiguousWorkspaceCandidate(
|
||||
value: string,
|
||||
target: InlinePathTarget,
|
||||
workspaceRoot?: string,
|
||||
): boolean {
|
||||
const normalizedWorkspaceRoot = normalizePathInput(workspaceRoot);
|
||||
if (!normalizedWorkspaceRoot || !isAllowedAbsolutePath(target.path, normalizedWorkspaceRoot)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = parseLocalPathParts(value);
|
||||
if (!parsed || isAbsolutePath(parsed.path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !parsed.path.includes("/");
|
||||
}
|
||||
|
||||
function getHeuristicLocalPath(value: string): string | null {
|
||||
const hashIndex = value.indexOf("#");
|
||||
const beforeHash = hashIndex >= 0 ? value.slice(0, hashIndex) : value;
|
||||
const hash = hashIndex >= 0 ? value.slice(hashIndex) : "";
|
||||
if (!parseLineFragment(hash)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inlinePathTarget = parseInlinePathToken(beforeHash);
|
||||
if (inlinePathTarget) {
|
||||
return inlinePathTarget.path;
|
||||
}
|
||||
|
||||
if (!beforeHash || beforeHash.includes(":")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return beforeHash;
|
||||
}
|
||||
|
||||
function isPlausibleAssistantLocalPath(pathValue: string): boolean {
|
||||
const normalized = normalizePathToken(pathValue);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isAbsolutePath(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const explicitRelative =
|
||||
normalized.startsWith("./") || normalized.startsWith("../") || normalized.startsWith("~/");
|
||||
if (explicitRelative) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const segments = normalized.split("/").filter(Boolean);
|
||||
const firstSegment = segments[0];
|
||||
if (!firstSegment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (segments.length > 1) {
|
||||
return !isDomainLikePathSegment(firstSegment);
|
||||
}
|
||||
|
||||
if (firstSegment.startsWith(".") && firstSegment.length > 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const lastDot = firstSegment.lastIndexOf(".");
|
||||
if (lastDot < 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const extension = firstSegment.slice(lastDot + 1).toLowerCase();
|
||||
return ASSISTANT_FILE_EXTENSIONS.has(extension);
|
||||
}
|
||||
|
||||
function isDomainLikePathSegment(segment: string): boolean {
|
||||
return /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(segment);
|
||||
}
|
||||
|
||||
function resolveRelativePathUnderRoot(pathValue: string, workspaceRoot: string): string | null {
|
||||
const normalizedPath = normalizePathToken(pathValue);
|
||||
if (!normalizedPath || isAbsolutePath(normalizedPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = workspaceRoot.replace(/\/+$/, "") || "/";
|
||||
const pathSegments = normalizedPath.split("/");
|
||||
const resolvedSegments: string[] = [];
|
||||
for (const segment of pathSegments) {
|
||||
if (!segment || segment === ".") {
|
||||
continue;
|
||||
}
|
||||
if (segment === "..") {
|
||||
if (resolvedSegments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
resolvedSegments.pop();
|
||||
continue;
|
||||
}
|
||||
resolvedSegments.push(segment);
|
||||
}
|
||||
|
||||
if (resolvedSegments.length === 0) {
|
||||
return root;
|
||||
}
|
||||
|
||||
return root === "/" ? `/${resolvedSegments.join("/")}` : `${root}/${resolvedSegments.join("/")}`;
|
||||
}
|
||||
|
||||
function normalizeFileUrlPath(pathname: string): string | null {
|
||||
if (!pathname) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decoded = decodeURIComponent(pathname).replace(/\\/g, "/");
|
||||
if (!decoded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/^\/[A-Za-z]:\//.test(decoded)) {
|
||||
return decoded.slice(1);
|
||||
}
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function normalizePathInput(value: string | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value
|
||||
.trim()
|
||||
.replace(/^['"`]/, "")
|
||||
.replace(/['"`]$/, "");
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed.replace(/\\/g, "/").replace(/\/{2,}/g, "/");
|
||||
}
|
||||
|
||||
function resolvePathAgainstCwd(pathValue: string, cwd?: string): string | null {
|
||||
const normalizedCwd = normalizePathInput(cwd);
|
||||
if (!normalizedCwd || !isAbsolutePath(pathValue) || !isAbsolutePath(normalizedCwd)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedCwdBase = normalizedCwd.replace(/\/+$/, "") || "/";
|
||||
const comparePath = normalizePathForCompare(pathValue);
|
||||
const compareCwd = normalizePathForCompare(normalizedCwdBase);
|
||||
const prefix = normalizedCwdBase === "/" ? "/" : `${normalizedCwdBase}/`;
|
||||
const comparePrefix = normalizePathForCompare(prefix);
|
||||
|
||||
if (comparePath === compareCwd) {
|
||||
return ".";
|
||||
}
|
||||
|
||||
if (comparePath.startsWith(comparePrefix)) {
|
||||
return pathValue.slice(prefix.length) || ".";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizePathForCompare(value: string): string {
|
||||
return /^[A-Za-z]:/.test(value) ? value.toLowerCase() : value;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export type OpenFileDisposition = "main" | "side";
|
||||
@@ -1,160 +0,0 @@
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
|
||||
type WorkspaceDraftTabSetup = NonNullable<Extract<WorkspaceTabTarget, { kind: "draft" }>["setup"]>;
|
||||
|
||||
export function normalizeWorkspaceTabTarget(
|
||||
value: WorkspaceTabTarget | null | undefined,
|
||||
): WorkspaceTabTarget | null {
|
||||
if (!value || typeof value !== "object" || typeof value.kind !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (value.kind === "draft") {
|
||||
const draftId = trimNonEmpty(value.draftId);
|
||||
if (!draftId) {
|
||||
return null;
|
||||
}
|
||||
const setup = normalizeWorkspaceDraftTabSetup(value.setup);
|
||||
return setup ? { kind: "draft", draftId, setup } : { kind: "draft", draftId };
|
||||
}
|
||||
if (value.kind === "agent") {
|
||||
const agentId = trimNonEmpty(value.agentId);
|
||||
return agentId ? { kind: "agent", agentId } : null;
|
||||
}
|
||||
if (value.kind === "terminal") {
|
||||
const terminalId = trimNonEmpty(value.terminalId);
|
||||
return terminalId ? { kind: "terminal", terminalId } : null;
|
||||
}
|
||||
if (value.kind === "browser") {
|
||||
const browserId = trimNonEmpty(value.browserId);
|
||||
return browserId ? { kind: "browser", browserId } : null;
|
||||
}
|
||||
if (value.kind === "file") {
|
||||
const path = trimNonEmpty(value.path);
|
||||
return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null;
|
||||
}
|
||||
if (value.kind === "setup") {
|
||||
const workspaceId = trimNonEmpty(value.workspaceId);
|
||||
return workspaceId ? { kind: "setup", workspaceId } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeWorkspaceDraftTabSetup(
|
||||
value: unknown,
|
||||
): WorkspaceDraftTabSetup | undefined {
|
||||
const record = isPlainRecord(value) ? value : null;
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
const provider = trimNonEmpty(typeof record.provider === "string" ? record.provider : null);
|
||||
const cwd = trimNonEmpty(typeof record.cwd === "string" ? record.cwd : null);
|
||||
if (!provider || !cwd) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
provider,
|
||||
cwd,
|
||||
modeId: trimOptionalString(typeof record.modeId === "string" ? record.modeId : null),
|
||||
model: trimOptionalString(typeof record.model === "string" ? record.model : null),
|
||||
thinkingOptionId: trimOptionalString(
|
||||
typeof record.thinkingOptionId === "string" ? record.thinkingOptionId : null,
|
||||
),
|
||||
featureValues: isPlainRecord(record.featureValues) ? { ...record.featureValues } : {},
|
||||
};
|
||||
}
|
||||
|
||||
export function workspaceTabTargetsEqual(
|
||||
left: WorkspaceTabTarget,
|
||||
right: WorkspaceTabTarget,
|
||||
): boolean {
|
||||
if (left.kind !== right.kind) {
|
||||
return false;
|
||||
}
|
||||
if (left.kind === "draft" && right.kind === "draft") {
|
||||
return left.draftId === right.draftId && workspaceDraftTabSetupsEqual(left.setup, right.setup);
|
||||
}
|
||||
if (left.kind === "agent" && right.kind === "agent") {
|
||||
return left.agentId === right.agentId;
|
||||
}
|
||||
if (left.kind === "terminal" && right.kind === "terminal") {
|
||||
return left.terminalId === right.terminalId;
|
||||
}
|
||||
if (left.kind === "browser" && right.kind === "browser") {
|
||||
return left.browserId === right.browserId;
|
||||
}
|
||||
if (left.kind === "file" && right.kind === "file") {
|
||||
return left.path === right.path;
|
||||
}
|
||||
if (left.kind === "setup" && right.kind === "setup") {
|
||||
return left.workspaceId === right.workspaceId;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function workspaceDraftTabSetupsEqual(
|
||||
left: WorkspaceDraftTabSetup | undefined,
|
||||
right: WorkspaceDraftTabSetup | undefined,
|
||||
): boolean {
|
||||
if (!left || !right) {
|
||||
return left === right;
|
||||
}
|
||||
return (
|
||||
left.provider === right.provider &&
|
||||
left.cwd === right.cwd &&
|
||||
left.modeId === right.modeId &&
|
||||
left.model === right.model &&
|
||||
left.thinkingOptionId === right.thinkingOptionId &&
|
||||
recordsShallowEqual(left.featureValues, right.featureValues)
|
||||
);
|
||||
}
|
||||
|
||||
function recordsShallowEqual(
|
||||
left: Record<string, unknown>,
|
||||
right: Record<string, unknown>,
|
||||
): boolean {
|
||||
const leftKeys = Object.keys(left);
|
||||
if (leftKeys.length !== Object.keys(right).length) {
|
||||
return false;
|
||||
}
|
||||
for (const key of leftKeys) {
|
||||
if (!Object.hasOwn(right, key) || !Object.is(left[key], right[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): string {
|
||||
if (target.kind === "draft") {
|
||||
return target.draftId;
|
||||
}
|
||||
if (target.kind === "agent") {
|
||||
return `agent_${target.agentId}`;
|
||||
}
|
||||
if (target.kind === "terminal") {
|
||||
return `terminal_${target.terminalId}`;
|
||||
}
|
||||
if (target.kind === "browser") {
|
||||
return `browser_${target.browserId}`;
|
||||
}
|
||||
if (target.kind === "setup") {
|
||||
return `setup_${target.workspaceId}`;
|
||||
}
|
||||
return `file_${target.path}`;
|
||||
}
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function trimOptionalString(value: string | null | undefined): string | null {
|
||||
return value == null ? null : trimNonEmpty(value);
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
Reference in New Issue
Block a user