223 lines
7.3 KiB
TypeScript
223 lines
7.3 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import path from 'node:path';
|
|
import os from 'os';
|
|
import * as crypto from 'crypto';
|
|
|
|
export const GEMINI_DIR = '.gemini';
|
|
export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json';
|
|
const TMP_DIR_NAME = 'tmp';
|
|
const COMMANDS_DIR_NAME = 'commands';
|
|
|
|
/**
|
|
* Special characters that need to be escaped in file paths for shell compatibility.
|
|
* Includes: spaces, parentheses, brackets, braces, semicolons, ampersands, pipes,
|
|
* asterisks, question marks, dollar signs, backticks, quotes, hash, and other shell metacharacters.
|
|
*/
|
|
export const SHELL_SPECIAL_CHARS = /[ \t()[\]{};|*?$`'"#&<>!~]/;
|
|
|
|
/**
|
|
* Replaces the home directory with a tilde.
|
|
* @param path - The path to tildeify.
|
|
* @returns The tildeified path.
|
|
*/
|
|
export function tildeifyPath(path: string): string {
|
|
const homeDir = os.homedir();
|
|
if (path.startsWith(homeDir)) {
|
|
return path.replace(homeDir, '~');
|
|
}
|
|
return path;
|
|
}
|
|
|
|
/**
|
|
* Shortens a path string if it exceeds maxLen, prioritizing the start and end segments.
|
|
* Example: /path/to/a/very/long/file.txt -> /path/.../long/file.txt
|
|
*/
|
|
export function shortenPath(filePath: string, maxLen: number = 35): string {
|
|
if (filePath.length <= maxLen) {
|
|
return filePath;
|
|
}
|
|
|
|
const parsedPath = path.parse(filePath);
|
|
const root = parsedPath.root;
|
|
const separator = path.sep;
|
|
|
|
// Get segments of the path *after* the root
|
|
const relativePath = filePath.substring(root.length);
|
|
const segments = relativePath.split(separator).filter((s) => s !== ''); // Filter out empty segments
|
|
|
|
// Handle cases with no segments after root (e.g., "/", "C:\") or only one segment
|
|
if (segments.length <= 1) {
|
|
// Fall back to simple start/end truncation for very short paths or single segments
|
|
const keepLen = Math.floor((maxLen - 3) / 2);
|
|
// Ensure keepLen is not negative if maxLen is very small
|
|
if (keepLen <= 0) {
|
|
return filePath.substring(0, maxLen - 3) + '...';
|
|
}
|
|
const start = filePath.substring(0, keepLen);
|
|
const end = filePath.substring(filePath.length - keepLen);
|
|
return `${start}...${end}`;
|
|
}
|
|
|
|
const firstDir = segments[0];
|
|
const lastSegment = segments[segments.length - 1];
|
|
const startComponent = root + firstDir;
|
|
|
|
const endPartSegments: string[] = [];
|
|
// Base length: separator + "..." + lastDir
|
|
let currentLength = separator.length + lastSegment.length;
|
|
|
|
// Iterate backwards through segments (excluding the first one)
|
|
for (let i = segments.length - 2; i >= 0; i--) {
|
|
const segment = segments[i];
|
|
// Length needed if we add this segment: current + separator + segment
|
|
const lengthWithSegment = currentLength + separator.length + segment.length;
|
|
|
|
if (lengthWithSegment <= maxLen) {
|
|
endPartSegments.unshift(segment); // Add to the beginning of the end part
|
|
currentLength = lengthWithSegment;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
let result = endPartSegments.join(separator) + separator + lastSegment;
|
|
|
|
if (currentLength > maxLen) {
|
|
return result;
|
|
}
|
|
|
|
// Construct the final path
|
|
result = startComponent + separator + result;
|
|
|
|
// As a final check, if the result is somehow still too long
|
|
// truncate the result string from the beginning, prefixing with "...".
|
|
if (result.length > maxLen) {
|
|
return '...' + result.substring(result.length - maxLen - 3);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Calculates the relative path from a root directory to a target path.
|
|
* Ensures both paths are resolved before calculating.
|
|
* Returns '.' if the target path is the same as the root directory.
|
|
*
|
|
* @param targetPath The absolute or relative path to make relative.
|
|
* @param rootDirectory The absolute path of the directory to make the target path relative to.
|
|
* @returns The relative path from rootDirectory to targetPath.
|
|
*/
|
|
export function makeRelative(
|
|
targetPath: string,
|
|
rootDirectory: string,
|
|
): string {
|
|
const resolvedTargetPath = path.resolve(targetPath);
|
|
const resolvedRootDirectory = path.resolve(rootDirectory);
|
|
|
|
const relativePath = path.relative(resolvedRootDirectory, resolvedTargetPath);
|
|
|
|
// If the paths are the same, path.relative returns '', return '.' instead
|
|
return relativePath || '.';
|
|
}
|
|
|
|
/**
|
|
* Escapes special characters in a file path like macOS terminal does.
|
|
* Escapes: spaces, parentheses, brackets, braces, semicolons, ampersands, pipes,
|
|
* asterisks, question marks, dollar signs, backticks, quotes, hash, and other shell metacharacters.
|
|
*/
|
|
export function escapePath(filePath: string): string {
|
|
let result = '';
|
|
for (let i = 0; i < filePath.length; i++) {
|
|
const char = filePath[i];
|
|
|
|
// Count consecutive backslashes before this character
|
|
let backslashCount = 0;
|
|
for (let j = i - 1; j >= 0 && filePath[j] === '\\'; j--) {
|
|
backslashCount++;
|
|
}
|
|
|
|
// Character is already escaped if there's an odd number of backslashes before it
|
|
const isAlreadyEscaped = backslashCount % 2 === 1;
|
|
|
|
// Only escape if not already escaped
|
|
if (!isAlreadyEscaped && SHELL_SPECIAL_CHARS.test(char)) {
|
|
result += '\\' + char;
|
|
} else {
|
|
result += char;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Unescapes special characters in a file path.
|
|
* Removes backslash escaping from shell metacharacters.
|
|
*/
|
|
export function unescapePath(filePath: string): string {
|
|
return filePath.replace(
|
|
new RegExp(`\\\\([${SHELL_SPECIAL_CHARS.source.slice(1, -1)}])`, 'g'),
|
|
'$1',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Generates a unique hash for a project based on its root path.
|
|
* @param projectRoot The absolute path to the project's root directory.
|
|
* @returns A SHA256 hash of the project root path.
|
|
*/
|
|
export function getProjectHash(projectRoot: string): string {
|
|
return crypto.createHash('sha256').update(projectRoot).digest('hex');
|
|
}
|
|
|
|
/**
|
|
* Generates a unique temporary directory path for a project.
|
|
* @param projectRoot The absolute path to the project's root directory.
|
|
* @returns The path to the project's temporary directory.
|
|
*/
|
|
export function getProjectTempDir(projectRoot: string): string {
|
|
const hash = getProjectHash(projectRoot);
|
|
return path.join(os.homedir(), GEMINI_DIR, TMP_DIR_NAME, hash);
|
|
}
|
|
|
|
/**
|
|
* Returns the absolute path to the user-level commands directory.
|
|
* @returns The path to the user's commands directory.
|
|
*/
|
|
export function getUserCommandsDir(): string {
|
|
return path.join(os.homedir(), GEMINI_DIR, COMMANDS_DIR_NAME);
|
|
}
|
|
|
|
/**
|
|
* Returns the absolute path to the project-level commands directory.
|
|
* @param projectRoot The absolute path to the project's root directory.
|
|
* @returns The path to the project's commands directory.
|
|
*/
|
|
export function getProjectCommandsDir(projectRoot: string): string {
|
|
return path.join(projectRoot, GEMINI_DIR, COMMANDS_DIR_NAME);
|
|
}
|
|
|
|
/**
|
|
* Checks if a path is a subpath of another path.
|
|
* @param parentPath The parent path.
|
|
* @param childPath The child path.
|
|
* @returns True if childPath is a subpath of parentPath, false otherwise.
|
|
*/
|
|
export function isSubpath(parentPath: string, childPath: string): boolean {
|
|
const isWindows = os.platform() === 'win32';
|
|
const pathModule = isWindows ? path.win32 : path;
|
|
|
|
// On Windows, path.relative is case-insensitive. On POSIX, it's case-sensitive.
|
|
const relative = pathModule.relative(parentPath, childPath);
|
|
|
|
return (
|
|
!relative.startsWith(`..${pathModule.sep}`) &&
|
|
relative !== '..' &&
|
|
!pathModule.isAbsolute(relative)
|
|
);
|
|
}
|