import { EDITORS, type EditorId } from "@t3tools/contracts"; import { isMacPlatform } from "./lib/utils"; export type TerminalLinkKind = "url" | "path"; export interface TerminalLinkMatch { kind: TerminalLinkKind; text: string; start: number; end: number; } const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/g; const FILE_PATH_PATTERN = /(?:~\/|\.{1,2}\/|\/|[A-Za-z]:\\|\\\\)[^\s"'`<>]+|[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}/g; const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/; const LAST_EDITOR_KEY = "t3code:last-editor"; function trimClosingDelimiters(value: string): string { let output = value.replace(TRAILING_PUNCTUATION_PATTERN, ""); if (output.length === 0) return output; const trimUnbalanced = (open: string, close: string) => { while (output.endsWith(close)) { const opens = output.split(open).length - 1; const closes = output.split(close).length - 1; if (opens >= closes) return; output = output.slice(0, -1); } }; trimUnbalanced("(", ")"); trimUnbalanced("[", "]"); trimUnbalanced("{", "}"); return output; } function overlaps(a: { start: number; end: number }, b: { start: number; end: number }): boolean { return a.start < b.end && b.start < a.end; } function collectMatches( line: string, kind: TerminalLinkKind, pattern: RegExp, existing: TerminalLinkMatch[], ): TerminalLinkMatch[] { const matches: TerminalLinkMatch[] = []; pattern.lastIndex = 0; for (const rawMatch of line.matchAll(pattern)) { const raw = rawMatch[0]; const start = rawMatch.index ?? -1; if (start < 0 || raw.length === 0) continue; const trimmed = trimClosingDelimiters(raw); if (trimmed.length === 0) continue; if (kind === "path" && /^https?:\/\//i.test(trimmed)) continue; const candidate: TerminalLinkMatch = { kind, text: trimmed, start, end: start + trimmed.length, }; const collides = [...existing, ...matches].some((other) => overlaps(candidate, other)); if (collides) continue; matches.push(candidate); } return matches; } function isWindowsAbsolutePath(value: string): boolean { return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\"); } function isAbsolutePath(value: string): boolean { return value.startsWith("/") || isWindowsAbsolutePath(value); } function isWindowsPathStyle(value: string): boolean { return isWindowsAbsolutePath(value) || /[A-Za-z]:\\/.test(value); } function joinPath(base: string, next: string, separator: "/" | "\\"): string { const cleanBase = base.replace(/[\\/]+$/, ""); if (separator === "\\") { return `${cleanBase}\\${next.replaceAll("/", "\\")}`; } return `${cleanBase}/${next.replace(/^\/+/, "")}`; } function inferHomeFromCwd(cwd: string): string | undefined { const posixUser = cwd.match(/^\/Users\/([^/]+)/); if (posixUser?.[1]) { return `/Users/${posixUser[1]}`; } const posixHome = cwd.match(/^\/home\/([^/]+)/); if (posixHome?.[1]) { return `/home/${posixHome[1]}`; } const windowsUser = cwd.match(/^([A-Za-z]:\\Users\\[^\\]+)/); if (windowsUser?.[1]) { return windowsUser[1]; } return undefined; } function splitPathAndPosition(value: string): { path: string; line: string | undefined; column: string | undefined; } { let path = value; let column: string | undefined; let line: string | undefined; const columnMatch = path.match(/:(\d+)$/); if (!columnMatch?.[1]) { return { path, line: undefined, column: undefined }; } column = columnMatch[1]; path = path.slice(0, -columnMatch[0].length); const lineMatch = path.match(/:(\d+)$/); if (lineMatch?.[1]) { line = lineMatch[1]; path = path.slice(0, -lineMatch[0].length); } else { line = column; column = undefined; } return { path, line, column }; } export function extractTerminalLinks(line: string): TerminalLinkMatch[] { const urlMatches = collectMatches(line, "url", URL_PATTERN, []); const pathMatches = collectMatches(line, "path", FILE_PATH_PATTERN, urlMatches); return [...urlMatches, ...pathMatches].toSorted((a, b) => a.start - b.start); } export function isTerminalLinkActivation( event: Pick, platform = typeof navigator === "undefined" ? "" : navigator.platform, ): boolean { if (platform.length === 0) return false; return isMacPlatform(platform) ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey; } export function resolvePathLinkTarget(rawPath: string, cwd: string): string { const { path, line, column } = splitPathAndPosition(rawPath); let resolvedPath = path; if (path.startsWith("~/")) { const home = inferHomeFromCwd(cwd); if (home) { const separator: "/" | "\\" = isWindowsPathStyle(home) ? "\\" : "/"; resolvedPath = joinPath(home, path.slice(2), separator); } } else if (!isAbsolutePath(path)) { const separator: "/" | "\\" = isWindowsPathStyle(cwd) ? "\\" : "/"; resolvedPath = joinPath(cwd, path, separator); } if (!line) return resolvedPath; return `${resolvedPath}:${line}${column ? `:${column}` : ""}`; } export function preferredTerminalEditor(): EditorId { const fallback = EDITORS.find((editor) => editor.command)?.id ?? EDITORS[0]?.id ?? "cursor"; if (typeof window === "undefined") { return fallback; } const storedEditor = window.localStorage.getItem(LAST_EDITOR_KEY); if (!storedEditor) { return fallback; } const configured = EDITORS.find((editor) => editor.id === storedEditor); if (!configured?.command) { return fallback; } return configured.id; }