136 lines
5 KiB
TypeScript
136 lines
5 KiB
TypeScript
/**
|
|
* Cross-platform path utilities for the renderer process.
|
|
*
|
|
* Node's `path` module is unavailable in the renderer, and incoming paths
|
|
* may originate from any OS (Unix `/` or Windows `\`). Every helper here
|
|
* handles both separators transparently.
|
|
*/
|
|
|
|
const SEP_RE = /[/\\]/;
|
|
|
|
/** Split a file path on both `/` and `\` separators. */
|
|
export function splitPath(filePath: string): string[] {
|
|
return filePath.split(SEP_RE).filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* Returns true if the string looks like a Windows path (drive letter or UNC).
|
|
* Used only to decide case-sensitivity for comparisons.
|
|
*/
|
|
export function isWindowsishPath(filePath: string): boolean {
|
|
const p = filePath.replace(/\\/g, '/');
|
|
return /^[A-Za-z]:\//.test(p) || p.startsWith('//');
|
|
}
|
|
|
|
/** True for filesystem-absolute paths and home-relative `~` paths. */
|
|
export function isAbsoluteOrHomePath(filePath: string): boolean {
|
|
return (
|
|
filePath.startsWith('/') ||
|
|
filePath.startsWith('~/') ||
|
|
filePath.startsWith('~\\') ||
|
|
filePath === '~' ||
|
|
filePath.startsWith('\\\\') ||
|
|
/^[A-Za-z]:[\\/]/.test(filePath)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Normalize for comparisons:
|
|
* - Convert `\` → `/`
|
|
* - Lowercase only for Windows-ish paths (Windows is case-insensitive)
|
|
*
|
|
* Do NOT use this for filesystem operations; it's for comparisons only.
|
|
*/
|
|
export function normalizePathForComparison(filePath: string): string {
|
|
const p = filePath.replace(/\\/g, '/');
|
|
return isWindowsishPath(p) ? p.toLowerCase() : p;
|
|
}
|
|
|
|
/** Strip trailing path separators (except for root paths like "/" or "C:/"). */
|
|
export function stripTrailingSeparators(filePath: string): string {
|
|
if (!filePath) return filePath;
|
|
const p = filePath.replace(/\\/g, '/');
|
|
if (p === '/' || /^[A-Za-z]:\/$/.test(p)) return filePath;
|
|
// Trim trailing separators manually to avoid sonarjs/slow-regex
|
|
let end = filePath.length;
|
|
while (end > 0 && (filePath[end - 1] === '/' || filePath[end - 1] === '\\')) end--;
|
|
return end === filePath.length ? filePath : filePath.slice(0, end);
|
|
}
|
|
|
|
/** Prefer the separator style already present in the path. */
|
|
export function getPreferredSeparator(filePath: string): '/' | '\\' {
|
|
const hasBackslash = filePath.includes('\\');
|
|
const hasSlash = filePath.includes('/');
|
|
if (hasBackslash && !hasSlash) return '\\';
|
|
return '/';
|
|
}
|
|
|
|
/** Join base + segments using the base path's preferred separator. */
|
|
export function joinPath(base: string, ...segments: string[]): string {
|
|
const sep = getPreferredSeparator(base);
|
|
let out = stripTrailingSeparators(base);
|
|
for (const seg of segments) {
|
|
// Trim leading and trailing separators manually to avoid sonarjs/slow-regex
|
|
let start = 0;
|
|
while (start < seg.length && (seg[start] === '/' || seg[start] === '\\')) start++;
|
|
let segEnd = seg.length;
|
|
while (segEnd > start && (seg[segEnd - 1] === '/' || seg[segEnd - 1] === '\\')) segEnd--;
|
|
const cleaned = seg.slice(start, segEnd);
|
|
if (!cleaned) continue;
|
|
if (!out || out.endsWith('/') || out.endsWith('\\')) {
|
|
out += cleaned;
|
|
} else {
|
|
out += sep + cleaned;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** True if fullPath is equal to prefix or is nested under prefix. */
|
|
export function isPathPrefix(prefix: string, fullPath: string): boolean {
|
|
const p = stripTrailingSeparators(normalizePathForComparison(prefix));
|
|
const f = stripTrailingSeparators(normalizePathForComparison(fullPath));
|
|
if (!p) return false;
|
|
if (f === p) return true;
|
|
// Root prefixes are special: p already ends with "/" ("/" or "c:/").
|
|
if (p === '/') return f.startsWith('/');
|
|
if (/^[a-z]:\/$/.test(p)) return f.startsWith(p);
|
|
return f.startsWith(p + '/');
|
|
}
|
|
|
|
/** Return fullPath relative to prefix when fullPath is inside prefix, preserving fullPath style. */
|
|
export function getRelativePathWithinPrefix(prefix: string, fullPath: string): string | null {
|
|
const cleanPrefix = stripTrailingSeparators(prefix);
|
|
if (!isPathPrefix(cleanPrefix, fullPath)) {
|
|
return null;
|
|
}
|
|
|
|
const normalizedPrefix = stripTrailingSeparators(normalizePathForComparison(cleanPrefix));
|
|
const normalizedFullPath = stripTrailingSeparators(normalizePathForComparison(fullPath));
|
|
if (normalizedFullPath === normalizedPrefix) {
|
|
return '';
|
|
}
|
|
|
|
let relativePath = fullPath.slice(cleanPrefix.length);
|
|
while (relativePath.startsWith('/') || relativePath.startsWith('\\')) {
|
|
relativePath = relativePath.slice(1);
|
|
}
|
|
return relativePath;
|
|
}
|
|
|
|
/** Get the last segment (filename) from a path. */
|
|
export function getBasename(filePath: string): string {
|
|
const parts = splitPath(filePath);
|
|
return parts[parts.length - 1] ?? '';
|
|
}
|
|
|
|
/** Get directory part of a path (everything before the last separator). */
|
|
export function getDirname(filePath: string): string {
|
|
const lastSep = lastSeparatorIndex(filePath);
|
|
return lastSep === -1 ? '' : filePath.substring(0, lastSep);
|
|
}
|
|
|
|
/** Find the last path separator index (handles both `/` and `\`). */
|
|
export function lastSeparatorIndex(filePath: string): number {
|
|
return Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
|
|
}
|