Avoid gitignore filtering during folder browsing Show folder load errors instead of empty folders Add timeout fallback for gitignore checks
1163 lines
39 KiB
JavaScript
1163 lines
39 KiB
JavaScript
import { createRealpathCache } from '../path-realpath-cache.js';
|
|
|
|
const EXEC_JOB_TTL_MS = 30 * 60 * 1000;
|
|
|
|
const createCommandTimeoutMs = () => {
|
|
const raw = Number(process.env.OPENCHAMBER_FS_EXEC_TIMEOUT_MS);
|
|
if (Number.isFinite(raw) && raw > 0) return raw;
|
|
return 5 * 60 * 1000;
|
|
};
|
|
|
|
// How long a cached git-read result stays fresh. The location of a repo's git
|
|
// directory is effectively static while the app runs, so a short TTL safely
|
|
// absorbs the burst of identical lookups a fresh client (e.g. right after a
|
|
// page reload) fires for every project. Set to 0 to disable caching.
|
|
const createGitReadCacheTtlMs = () => {
|
|
const raw = Number(process.env.OPENCHAMBER_GIT_READ_CACHE_TTL_MS);
|
|
if (Number.isFinite(raw) && raw >= 0) return raw;
|
|
return 30 * 1000;
|
|
};
|
|
|
|
const createGitCheckIgnoreTimeoutMs = () => {
|
|
const raw = Number(process.env.OPENCHAMBER_GIT_CHECK_IGNORE_TIMEOUT_MS);
|
|
if (Number.isFinite(raw) && raw >= 0) return raw;
|
|
return 2500;
|
|
};
|
|
|
|
// Only deterministic, side-effect-free git plumbing path queries are cacheable.
|
|
// Anything outside this allowlist (including any non-git command) runs normally
|
|
// — we never cache arbitrary exec.
|
|
const normalizeCommand = (command) =>
|
|
typeof command === 'string' ? command.trim().replace(/\s+/g, ' ') : '';
|
|
|
|
const isCacheableGitReadCommand = (command) => {
|
|
const normalized = normalizeCommand(command);
|
|
return /^git rev-parse(?: --(?:absolute-git-dir|git-common-dir|show-toplevel)){1,3}$/.test(normalized);
|
|
};
|
|
|
|
// Dual-constraint bound per the project's caching policy (count + bytes). Git
|
|
// rev-parse outputs are tiny, so these ceilings are generous and only guard
|
|
// against pathological growth on long-lived, many-directory deployments.
|
|
const GIT_READ_CACHE_MAX_ENTRIES = 500;
|
|
const GIT_READ_CACHE_MAX_BYTES = 1024 * 1024;
|
|
|
|
const gitReadEntryBytes = (key, result) =>
|
|
key.length + (result?.stdout?.length || 0) + (result?.stderr?.length || 0);
|
|
|
|
const isPathWithinRoot = (resolvedPath, rootPath, path, os) => {
|
|
const resolvedRoot = path.resolve(rootPath || os.homedir());
|
|
const relative = path.relative(resolvedRoot, resolvedPath);
|
|
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const resolveWorkspacePath = ({ targetPath, baseDirectory, path, os, normalizeDirectoryPath, openchamberUserConfigRoot }) => {
|
|
const normalized = normalizeDirectoryPath(targetPath);
|
|
if (!normalized || typeof normalized !== 'string') {
|
|
return { ok: false, error: 'Path is required' };
|
|
}
|
|
|
|
const resolved = path.resolve(normalized);
|
|
const resolvedBase = path.resolve(baseDirectory || os.homedir());
|
|
|
|
if (isPathWithinRoot(resolved, resolvedBase, path, os)) {
|
|
return { ok: true, base: resolvedBase, resolved };
|
|
}
|
|
|
|
if (isPathWithinRoot(resolved, openchamberUserConfigRoot, path, os)) {
|
|
return { ok: true, base: path.resolve(openchamberUserConfigRoot), resolved };
|
|
}
|
|
|
|
return { ok: false, error: 'Path is outside of active workspace' };
|
|
};
|
|
|
|
const resolveWorkspacePathFromWorktrees = async ({ targetPath, baseDirectory, path, os, normalizeDirectoryPath }) => {
|
|
const normalized = normalizeDirectoryPath(targetPath);
|
|
if (!normalized || typeof normalized !== 'string') {
|
|
return { ok: false, error: 'Path is required' };
|
|
}
|
|
|
|
const resolved = path.resolve(normalized);
|
|
const resolvedBase = path.resolve(baseDirectory || os.homedir());
|
|
|
|
try {
|
|
const { getWorktrees } = await import('../git/index.js');
|
|
const worktrees = await getWorktrees(resolvedBase);
|
|
|
|
for (const worktree of worktrees) {
|
|
const candidatePath = typeof worktree?.path === 'string'
|
|
? worktree.path
|
|
: (typeof worktree?.worktree === 'string' ? worktree.worktree : '');
|
|
const candidate = normalizeDirectoryPath(candidatePath);
|
|
if (!candidate) {
|
|
continue;
|
|
}
|
|
const candidateResolved = path.resolve(candidate);
|
|
if (isPathWithinRoot(resolved, candidateResolved, path, os)) {
|
|
return { ok: true, base: candidateResolved, resolved };
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to resolve worktree roots:', error);
|
|
}
|
|
|
|
return { ok: false, error: 'Path is outside of active workspace' };
|
|
};
|
|
|
|
const resolveWorkspacePathFromContext = async ({ req, targetPath, resolveProjectDirectory, path, os, normalizeDirectoryPath, openchamberUserConfigRoot }) => {
|
|
const resolvedProject = await resolveProjectDirectory(req);
|
|
if (!resolvedProject.directory) {
|
|
return { ok: false, error: resolvedProject.error || 'Active workspace is required' };
|
|
}
|
|
|
|
const resolved = resolveWorkspacePath({
|
|
targetPath,
|
|
baseDirectory: resolvedProject.directory,
|
|
path,
|
|
os,
|
|
normalizeDirectoryPath,
|
|
openchamberUserConfigRoot,
|
|
});
|
|
if (resolved.ok || resolved.error !== 'Path is outside of active workspace') {
|
|
return resolved;
|
|
}
|
|
|
|
return resolveWorkspacePathFromWorktrees({
|
|
targetPath,
|
|
baseDirectory: resolvedProject.directory,
|
|
path,
|
|
os,
|
|
normalizeDirectoryPath,
|
|
});
|
|
};
|
|
|
|
const deriveCloneDirectoryName = (remoteUrl) => {
|
|
const remote = typeof remoteUrl === 'string' ? remoteUrl.trim() : '';
|
|
if (!remote) return '';
|
|
const withoutQuery = remote.split(/[?#]/, 1)[0] || remote;
|
|
const match = withoutQuery.match(/([^/:]+?)(?:\.git)?\/?$/);
|
|
return match?.[1]?.trim() || '';
|
|
};
|
|
|
|
const resolveCloneGitIdentity = async (gitIdentityId) => {
|
|
const id = typeof gitIdentityId === 'string' ? gitIdentityId.trim() : '';
|
|
if (!id) return null;
|
|
const { getProfile, getGlobalIdentity } = await import('../git/index.js');
|
|
if (id === 'global') {
|
|
const globalIdentity = await getGlobalIdentity();
|
|
if (!globalIdentity?.userName || !globalIdentity?.userEmail) return null;
|
|
return {
|
|
id: 'global',
|
|
name: 'Global Identity',
|
|
userName: globalIdentity.userName,
|
|
userEmail: globalIdentity.userEmail,
|
|
sshKey: globalIdentity.sshCommand ? globalIdentity.sshCommand.replace('ssh -i ', '') : null,
|
|
};
|
|
}
|
|
return getProfile(id) || null;
|
|
};
|
|
|
|
const escapeCloneSshKeyPath = (sshKeyPath) => {
|
|
const raw = String(sshKeyPath || '').trim();
|
|
if (!raw) return '';
|
|
const normalized = process.platform === 'win32' ? raw.replace(/\\/g, '/') : raw;
|
|
const dangerousChars = /[`$!"';&|<>(){}[\]*?#~]/;
|
|
if (dangerousChars.test(normalized)) {
|
|
throw new Error(`SSH key path contains invalid characters: ${raw}`);
|
|
}
|
|
if (process.platform === 'win32') {
|
|
const driveMatch = normalized.match(/^([A-Za-z]):\//);
|
|
const unixPath = driveMatch ? `/${driveMatch[1].toLowerCase()}${normalized.slice(2)}` : normalized;
|
|
return `'${unixPath}'`;
|
|
}
|
|
return `'${normalized.replace(/'/g, "'\\''")}'`;
|
|
};
|
|
|
|
const resolveReadPathFromContext = async ({ req, targetPath, resolveProjectDirectory, path, os, normalizeDirectoryPath, openchamberUserConfigRoot }) => {
|
|
if (req.query?.allowOutsideWorkspace === 'true') {
|
|
const normalized = normalizeDirectoryPath(targetPath);
|
|
if (!normalized || typeof normalized !== 'string') {
|
|
return { ok: false, error: 'Path is required' };
|
|
}
|
|
const resolved = path.resolve(normalized);
|
|
return { ok: true, base: path.dirname(resolved), resolved };
|
|
}
|
|
|
|
return resolveWorkspacePathFromContext({
|
|
req,
|
|
targetPath,
|
|
resolveProjectDirectory,
|
|
path,
|
|
os,
|
|
normalizeDirectoryPath,
|
|
openchamberUserConfigRoot,
|
|
});
|
|
};
|
|
|
|
const runCommandInDirectory = ({ shell, shellFlag, command, resolvedCwd, spawn, buildAugmentedPath, commandTimeoutMs }) => {
|
|
return new Promise((resolve) => {
|
|
let stdout = '';
|
|
let stderr = '';
|
|
let timedOut = false;
|
|
|
|
const envPath = buildAugmentedPath();
|
|
const execEnv = { ...process.env, PATH: envPath };
|
|
|
|
const child = spawn(shell, [shellFlag, command], {
|
|
cwd: resolvedCwd,
|
|
env: execEnv,
|
|
windowsHide: true,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
const timeout = setTimeout(() => {
|
|
timedOut = true;
|
|
try {
|
|
child.kill('SIGKILL');
|
|
} catch {
|
|
}
|
|
}, commandTimeoutMs);
|
|
|
|
child.stdout?.on('data', (chunk) => {
|
|
stdout += chunk.toString();
|
|
});
|
|
|
|
child.stderr?.on('data', (chunk) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
|
|
child.on('error', (error) => {
|
|
clearTimeout(timeout);
|
|
resolve({
|
|
command,
|
|
success: false,
|
|
exitCode: undefined,
|
|
stdout: stdout.trim(),
|
|
stderr: stderr.trim(),
|
|
error: (error && error.message) || 'Command execution failed',
|
|
});
|
|
});
|
|
|
|
child.on('close', (code, signal) => {
|
|
clearTimeout(timeout);
|
|
const exitCode = typeof code === 'number' ? code : undefined;
|
|
const base = {
|
|
command,
|
|
success: exitCode === 0 && !timedOut,
|
|
exitCode,
|
|
stdout: stdout.trim(),
|
|
stderr: stderr.trim(),
|
|
};
|
|
|
|
if (timedOut) {
|
|
resolve({
|
|
...base,
|
|
success: false,
|
|
error: `Command timed out after ${commandTimeoutMs}ms` + (signal ? ` (${signal})` : ''),
|
|
});
|
|
return;
|
|
}
|
|
|
|
resolve(base);
|
|
});
|
|
});
|
|
};
|
|
|
|
export const registerFsRoutes = (app, dependencies) => {
|
|
const {
|
|
os,
|
|
path,
|
|
fsPromises,
|
|
spawn,
|
|
crypto,
|
|
normalizeDirectoryPath,
|
|
resolveProjectDirectory,
|
|
buildAugmentedPath,
|
|
resolveGitBinaryForSpawn,
|
|
openchamberUserConfigRoot,
|
|
} = dependencies;
|
|
const realpathCache = createRealpathCache({
|
|
realpath: fsPromises.realpath.bind(fsPromises),
|
|
});
|
|
|
|
const execJobs = new Map();
|
|
const commandTimeoutMs = createCommandTimeoutMs();
|
|
const gitReadCacheTtlMs = createGitReadCacheTtlMs();
|
|
const gitCheckIgnoreTimeoutMs = createGitCheckIgnoreTimeoutMs();
|
|
const gitReadCache = new Map();
|
|
const inFlightGitReadCache = new Map();
|
|
|
|
const pruneExecJobs = () => {
|
|
const now = Date.now();
|
|
for (const [jobId, job] of execJobs.entries()) {
|
|
if (!job || typeof job !== 'object') {
|
|
execJobs.delete(jobId);
|
|
continue;
|
|
}
|
|
const updatedAt = typeof job.updatedAt === 'number' ? job.updatedAt : 0;
|
|
if (updatedAt && now - updatedAt > EXEC_JOB_TTL_MS) {
|
|
execJobs.delete(jobId);
|
|
}
|
|
}
|
|
};
|
|
|
|
const pruneGitReadCache = () => {
|
|
if (gitReadCacheTtlMs <= 0) {
|
|
return;
|
|
}
|
|
const now = Date.now();
|
|
for (const [key, entry] of gitReadCache.entries()) {
|
|
if (!entry || now - entry.at > gitReadCacheTtlMs) {
|
|
gitReadCache.delete(key);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Insert with LRU (oldest-first) eviction enforcing both count and byte caps.
|
|
// Map iteration order is insertion order, so deleting+re-setting a key moves
|
|
// it to the most-recently-used position.
|
|
const setGitReadCacheEntry = (key, result) => {
|
|
gitReadCache.delete(key);
|
|
gitReadCache.set(key, { result, at: Date.now() });
|
|
|
|
let totalBytes = 0;
|
|
for (const [k, entry] of gitReadCache) {
|
|
totalBytes += gitReadEntryBytes(k, entry.result);
|
|
}
|
|
while (
|
|
gitReadCache.size > GIT_READ_CACHE_MAX_ENTRIES ||
|
|
(totalBytes > GIT_READ_CACHE_MAX_BYTES && gitReadCache.size > 1)
|
|
) {
|
|
const oldest = gitReadCache.entries().next().value;
|
|
if (!oldest) {
|
|
break;
|
|
}
|
|
totalBytes -= gitReadEntryBytes(oldest[0], oldest[1].result);
|
|
gitReadCache.delete(oldest[0]);
|
|
}
|
|
};
|
|
|
|
// Runs a command, transparently serving/storing cacheable git-read results.
|
|
// Non-cacheable commands always execute and are never stored.
|
|
const runCommandWithGitReadCache = async ({ shell, shellFlag, command, resolvedCwd }) => {
|
|
const cacheable = gitReadCacheTtlMs > 0 && isCacheableGitReadCommand(command);
|
|
const cacheKey = cacheable ? `${resolvedCwd} |