mirror of
http://10.0.2.1:3031/sauer/claude-code.git
synced 2026-06-30 14:06:57 +10:00
585 lines
18 KiB
TypeScript
585 lines
18 KiB
TypeScript
|
|
import { chmodSync, writeFileSync as fsWriteFileSync } from 'fs'
|
|||
|
|
import { realpath, stat } from 'fs/promises'
|
|||
|
|
import { homedir } from 'os'
|
|||
|
|
import {
|
|||
|
|
basename,
|
|||
|
|
dirname,
|
|||
|
|
extname,
|
|||
|
|
isAbsolute,
|
|||
|
|
join,
|
|||
|
|
normalize,
|
|||
|
|
relative,
|
|||
|
|
resolve,
|
|||
|
|
sep,
|
|||
|
|
} from 'path'
|
|||
|
|
import { logEvent } from 'src/services/analytics/index.js'
|
|||
|
|
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
|
|||
|
|
import { getCwd } from '../utils/cwd.js'
|
|||
|
|
import { logForDebugging } from './debug.js'
|
|||
|
|
import { isENOENT, isFsInaccessible } from './errors.js'
|
|||
|
|
import {
|
|||
|
|
detectEncodingForResolvedPath,
|
|||
|
|
detectLineEndingsForString,
|
|||
|
|
type LineEndingType,
|
|||
|
|
} from './fileRead.js'
|
|||
|
|
import { fileReadCache } from './fileReadCache.js'
|
|||
|
|
import { getFsImplementation, safeResolvePath } from './fsOperations.js'
|
|||
|
|
import { logError } from './log.js'
|
|||
|
|
import { expandPath } from './path.js'
|
|||
|
|
import { getPlatform } from './platform.js'
|
|||
|
|
|
|||
|
|
export type File = {
|
|||
|
|
filename: string
|
|||
|
|
content: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Check if a path exists asynchronously.
|
|||
|
|
*/
|
|||
|
|
export async function pathExists(path: string): Promise<boolean> {
|
|||
|
|
try {
|
|||
|
|
await stat(path)
|
|||
|
|
return true
|
|||
|
|
} catch {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export const MAX_OUTPUT_SIZE = 0.25 * 1024 * 1024 // 0.25MB in bytes
|
|||
|
|
|
|||
|
|
export function readFileSafe(filepath: string): string | null {
|
|||
|
|
try {
|
|||
|
|
const fs = getFsImplementation()
|
|||
|
|
return fs.readFileSync(filepath, { encoding: 'utf8' })
|
|||
|
|
} catch (error) {
|
|||
|
|
logError(error)
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Get the normalized modification time of a file in milliseconds.
|
|||
|
|
* Uses Math.floor to ensure consistent timestamp comparisons across file operations,
|
|||
|
|
* reducing false positives from sub-millisecond precision changes (e.g., from IDE
|
|||
|
|
* file watchers that touch files without changing content).
|
|||
|
|
*/
|
|||
|
|
export function getFileModificationTime(filePath: string): number {
|
|||
|
|
const fs = getFsImplementation()
|
|||
|
|
return Math.floor(fs.statSync(filePath).mtimeMs)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Async variant of getFileModificationTime. Same floor semantics.
|
|||
|
|
* Use this in async paths (getChangedFiles runs every turn on every readFileState
|
|||
|
|
* entry — sync statSync there triggers the slow-operation indicator on network/
|
|||
|
|
* slow disks).
|
|||
|
|
*/
|
|||
|
|
export async function getFileModificationTimeAsync(
|
|||
|
|
filePath: string,
|
|||
|
|
): Promise<number> {
|
|||
|
|
const s = await getFsImplementation().stat(filePath)
|
|||
|
|
return Math.floor(s.mtimeMs)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function writeTextContent(
|
|||
|
|
filePath: string,
|
|||
|
|
content: string,
|
|||
|
|
encoding: BufferEncoding,
|
|||
|
|
endings: LineEndingType,
|
|||
|
|
): void {
|
|||
|
|
let toWrite = content
|
|||
|
|
if (endings === 'CRLF') {
|
|||
|
|
// Normalize any existing CRLF to LF first so a new_string that already
|
|||
|
|
// contains \r\n (raw model output) doesn't become \r\r\n after the join.
|
|||
|
|
toWrite = content.replaceAll('\r\n', '\n').split('\n').join('\r\n')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
writeFileSyncAndFlush_DEPRECATED(filePath, toWrite, { encoding })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function detectFileEncoding(filePath: string): BufferEncoding {
|
|||
|
|
try {
|
|||
|
|
const fs = getFsImplementation()
|
|||
|
|
const { resolvedPath } = safeResolvePath(fs, filePath)
|
|||
|
|
return detectEncodingForResolvedPath(resolvedPath)
|
|||
|
|
} catch (error) {
|
|||
|
|
if (isFsInaccessible(error)) {
|
|||
|
|
logForDebugging(
|
|||
|
|
`detectFileEncoding failed for expected reason: ${error.code}`,
|
|||
|
|
{
|
|||
|
|
level: 'debug',
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
} else {
|
|||
|
|
logError(error)
|
|||
|
|
}
|
|||
|
|
return 'utf8'
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function detectLineEndings(
|
|||
|
|
filePath: string,
|
|||
|
|
encoding: BufferEncoding = 'utf8',
|
|||
|
|
): LineEndingType {
|
|||
|
|
try {
|
|||
|
|
const fs = getFsImplementation()
|
|||
|
|
const { resolvedPath } = safeResolvePath(fs, filePath)
|
|||
|
|
const { buffer, bytesRead } = fs.readSync(resolvedPath, { length: 4096 })
|
|||
|
|
|
|||
|
|
const content = buffer.toString(encoding, 0, bytesRead)
|
|||
|
|
return detectLineEndingsForString(content)
|
|||
|
|
} catch (error) {
|
|||
|
|
logError(error)
|
|||
|
|
return 'LF'
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function convertLeadingTabsToSpaces(content: string): string {
|
|||
|
|
// The /gm regex scans every line even on no-match; skip it entirely
|
|||
|
|
// for the common tab-free case.
|
|||
|
|
if (!content.includes('\t')) return content
|
|||
|
|
return content.replace(/^\t+/gm, _ => ' '.repeat(_.length))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function getAbsoluteAndRelativePaths(path: string | undefined): {
|
|||
|
|
absolutePath: string | undefined
|
|||
|
|
relativePath: string | undefined
|
|||
|
|
} {
|
|||
|
|
const absolutePath = path ? expandPath(path) : undefined
|
|||
|
|
const relativePath = absolutePath
|
|||
|
|
? relative(getCwd(), absolutePath)
|
|||
|
|
: undefined
|
|||
|
|
return { absolutePath, relativePath }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function getDisplayPath(filePath: string): string {
|
|||
|
|
// Use relative path if file is in the current working directory
|
|||
|
|
const { relativePath } = getAbsoluteAndRelativePaths(filePath)
|
|||
|
|
if (relativePath && !relativePath.startsWith('..')) {
|
|||
|
|
return relativePath
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Use tilde notation for files in home directory
|
|||
|
|
const homeDir = homedir()
|
|||
|
|
if (filePath.startsWith(homeDir + sep)) {
|
|||
|
|
return '~' + filePath.slice(homeDir.length)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Otherwise return the absolute path
|
|||
|
|
return filePath
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Find files with the same name but different extensions in the same directory
|
|||
|
|
* @param filePath The path to the file that doesn't exist
|
|||
|
|
* @returns The found file with a different extension, or undefined if none found
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
export function findSimilarFile(filePath: string): string | undefined {
|
|||
|
|
const fs = getFsImplementation()
|
|||
|
|
try {
|
|||
|
|
const dir = dirname(filePath)
|
|||
|
|
const fileBaseName = basename(filePath, extname(filePath))
|
|||
|
|
|
|||
|
|
// Get all files in the directory
|
|||
|
|
const files = fs.readdirSync(dir)
|
|||
|
|
|
|||
|
|
// Find files with the same base name but different extension
|
|||
|
|
const similarFiles = files.filter(
|
|||
|
|
file =>
|
|||
|
|
basename(file.name, extname(file.name)) === fileBaseName &&
|
|||
|
|
join(dir, file.name) !== filePath,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Return just the filename of the first match if found
|
|||
|
|
const firstMatch = similarFiles[0]
|
|||
|
|
if (firstMatch) {
|
|||
|
|
return firstMatch.name
|
|||
|
|
}
|
|||
|
|
return undefined
|
|||
|
|
} catch (error) {
|
|||
|
|
// Missing dir (ENOENT) is expected; for other errors log and return undefined
|
|||
|
|
if (!isENOENT(error)) {
|
|||
|
|
logError(error)
|
|||
|
|
}
|
|||
|
|
return undefined
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Marker included in file-not-found error messages that contain a cwd note.
|
|||
|
|
* UI renderers check for this to show a short "File not found" message.
|
|||
|
|
*/
|
|||
|
|
export const FILE_NOT_FOUND_CWD_NOTE = 'Note: your current working directory is'
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Suggests a corrected path under the current working directory when a file/directory
|
|||
|
|
* is not found. Detects the "dropped repo folder" pattern where the model constructs
|
|||
|
|
* an absolute path missing the repo directory component.
|
|||
|
|
*
|
|||
|
|
* Example:
|
|||
|
|
* cwd = /Users/zeeg/src/currentRepo
|
|||
|
|
* requestedPath = /Users/zeeg/src/foobar (doesn't exist)
|
|||
|
|
* returns /Users/zeeg/src/currentRepo/foobar (if it exists)
|
|||
|
|
*
|
|||
|
|
* @param requestedPath - The absolute path that was not found
|
|||
|
|
* @returns The corrected path if found under cwd, undefined otherwise
|
|||
|
|
*/
|
|||
|
|
export async function suggestPathUnderCwd(
|
|||
|
|
requestedPath: string,
|
|||
|
|
): Promise<string | undefined> {
|
|||
|
|
const cwd = getCwd()
|
|||
|
|
const cwdParent = dirname(cwd)
|
|||
|
|
|
|||
|
|
// Resolve symlinks in the requested path's parent directory (e.g., /tmp -> /private/tmp on macOS)
|
|||
|
|
// so the prefix comparison works correctly against the cwd (which is already realpath-resolved).
|
|||
|
|
let resolvedPath = requestedPath
|
|||
|
|
try {
|
|||
|
|
const resolvedDir = await realpath(dirname(requestedPath))
|
|||
|
|
resolvedPath = join(resolvedDir, basename(requestedPath))
|
|||
|
|
} catch {
|
|||
|
|
// Parent directory doesn't exist, use the original path
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Only check if the requested path is under cwd's parent but not under cwd itself.
|
|||
|
|
// When cwdParent is the root directory (e.g., '/'), use it directly as the prefix
|
|||
|
|
// to avoid a double-separator '//' that would never match.
|
|||
|
|
const cwdParentPrefix = cwdParent === sep ? sep : cwdParent + sep
|
|||
|
|
if (
|
|||
|
|
!resolvedPath.startsWith(cwdParentPrefix) ||
|
|||
|
|
resolvedPath.startsWith(cwd + sep) ||
|
|||
|
|
resolvedPath === cwd
|
|||
|
|
) {
|
|||
|
|
return undefined
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Get the relative path from the parent directory
|
|||
|
|
const relFromParent = relative(cwdParent, resolvedPath)
|
|||
|
|
|
|||
|
|
// Check if the same relative path exists under cwd
|
|||
|
|
const correctedPath = join(cwd, relFromParent)
|
|||
|
|
try {
|
|||
|
|
await stat(correctedPath)
|
|||
|
|
return correctedPath
|
|||
|
|
} catch {
|
|||
|
|
return undefined
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Whether to use the compact line-number prefix format (`N\t` instead of
|
|||
|
|
* ` N→`). The padded-arrow format costs 9 bytes/line overhead; at
|
|||
|
|
* 1.35B Read calls × 132 lines avg this is 2.18% of fleet uncached input
|
|||
|
|
* (bq-queries/read_line_prefix_overhead_verify.sql).
|
|||
|
|
*
|
|||
|
|
* Ant soak validated no Edit error regression (6.29% vs 6.86% baseline).
|
|||
|
|
* Killswitch pattern: GB can disable if issues surface externally.
|
|||
|
|
*/
|
|||
|
|
export function isCompactLinePrefixEnabled(): boolean {
|
|||
|
|
// 3P default: killswitch off = compact format enabled. Client-side only —
|
|||
|
|
// no server support needed, safe for Bedrock/Vertex/Foundry.
|
|||
|
|
return !getFeatureValue_CACHED_MAY_BE_STALE(
|
|||
|
|
'tengu_compact_line_prefix_killswitch',
|
|||
|
|
false,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Adds cat -n style line numbers to the content.
|
|||
|
|
*/
|
|||
|
|
export function addLineNumbers({
|
|||
|
|
content,
|
|||
|
|
// 1-indexed
|
|||
|
|
startLine,
|
|||
|
|
}: {
|
|||
|
|
content: string
|
|||
|
|
startLine: number
|
|||
|
|
}): string {
|
|||
|
|
if (!content) {
|
|||
|
|
return ''
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const lines = content.split(/\r?\n/)
|
|||
|
|
|
|||
|
|
if (isCompactLinePrefixEnabled()) {
|
|||
|
|
return lines
|
|||
|
|
.map((line, index) => `${index + startLine}\t${line}`)
|
|||
|
|
.join('\n')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return lines
|
|||
|
|
.map((line, index) => {
|
|||
|
|
const numStr = String(index + startLine)
|
|||
|
|
if (numStr.length >= 6) {
|
|||
|
|
return `${numStr}→${line}`
|
|||
|
|
}
|
|||
|
|
return `${numStr.padStart(6, ' ')}→${line}`
|
|||
|
|
})
|
|||
|
|
.join('\n')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Inverse of addLineNumbers — strips the `N→` or `N\t` prefix from a single
|
|||
|
|
* line. Co-located so format changes here and in addLineNumbers stay in sync.
|
|||
|
|
*/
|
|||
|
|
export function stripLineNumberPrefix(line: string): string {
|
|||
|
|
const match = line.match(/^\s*\d+[\u2192\t](.*)$/)
|
|||
|
|
return match?.[1] ?? line
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Checks if a directory is empty.
|
|||
|
|
* @param dirPath The path to the directory to check
|
|||
|
|
* @returns true if the directory is empty or does not exist, false otherwise
|
|||
|
|
*/
|
|||
|
|
export function isDirEmpty(dirPath: string): boolean {
|
|||
|
|
try {
|
|||
|
|
return getFsImplementation().isDirEmptySync(dirPath)
|
|||
|
|
} catch (e) {
|
|||
|
|
// ENOENT: directory doesn't exist, consider it empty
|
|||
|
|
// Other errors (EPERM on macOS protected folders, etc.): assume not empty
|
|||
|
|
return isENOENT(e)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Reads a file with caching to avoid redundant I/O operations.
|
|||
|
|
* This is the preferred method for FileEditTool operations.
|
|||
|
|
*/
|
|||
|
|
export function readFileSyncCached(filePath: string): string {
|
|||
|
|
const { content } = fileReadCache.readFile(filePath)
|
|||
|
|
return content
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Writes to a file and flushes the file to disk
|
|||
|
|
* @param filePath The path to the file to write to
|
|||
|
|
* @param content The content to write to the file
|
|||
|
|
* @param options Options for writing the file, including encoding and mode
|
|||
|
|
* @deprecated Use `fs.promises.writeFile` with flush option instead for non-blocking writes.
|
|||
|
|
* Sync file writes block the event loop and cause performance issues.
|
|||
|
|
*/
|
|||
|
|
export function writeFileSyncAndFlush_DEPRECATED(
|
|||
|
|
filePath: string,
|
|||
|
|
content: string,
|
|||
|
|
options: { encoding: BufferEncoding; mode?: number } = { encoding: 'utf-8' },
|
|||
|
|
): void {
|
|||
|
|
const fs = getFsImplementation()
|
|||
|
|
|
|||
|
|
// Check if the target file is a symlink to preserve it for all users
|
|||
|
|
// Note: We don't use safeResolvePath here because we need to manually handle
|
|||
|
|
// symlinks to ensure we write to the target while preserving the symlink itself
|
|||
|
|
let targetPath = filePath
|
|||
|
|
try {
|
|||
|
|
// Try to read the symlink - if successful, it's a symlink
|
|||
|
|
const linkTarget = fs.readlinkSync(filePath)
|
|||
|
|
// Resolve to absolute path
|
|||
|
|
targetPath = isAbsolute(linkTarget)
|
|||
|
|
? linkTarget
|
|||
|
|
: resolve(dirname(filePath), linkTarget)
|
|||
|
|
logForDebugging(`Writing through symlink: ${filePath} -> ${targetPath}`)
|
|||
|
|
} catch {
|
|||
|
|
// ENOENT (doesn't exist) or EINVAL (not a symlink) — keep targetPath = filePath
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Try atomic write first
|
|||
|
|
const tempPath = `${targetPath}.tmp.${process.pid}.${Date.now()}`
|
|||
|
|
|
|||
|
|
// Check if target file exists and get its permissions (single stat, reused in both atomic and fallback paths)
|
|||
|
|
let targetMode: number | undefined
|
|||
|
|
let targetExists = false
|
|||
|
|
try {
|
|||
|
|
targetMode = fs.statSync(targetPath).mode
|
|||
|
|
targetExists = true
|
|||
|
|
logForDebugging(`Preserving file permissions: ${targetMode.toString(8)}`)
|
|||
|
|
} catch (e) {
|
|||
|
|
if (!isENOENT(e)) throw e
|
|||
|
|
if (options.mode !== undefined) {
|
|||
|
|
// Use provided mode for new files
|
|||
|
|
targetMode = options.mode
|
|||
|
|
logForDebugging(
|
|||
|
|
`Setting permissions for new file: ${targetMode.toString(8)}`,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
logForDebugging(`Writing to temp file: ${tempPath}`)
|
|||
|
|
|
|||
|
|
// Write to temp file with flush and mode (if specified for new file)
|
|||
|
|
const writeOptions: {
|
|||
|
|
encoding: BufferEncoding
|
|||
|
|
flush: boolean
|
|||
|
|
mode?: number
|
|||
|
|
} = {
|
|||
|
|
encoding: options.encoding,
|
|||
|
|
flush: true,
|
|||
|
|
}
|
|||
|
|
// Only set mode in writeFileSync for new files to ensure atomic permission setting
|
|||
|
|
if (!targetExists && options.mode !== undefined) {
|
|||
|
|
writeOptions.mode = options.mode
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fsWriteFileSync(tempPath, content, writeOptions)
|
|||
|
|
logForDebugging(
|
|||
|
|
`Temp file written successfully, size: ${content.length} bytes`,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// For existing files or if mode was not set atomically, apply permissions
|
|||
|
|
if (targetExists && targetMode !== undefined) {
|
|||
|
|
chmodSync(tempPath, targetMode)
|
|||
|
|
logForDebugging(`Applied original permissions to temp file`)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Atomic rename (on POSIX systems, this is atomic)
|
|||
|
|
// On Windows, this will overwrite the destination if it exists
|
|||
|
|
logForDebugging(`Renaming ${tempPath} to ${targetPath}`)
|
|||
|
|
fs.renameSync(tempPath, targetPath)
|
|||
|
|
logForDebugging(`File ${targetPath} written atomically`)
|
|||
|
|
} catch (atomicError) {
|
|||
|
|
logForDebugging(`Failed to write file atomically: ${atomicError}`, {
|
|||
|
|
level: 'error',
|
|||
|
|
})
|
|||
|
|
logEvent('tengu_atomic_write_error', {})
|
|||
|
|
|
|||
|
|
// Clean up temp file on error
|
|||
|
|
try {
|
|||
|
|
logForDebugging(`Cleaning up temp file: ${tempPath}`)
|
|||
|
|
fs.unlinkSync(tempPath)
|
|||
|
|
} catch (cleanupError) {
|
|||
|
|
logForDebugging(`Failed to clean up temp file: ${cleanupError}`)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Fallback to non-atomic write
|
|||
|
|
logForDebugging(`Falling back to non-atomic write for ${targetPath}`)
|
|||
|
|
try {
|
|||
|
|
const fallbackOptions: {
|
|||
|
|
encoding: BufferEncoding
|
|||
|
|
flush: boolean
|
|||
|
|
mode?: number
|
|||
|
|
} = {
|
|||
|
|
encoding: options.encoding,
|
|||
|
|
flush: true,
|
|||
|
|
}
|
|||
|
|
// Only set mode for new files
|
|||
|
|
if (!targetExists && options.mode !== undefined) {
|
|||
|
|
fallbackOptions.mode = options.mode
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fsWriteFileSync(targetPath, content, fallbackOptions)
|
|||
|
|
logForDebugging(
|
|||
|
|
`File ${targetPath} written successfully with non-atomic fallback`,
|
|||
|
|
)
|
|||
|
|
} catch (fallbackError) {
|
|||
|
|
logForDebugging(`Non-atomic write also failed: ${fallbackError}`)
|
|||
|
|
throw fallbackError
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function getDesktopPath(): string {
|
|||
|
|
const platform = getPlatform()
|
|||
|
|
const homeDir = homedir()
|
|||
|
|
|
|||
|
|
if (platform === 'macos') {
|
|||
|
|
return join(homeDir, 'Desktop')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (platform === 'windows') {
|
|||
|
|
// For WSL, try to access Windows desktop
|
|||
|
|
const windowsHome = process.env.USERPROFILE
|
|||
|
|
? process.env.USERPROFILE.replace(/\\/g, '/')
|
|||
|
|
: null
|
|||
|
|
|
|||
|
|
if (windowsHome) {
|
|||
|
|
const wslPath = windowsHome.replace(/^[A-Z]:/, '')
|
|||
|
|
const desktopPath = `/mnt/c${wslPath}/Desktop`
|
|||
|
|
|
|||
|
|
if (getFsImplementation().existsSync(desktopPath)) {
|
|||
|
|
return desktopPath
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Fallback: try to find desktop in typical Windows user location
|
|||
|
|
try {
|
|||
|
|
const usersDir = '/mnt/c/Users'
|
|||
|
|
const userDirs = getFsImplementation().readdirSync(usersDir)
|
|||
|
|
|
|||
|
|
for (const user of userDirs) {
|
|||
|
|
if (
|
|||
|
|
user.name === 'Public' ||
|
|||
|
|
user.name === 'Default' ||
|
|||
|
|
user.name === 'Default User' ||
|
|||
|
|
user.name === 'All Users'
|
|||
|
|
) {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const potentialDesktopPath = join(usersDir, user.name, 'Desktop')
|
|||
|
|
|
|||
|
|
if (getFsImplementation().existsSync(potentialDesktopPath)) {
|
|||
|
|
return potentialDesktopPath
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
logError(error)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Linux/unknown platform fallback
|
|||
|
|
const desktopPath = join(homeDir, 'Desktop')
|
|||
|
|
if (getFsImplementation().existsSync(desktopPath)) {
|
|||
|
|
return desktopPath
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// If Desktop folder doesn't exist, fallback to home directory
|
|||
|
|
return homeDir
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Validates that a file size is within the specified limit.
|
|||
|
|
* Returns true if the file is within the limit, false otherwise.
|
|||
|
|
*
|
|||
|
|
* @param filePath The path to the file to validate
|
|||
|
|
* @param maxSizeBytes The maximum allowed file size in bytes
|
|||
|
|
* @returns true if file size is within limit, false otherwise
|
|||
|
|
*/
|
|||
|
|
export function isFileWithinReadSizeLimit(
|
|||
|
|
filePath: string,
|
|||
|
|
maxSizeBytes: number = MAX_OUTPUT_SIZE,
|
|||
|
|
): boolean {
|
|||
|
|
try {
|
|||
|
|
const stats = getFsImplementation().statSync(filePath)
|
|||
|
|
return stats.size <= maxSizeBytes
|
|||
|
|
} catch {
|
|||
|
|
// If we can't stat the file, return false to indicate validation failure
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Normalize a file path for comparison, handling platform differences.
|
|||
|
|
* On Windows, normalizes path separators and converts to lowercase for
|
|||
|
|
* case-insensitive comparison.
|
|||
|
|
*/
|
|||
|
|
export function normalizePathForComparison(filePath: string): string {
|
|||
|
|
// Use path.normalize() to clean up redundant separators and resolve . and ..
|
|||
|
|
let normalized = normalize(filePath)
|
|||
|
|
|
|||
|
|
// On Windows, normalize for case-insensitive comparison:
|
|||
|
|
// - Convert forward slashes to backslashes (path.normalize only does this on actual Windows)
|
|||
|
|
// - Convert to lowercase (Windows paths are case-insensitive)
|
|||
|
|
if (getPlatform() === 'windows') {
|
|||
|
|
normalized = normalized.replace(/\//g, '\\').toLowerCase()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return normalized
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Compare two file paths for equality, handling Windows case-insensitivity.
|
|||
|
|
*/
|
|||
|
|
export function pathsEqual(path1: string, path2: string): boolean {
|
|||
|
|
return normalizePathForComparison(path1) === normalizePathForComparison(path2)
|
|||
|
|
}
|