mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
259 lines
7.2 KiB
TypeScript
259 lines
7.2 KiB
TypeScript
import {
|
|
classifyAssistantFileLink,
|
|
isFileLookingAssistantToken,
|
|
type AssistantFileLinkClassification,
|
|
type InlinePathTarget,
|
|
} from "./inline-path";
|
|
|
|
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) => void;
|
|
openExternalUrl: (url: string) => void | Promise<void>;
|
|
onUnresolvedFileCandidate?: (token: string) => void;
|
|
isCurrentContext?: (context: AssistantFileLinkContext) => boolean;
|
|
}
|
|
|
|
export interface AssistantFileLinkResolver {
|
|
prefetch(input: AssistantFileLinkInteraction): Promise<ResolvedAssistantFileLink>;
|
|
open(input: AssistantFileLinkInteraction): Promise<AssistantFileLinkOpenResult>;
|
|
}
|
|
|
|
export interface AssistantFileLinkInteraction {
|
|
context: AssistantFileLinkContext;
|
|
source: AssistantFileLinkSource;
|
|
}
|
|
|
|
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: AssistantFileLinkInteraction): 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);
|
|
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: AssistantFileLinkInteraction,
|
|
): 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;
|
|
}
|