* feat: add draw.io diagram editor integration Embed draw.io editor via react-drawio (MIT, zero deps) for inline editing of .drawio files. Changes auto-save to disk. Includes inline editor in FilesView with Visual/Source toggle, dark mode support, template picker for new files, and chat file attachment integration. * fix: debounce diagram autosave to prevent reload loop * fix: ignore watcher-triggered xml prop changes to prevent reload loop * fix: remove auto-save-to-disk, add manual save button for diagrams Autosave writes triggered file watcher cascade that reloaded the draw.io iframe and reset zoom. Replaced with explicit Save button in the toolbar (floppy disk icon). Editor XML is stable on mount and ignores watcher-triggered prop changes. * fix: remove auto-save write from DiagramView, add save button * fix: hide draw.io save/exit buttons in editor * fix: also hide save-and-exit button * fix: brighten save button styling, add saved confirmation * fix: remove autoSaveStatus toggle on diagram save to prevent toolbar collapse * fix: add local save confirmation state for diagram button * fix: remount drawio iframe on theme change, persisting XML across mounts * fix: clear persisted xml on mount to prevent leaking between files * fix: initialize dark mode synchronously, preserve edits across theme remount * fix: auto-focus drawio iframe on mount/theme-change for keyboard shortcuts * fix: add diagram i18n keys to Traditional Chinese locale * fix: restore upstream HMR host and LAN address support * fix: load sub-agent sessions on bootstrap for sidebar visibility Two-phase session load: first fetch root sessions (for accurate sessionTotal), then fetch all sessions and include child sessions (sub-agent delegations). This ensures sub-agent sessions appear in the sidebar immediately instead of relying on the async global session store. * remove opencode-drawio from PR branch * fix: atomic file writes to prevent concurrent read/write truncation Three-layer defense against the O_TRUNC race: 1. Write side (server): replace direct writeFile with write-to-temp- then-rename. fs.rename is atomic on POSIX. 2. Read side (server): retry up to 3 times with 50ms backoff when readFile returns empty but stat reported non-zero size. 3. FilesView client: refuse to save empty draftContent when the original fileContent was non-empty. * fix(dev): clean up orphaned OpenCode processes on Ctrl+C * fix: allow empty file saves, log warning instead of blocking Replaces the hard block on saving empty content with a console.warn. The atomic write + read retry on the server side handle the O_TRUNC race properly. The previous guard caused a UX regression by silently preventing users from clearing a file and saving. * fix: remove time window from sub-agent fallback for live tasks While a task tool is active, the fallback now matches any session with the correct parentID regardless of creation time. This allows late-appearing child sessions to be found when the OpenCode server is slow or the SSE event pipeline is delayed. The time window is still applied once the task tool has completed, as a final sanity check. * fix: three diagram editor bugs from Greptile review 1. stableXmlRef now resets when xml prop changes — switching between .drawio files renders the correct content. 2. Focus effect only runs on mount, not on isDark changes — theme toggle no longer steals keyboard focus 600ms later. 3. saveDiagram updates xml state after writing — dirty-check guard works correctly for subsequent saves. * fix: route session.created SSE events to correct directory Three-layer fix for sub-agent sessions not appearing in sidebar and inline chat: 1. protocol.js: parseSseEventEnvelope now extracts directory from properties.info.directory (where session.created/updated events carry it) in addition to properties.directory. WS frames relayed to the browser now carry the real directory instead of 'global', so child sessions routed to the correct directory store. 2. event-pipeline.ts: same fallback in resolveEventDirectory for defense-in-depth when SSE events bypass the WS relay. 3. resolveFallbackTaskSessionId.ts: time window lower bound now allows 2s grace before taskStartTime to accommodate server timing jitter (child session creation timestamps consistently precede the tool's recorded start by ~6-9ms), fixing the 'Open subtask' button not rendering in OpenChamber's inline chat. * fix: sub-agent sidebar visibility, file zeroing guard, inline badge fallback - Sync watchdog: periodic child session discovery poll (every 15s) detects sessions created by other OpenCode instances, triggers parent materialization - protocol.js: parseSseEventEnvelope extracts directory from properties.info.directory for session.created/updated events - event-pipeline.ts: same fallback in resolveEventDirectory for defense-in-depth - resolveFallbackTaskSessionId: don't require taskStartTime (cross-OpenCode); pick most recent child when multiple idle candidates exist - readTaskSessionIdFromOutput: parse <task id="ses_xxx"> format from output - FilesView: reinstate empty-draft guard (block save when draftContent='' but fileContent had content) to prevent file zeroing on tab switch * Fix diagram autosave reload loop * Highlight drawio files as XML * Use diff-compatible highlighting for drawio files * Restore drawio file icon mapping * Stabilize drawio source preview toggle --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
1186 lines
40 KiB
JavaScript
1186 lines
40 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} |