import { formatFilePathPosition, splitFilePathPosition,} from "@t3tools/client-runtime/markdown-links"; import { isMacPlatform } from "./lib/utils"; export type TerminalLinkKind = "url" | "path"; export interface TerminalLinkMatch { kind: TerminalLinkKind; text: string; start: number; end: number;} export interface TerminalBufferLineLike { readonly isWrapped?: boolean; translateToString(trimRight?: boolean): string;} export interface WrappedTerminalLinkLineSegment { bufferLineNumber: number; text: string; startIndex: number; endIndex: number;} export interface WrappedTerminalLinkLine { text: string; segments: ReadonlyArray<WrappedTerminalLinkLineSegment>;} const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/giu;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 = /[.,;!?]+$/; 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" && isTerminalUrl(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("\\\\");} export 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;} 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 isTerminalUrl(value: string): boolean { return /^https?:\/\//iu.test(value);} export function collectWrappedTerminalLinkLine( bufferLineNumber: number, getLine: (bufferLineIndex: number) => TerminalBufferLineLike | null | undefined,): WrappedTerminalLinkLine | null { const anchorLine = getLine(bufferLineNumber - 1); if (!anchorLine) return null; let startBufferLineNumber = bufferLineNumber; let startLine = anchorLine; while (startBufferLineNumber > 1 && startLine.isWrapped) { const previousLine = getLine(startBufferLineNumber - 2); if (!previousLine) return null; startBufferLineNumber -= 1; startLine = previousLine; } const segments: WrappedTerminalLinkLineSegment[] = []; let nextStartIndex = 0; let currentBufferLineNumber = startBufferLineNumber; while (true) { const currentLine = getLine(currentBufferLineNumber - 1); if (!currentLine) break; const nextLine = getLine(currentBufferLineNumber); const hasWrappedContinuation = nextLine?.isWrapped === true; const text = currentLine.translateToString(!hasWrappedContinuation); segments.push({ bufferLineNumber: currentBufferLineNumber, text, startIndex: nextStartIndex, endIndex: nextStartIndex + text.length, }); nextStartIndex += text.length; if (!hasWrappedContinuation) break; currentBufferLineNumber += 1; } return { text: segments.map((segment) => segment.text).join(""), segments, };} export function isTerminalLinkActivation( event: Pick<MouseEvent, "metaKey" | "ctrlKey">, 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 position = splitFilePathPosition(rawPath); const { path } = position; 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); } return formatFilePathPosition({ ...position, path: resolvedPath });}
import {
formatFilePathPosition,
splitFilePathPosition,
} from "@t3tools/client-runtime/markdown-links";
import { isMacPlatform } from "./lib/utils";
export type TerminalLinkKind = "url" | "path";
export interface TerminalLinkMatch {
kind: TerminalLinkKind;
text: string;
start: number;
end: number;
}
export interface TerminalBufferLineLike {
readonly isWrapped?: boolean;
translateToString(trimRight?: boolean): string;
}
export interface WrappedTerminalLinkLineSegment {
bufferLineNumber: number;
text: string;
startIndex: number;
endIndex: number;
}
export interface WrappedTerminalLinkLine {
text: string;
segments: ReadonlyArray<WrappedTerminalLinkLineSegment>;
}
const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/giu;
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 = /[.,;!?]+$/;
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" && isTerminalUrl(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("\\\\");
}
export 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;
}
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 isTerminalUrl(value: string): boolean {
return /^https?:\/\//iu.test(value);
}
export function collectWrappedTerminalLinkLine(
bufferLineNumber: number,
getLine: (bufferLineIndex: number) => TerminalBufferLineLike | null | undefined,
): WrappedTerminalLinkLine | null {
const anchorLine = getLine(bufferLineNumber - 1);
if (!anchorLine) return null;
let startBufferLineNumber = bufferLineNumber;
let startLine = anchorLine;
while (startBufferLineNumber > 1 && startLine.isWrapped) {
const previousLine = getLine(startBufferLineNumber - 2);
if (!previousLine) return null;
startBufferLineNumber -= 1;
startLine = previousLine;
}
const segments: WrappedTerminalLinkLineSegment[] = [];
let nextStartIndex = 0;
let currentBufferLineNumber = startBufferLineNumber;
while (true) {
const currentLine = getLine(currentBufferLineNumber - 1);
if (!currentLine) break;
const nextLine = getLine(currentBufferLineNumber);
const hasWrappedContinuation = nextLine?.isWrapped === true;
const text = currentLine.translateToString(!hasWrappedContinuation);
segments.push({
bufferLineNumber: currentBufferLineNumber,
text,
startIndex: nextStartIndex,
endIndex: nextStartIndex + text.length,
});
nextStartIndex += text.length;
if (!hasWrappedContinuation) break;
currentBufferLineNumber += 1;
}
return {
text: segments.map((segment) => segment.text).join(""),
segments,
};
}
export function isTerminalLinkActivation(
event: Pick<MouseEvent, "metaKey" | "ctrlKey">,
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 position = splitFilePathPosition(rawPath);
const { path } = position;
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);
}
return formatFilePathPosition({ ...position, path: resolvedPath });
}