da7eba9c25
* fix(images): restore read rendering and protocol fallbacks * fix(tui): narrow kitty fallback heuristic * fix(coding-agent): keep read placeholder on abort --------- Co-authored-by: Can Bölük <can1357@users.noreply.github.com>
169 lines
5.0 KiB
TypeScript
169 lines
5.0 KiB
TypeScript
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
|
|
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
const NARROW_NO_BREAK_SPACE = "\u202F";
|
|
|
|
function normalizeUnicodeSpaces(str: string): string {
|
|
return str.replace(UNICODE_SPACES, " ");
|
|
}
|
|
|
|
function tryMacOSScreenshotPath(filePath: string): string {
|
|
return filePath.replace(/ (AM|PM)\./g, `${NARROW_NO_BREAK_SPACE}$1.`);
|
|
}
|
|
|
|
function tryNFDVariant(filePath: string): string {
|
|
// macOS stores filenames in NFD (decomposed) form, try converting user input to NFD
|
|
return filePath.normalize("NFD");
|
|
}
|
|
|
|
function tryCurlyQuoteVariant(filePath: string): string {
|
|
// macOS uses U+2019 (right single quotation mark) in screenshot names like "Capture d'écran"
|
|
// Users typically type U+0027 (straight apostrophe)
|
|
return filePath.replace(/'/g, "\u2019");
|
|
}
|
|
|
|
function tryShellEscapedPath(filePath: string): string {
|
|
if (!filePath.includes("\\") || !filePath.includes("/")) return filePath;
|
|
return filePath.replace(/\\([ \t"'(){}[\]])/g, "$1");
|
|
}
|
|
|
|
function fileExists(filePath: string): boolean {
|
|
try {
|
|
fs.accessSync(filePath, fs.constants.F_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function normalizeAtPrefix(filePath: string): string {
|
|
if (!filePath.startsWith("@")) return filePath;
|
|
|
|
const withoutAt = filePath.slice(1);
|
|
|
|
// We only treat a leading "@" as a shorthand for a small set of well-known
|
|
// syntaxes. This avoids mangling literal paths like "@my-file.txt".
|
|
if (
|
|
withoutAt.startsWith("/") ||
|
|
withoutAt === "~" ||
|
|
withoutAt.startsWith("~/") ||
|
|
// Windows absolute paths (drive letters / UNC / root-relative)
|
|
path.win32.isAbsolute(withoutAt) ||
|
|
// Internal URL shorthands
|
|
withoutAt.startsWith("agent://") ||
|
|
withoutAt.startsWith("artifact://") ||
|
|
withoutAt.startsWith("skill://") ||
|
|
withoutAt.startsWith("rule://") ||
|
|
withoutAt.startsWith("local://")
|
|
) {
|
|
return withoutAt;
|
|
}
|
|
|
|
return filePath;
|
|
}
|
|
|
|
export function expandTilde(filePath: string, home?: string): string {
|
|
const h = home ?? os.homedir();
|
|
if (filePath === "~") return h;
|
|
if (filePath.startsWith("~/") || filePath.startsWith("~\\")) {
|
|
return h + filePath.slice(1);
|
|
}
|
|
if (filePath.startsWith("~")) {
|
|
return path.join(h, filePath.slice(1));
|
|
}
|
|
return filePath;
|
|
}
|
|
|
|
export function expandPath(filePath: string): string {
|
|
const normalized = normalizeUnicodeSpaces(normalizeAtPrefix(filePath));
|
|
return expandTilde(normalized);
|
|
}
|
|
|
|
/**
|
|
* Resolve a path relative to the given cwd.
|
|
* Handles ~ expansion and absolute paths.
|
|
*/
|
|
export function resolveToCwd(filePath: string, cwd: string): string {
|
|
const expanded = expandPath(filePath);
|
|
if (path.isAbsolute(expanded)) {
|
|
return expanded;
|
|
}
|
|
return path.resolve(cwd, expanded);
|
|
}
|
|
|
|
const GLOB_PATH_CHARS = ["*", "?", "[", "{"] as const;
|
|
|
|
export function hasGlobPathChars(filePath: string): boolean {
|
|
return GLOB_PATH_CHARS.some(char => filePath.includes(char));
|
|
}
|
|
|
|
export interface ParsedSearchPath {
|
|
basePath: string;
|
|
glob?: string;
|
|
}
|
|
|
|
/**
|
|
* Split a user path into a base path + glob pattern for tools that delegate to
|
|
* APIs accepting separate `path` and `glob` arguments.
|
|
*/
|
|
export function parseSearchPath(filePath: string): ParsedSearchPath {
|
|
const normalizedPath = filePath.replace(/\\/g, "/");
|
|
if (!hasGlobPathChars(normalizedPath)) {
|
|
return { basePath: filePath };
|
|
}
|
|
|
|
const segments = normalizedPath.split("/");
|
|
const firstGlobIndex = segments.findIndex(segment => hasGlobPathChars(segment));
|
|
|
|
if (firstGlobIndex <= 0) {
|
|
return { basePath: ".", glob: normalizedPath };
|
|
}
|
|
|
|
return {
|
|
basePath: segments.slice(0, firstGlobIndex).join("/"),
|
|
glob: segments.slice(firstGlobIndex).join("/"),
|
|
};
|
|
}
|
|
|
|
export function resolveReadPath(filePath: string, cwd: string): string {
|
|
const resolved = resolveToCwd(filePath, cwd);
|
|
const shellEscapedVariant = tryShellEscapedPath(resolved);
|
|
const baseCandidates = shellEscapedVariant !== resolved ? [resolved, shellEscapedVariant] : [resolved];
|
|
|
|
for (const baseCandidate of baseCandidates) {
|
|
if (fileExists(baseCandidate)) {
|
|
return baseCandidate;
|
|
}
|
|
}
|
|
|
|
for (const baseCandidate of baseCandidates) {
|
|
// Try macOS AM/PM variant (narrow no-break space before AM/PM)
|
|
const amPmVariant = tryMacOSScreenshotPath(baseCandidate);
|
|
if (amPmVariant !== baseCandidate && fileExists(amPmVariant)) {
|
|
return amPmVariant;
|
|
}
|
|
|
|
// Try NFD variant (macOS stores filenames in NFD form)
|
|
const nfdVariant = tryNFDVariant(baseCandidate);
|
|
if (nfdVariant !== baseCandidate && fileExists(nfdVariant)) {
|
|
return nfdVariant;
|
|
}
|
|
|
|
// Try curly quote variant (macOS uses U+2019 in screenshot names)
|
|
const curlyVariant = tryCurlyQuoteVariant(baseCandidate);
|
|
if (curlyVariant !== baseCandidate && fileExists(curlyVariant)) {
|
|
return curlyVariant;
|
|
}
|
|
|
|
// Try combined NFD + curly quote (for French macOS screenshots like "Capture d'écran")
|
|
const nfdCurlyVariant = tryCurlyQuoteVariant(nfdVariant);
|
|
if (nfdCurlyVariant !== baseCandidate && fileExists(nfdCurlyVariant)) {
|
|
return nfdCurlyVariant;
|
|
}
|
|
}
|
|
|
|
return resolved;
|
|
}
|