diff --git a/agent/coding_context.py b/agent/coding_context.py index 944083fe1b6..78229bc4f55 100644 --- a/agent/coding_context.py +++ b/agent/coding_context.py @@ -83,6 +83,59 @@ _PROJECT_MARKERS = ( # Agent-instruction files surfaced separately from manifests in the snapshot. _CONTEXT_FILES = ("AGENTS.md", "CLAUDE.md", ".cursorrules") +# Source-file extensions that make a git repo a *code* workspace even with no +# manifest. Without this, `git init` on a notes/writing/research folder (a huge +# non-coding use case) would flip the whole session into the coding posture just +# for having a `.git`. A manifest still wins on its own (see `_PROJECT_MARKERS`). +_CODE_EXTENSIONS = frozenset({ + ".py", ".pyi", ".ipynb", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", + ".go", ".rs", ".java", ".kt", ".kts", ".scala", ".rb", ".php", ".c", ".h", + ".cc", ".cpp", ".hpp", ".cs", ".swift", ".m", ".mm", ".dart", ".ex", ".exs", + ".lua", ".sh", ".bash", ".zsh", ".sql", ".vue", ".svelte", ".r", ".jl", + ".hs", ".clj", ".erl", ".pl", +}) + +# Dirs never worth scanning for the code check (deps/build/vcs/venv noise). +_CODE_SCAN_SKIP_DIRS = frozenset({ + ".git", "node_modules", "venv", ".venv", "__pycache__", "dist", "build", + "target", ".next", ".turbo", "vendor", +}) + +# Bounded sweep: a code workspace reveals itself in the first handful of entries. +_CODE_SCAN_MAX_ENTRIES = 500 + + +def _has_code_files(root: Path) -> bool: + """Cheap, bounded check for source files in a repo's top two levels. + + Lets a git repo of loose scripts (no manifest) still read as a code + workspace while a bare notes/writing repo does not. Scans the root and its + immediate subdirectories only, capped at ``_CODE_SCAN_MAX_ENTRIES`` stats — + a handful of readdirs at session start, not a full walk. + """ + seen = 0 + stack = [(root, True)] + while stack: + directory, is_root = stack.pop() + try: + with os.scandir(directory) as entries: + for entry in entries: + seen += 1 + if seen > _CODE_SCAN_MAX_ENTRIES: + return False + name = entry.name + try: + if entry.is_file(): + if os.path.splitext(name)[1].lower() in _CODE_EXTENSIONS: + return True + elif is_root and entry.is_dir() and name not in _CODE_SCAN_SKIP_DIRS and not name.startswith("."): + stack.append((Path(entry.path), False)) + except OSError: + continue + except OSError: + continue + return False + # Lockfile → package manager, checked in priority order. _PY_LOCKFILES = (("uv.lock", "uv"), ("poetry.lock", "poetry"), ("Pipfile.lock", "pipenv")) _JS_LOCKFILES = ( @@ -368,10 +421,16 @@ def _detect_profile_name(mode: str, platform: str, cwd_str: str) -> str: if platform and platform.strip().lower() not in INTERACTIVE_CODING_PLATFORMS: return GENERAL_PROFILE.name cwd = Path(cwd_str) + # A recognized project root (manifest / AGENTS.md / .cursorrules) is a code + # workspace on its own — cheap stat checks, no scan. + if _marker_root(cwd) is not None: + return CODING_PROFILE.name git_root = _git_root(cwd) if git_root is not None and git_root == _home(): git_root = None # dotfiles repo at $HOME — not a code workspace - if git_root is not None or _marker_root(cwd) is not None: + # A bare git repo only counts when it actually holds code, so `git init` on a + # notes/writing/research folder stays in the general posture. + if git_root is not None and _has_code_files(git_root): return CODING_PROFILE.name return GENERAL_PROFILE.name diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 7f1986fbed0..1a87e66cde4 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -243,7 +243,10 @@ KANBAN_GUIDANCE = ( "- **Workspace.** `cd $HERMES_KANBAN_WORKSPACE` first. For a `worktree` kind " "with no `.git`, `git worktree add " "${HERMES_KANBAN_BRANCH:-wt/$HERMES_KANBAN_TASK}` from the main repo, then " - "cd there.\n" + "cd there. For a project-linked task the workspace is a fresh " + "`/.worktrees/` and `$HERMES_KANBAN_BRANCH` a deterministic " + "`/` — the main repo is two levels up, so run " + "`git worktree add` from there.\n" "- **Deliverables.** Files a human wants go in " "`kanban_complete(artifacts=[])` (top-level param; paths in " "`metadata` are NOT uploaded). Files must exist at completion.\n" diff --git a/apps/desktop/components.json b/apps/desktop/components.json index 3ad19817cdd..545360ae7a2 100644 --- a/apps/desktop/components.json +++ b/apps/desktop/components.json @@ -17,5 +17,5 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "iconLibrary": "lucide" + "iconLibrary": "tabler" } diff --git a/apps/desktop/electron/git-repo-scan.cjs b/apps/desktop/electron/git-repo-scan.cjs new file mode 100644 index 00000000000..7b56eed40c2 --- /dev/null +++ b/apps/desktop/electron/git-repo-scan.cjs @@ -0,0 +1,98 @@ +'use strict' + +// Repo-first discovery: walk bounded roots for git repos using only Node's `fs` +// — no native addon, so it just works for anyone who pulls main (no +// electron-rebuild). Mirrors how GitHub Desktop scans: stop at the first `.git` +// (don't descend into a repo), cap depth, and skip heavy non-repo trees so the +// first scan stays fast. Results are cached by the backend after the first run. + +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') + +const fsp = fs.promises + +// Shallow on purpose: real projects live a few levels under home +// (`~/www/repo`, `~/code/org/repo`); deeper `.git` dirs are almost always +// fixtures/vendored/eval checkouts (e.g. `~/www/ha-evals/tasks/*/repo`). Repos +// you actually use but keep deeper still surface via session-derived discovery, +// so this only prunes noise, never repos with history. +const DEFAULT_MAX_DEPTH = 3 +const MAX_CONCURRENCY = 32 + +// Big trees that are never themselves repos and would waste the walk. Anything +// hidden (dotdirs like .cache/.Trash/.npm) is skipped wholesale below, so this +// only needs the non-hidden heavyweights. +const JUNK_DIRS = new Set(['Applications', 'Library', 'node_modules', 'site-packages', 'vendor', 'venv']) + +async function mapLimit(items, limit, fn) { + let cursor = 0 + + async function worker() { + while (cursor < items.length) { + const index = cursor + cursor += 1 + await fn(items[index]) + } + } + + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)) +} + +/** + * Scan `roots` (default: the home dir) for git repositories. Returns deduped + * `{ root, label }` entries. `options.maxDepth` caps recursion (default 3). + */ +async function scanGitRepos(roots, options = {}) { + const maxDepth = Number(options.maxDepth) || DEFAULT_MAX_DEPTH + const searchRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()] + const found = new Map() + + async function walk(dir, depth) { + if (depth > maxDepth) { + return + } + + let entries + try { + entries = await fsp.readdir(dir, { withFileTypes: true }) + } catch { + return // unreadable / permission denied + } + + // A `.git` DIRECTORY marks a real repo root (a main checkout). A `.git` + // FILE is a linked worktree or submodule — those belong to their parent + // repo as lanes, not as separate projects, so we don't list them (and we + // keep descending in case a real repo sits deeper). This is what kills the + // worktree/eval-repo duplicate explosion. + if (entries.some(entry => entry.name === '.git' && entry.isDirectory())) { + const root = dir.replace(/[/\\]+$/, '') + found.set(root, path.basename(root) || root) + + return + } + + const subdirs = [] + for (const entry of entries) { + // Real directories only (skip symlinks to avoid loops), no hidden dirs, no + // known heavy trees. + if (!entry.isDirectory() || entry.name.startsWith('.') || JUNK_DIRS.has(entry.name)) { + continue + } + + subdirs.push(path.join(dir, entry.name)) + } + + await mapLimit(subdirs, MAX_CONCURRENCY, sub => walk(sub, depth + 1)) + } + + await mapLimit( + searchRoots.map(root => String(root || '').trim()).filter(Boolean), + MAX_CONCURRENCY, + root => walk(root, 0) + ) + + return [...found.entries()].map(([root, label]) => ({ label, root })) +} + +module.exports = { scanGitRepos } diff --git a/apps/desktop/electron/git-review-ops.cjs b/apps/desktop/electron/git-review-ops.cjs new file mode 100644 index 00000000000..19b4aecf92d --- /dev/null +++ b/apps/desktop/electron/git-review-ops.cjs @@ -0,0 +1,679 @@ +'use strict' + +// Git ops backing the coding rail + Codex-style review pane. Built on `simple-git` +// (a maintained wrapper around the system git binary — same git the rest of the +// app shells to, no native build) so we read structured status()/diffSummary() +// results instead of hand-parsing porcelain. Reads degrade to null/empty on a +// non-repo / remote backend; mutations reject so the renderer can toast. + +const { execFile } = require('node:child_process') +const fs = require('node:fs/promises') +const path = require('node:path') + +const simpleGit = require('simple-git') + +const { resolveRequestedPathForIpc } = require('./hardening.cjs') + +const COMMIT_CONTEXT_DIFF_MAX_CHARS = 120_000 +const COMMIT_CONTEXT_UNTRACKED_MAX = 80 +const UNTRACKED_LINE_COUNT_CONCURRENCY = 16 +const UNTRACKED_LINE_COUNT_MAX_BYTES = 1024 * 1024 + +// GUI-launched Electron apps on macOS inherit only a minimal PATH (no +// /opt/homebrew/bin or /usr/local/bin), so `gh` — and the `git` gh shells out +// to — aren't found. Augment PATH with the resolved gh dir + the common +// package-manager bins so gh runs the same way it does in a terminal. +function ghEnv(ghBin) { + const extra = [ghBin ? path.dirname(ghBin) : '', '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin'].filter( + dir => dir && dir !== '.' + ) + + return { ...process.env, PATH: [...extra, process.env.PATH].filter(Boolean).join(path.delimiter) } +} + +// Run the `gh` CLI in a repo. Resolves { ok, stdout } so callers branch on +// availability/auth without a throw. gh missing/unauthed → ok:false. +function runGh(args, cwd, ghBin) { + return new Promise(resolve => { + execFile( + ghBin || 'gh', + args, + { cwd, env: ghEnv(ghBin), windowsHide: true, timeout: 30_000, maxBuffer: 8 * 1024 * 1024 }, + (err, stdout) => resolve({ ok: !err, stdout: String(stdout || '') }) + ) + }) +} + +function gitFor(cwd, gitBin) { + return simpleGit({ baseDir: cwd, binary: gitBin || 'git', maxConcurrentProcesses: 4, trimmed: false }) +} + +// simple-git reports renames as `old => new` (and `dir/{old => new}/f`); resolve +// to the NEW path so the row addresses the real file for diff/stage. +function resolveRenamePath(raw) { + const path = String(raw || '').trim() + + if (!path.includes(' => ')) { + return path + } + + const brace = path.match(/^(.*)\{(.*) => (.*)\}(.*)$/) + + if (brace) { + const [, prefix, , to, suffix] = brace + + return `${prefix}${to}${suffix}`.replace(/\/{2,}/g, '/') + } + + return path.split(' => ').pop().trim() +} + +// DiffResult.files → Map (binary files carry no line +// delta). +function countsByPath(summary) { + const map = new Map() + + for (const file of summary.files) { + map.set(resolveRenamePath(file.file), { + added: file.binary ? 0 : file.insertions, + removed: file.binary ? 0 : file.deletions + }) + } + + return map +} + +// Untracked files don't appear in diffSummary(); count insertions from disk so +// the review tree can show +N for new files (matches an all-add diff view). +// Insertions = line count: newline bytes, plus one for a final unterminated +// line. Binary (NUL byte) → 0, mirroring git numstat's "-". +async function untrackedInsertions(cwd, relPath) { + try { + const fullPath = path.join(cwd, relPath) + const stat = await fs.stat(fullPath) + + if (!stat.isFile() || stat.size > UNTRACKED_LINE_COUNT_MAX_BYTES) { + return 0 + } + + const buf = await fs.readFile(fullPath) + + if (buf.includes(0)) { + return 0 + } + + let lines = 0 + + for (const byte of buf) { + if (byte === 10) { + lines++ + } + } + + return buf.length > 0 && buf[buf.length - 1] !== 10 ? lines + 1 : lines + } catch { + return 0 + } +} + +function capText(text, maxChars, label = 'truncated') { + const value = String(text || '') + + if (value.length <= maxChars) { + return value + } + + return `${value.slice(0, maxChars)}\n# ${label}: ${value.length - maxChars} chars omitted\n` +} + +async function fillUntrackedCounts(cwd, files) { + const pending = files.filter(file => file.status === '?' && file.added === 0 && file.removed === 0) + + for (let i = 0; i < pending.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) { + await Promise.all( + pending.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(async file => { + file.added = await untrackedInsertions(cwd, file.path) + }) + ) + } +} + +// Resolve the base ref for "all branch changes": merge-base with the remote +// default branch (origin/HEAD), falling back to common trunk names. +async function branchBase(git) { + const candidates = [] + + try { + const head = (await git.revparse(['--abbrev-ref', 'origin/HEAD'])).trim() + + if (head) { + candidates.push(head) + } + } catch { + // No origin/HEAD configured. + } + + candidates.push('origin/main', 'origin/master', 'main', 'master') + + for (const ref of candidates) { + try { + const base = (await git.raw(['merge-base', 'HEAD', ref])).trim() + + if (base) { + return base + } + } catch { + // Ref doesn't exist; try the next candidate. + } + } + + return null +} + +// Resolve the repo's default branch NAME ("main" / "master" / …), preferring +// the remote's HEAD, then common local trunk names. Null when none is found +// (e.g. a fresh repo with only a feature branch). Used to offer "branch off the +// trunk" regardless of which branch you're currently on. +async function defaultBranchName(git) { + try { + const head = (await git.revparse(['--abbrev-ref', 'origin/HEAD'])).trim() + + // "origin/main" → "main"; skip the bare "origin/HEAD" placeholder. + if (head && head !== 'origin/HEAD') { + return head.replace(/^origin\//, '') + } + } catch { + // No origin/HEAD configured. + } + + // Prefer a local trunk, then a remote-only one (returns the clean name either + // way) so "branch off main" works even before main is checked out locally. + for (const ref of ['refs/heads/main', 'refs/heads/master', 'refs/remotes/origin/main', 'refs/remotes/origin/master']) { + try { + await git.raw(['rev-parse', '--verify', '--quiet', ref]) + + return ref.replace(/^refs\/(?:heads|remotes\/origin)\//, '') + } catch { + // Ref doesn't exist; try the next candidate. + } + } + + return null +} + +// A status file's single-letter classification, preferring the staged (index) +// code over the worktree code; untracked wins (simple-git marks both '?'). +function statusLetter(file) { + if (file.index === '?' || file.working_dir === '?') { + return '?' + } + + const code = file.index && file.index !== ' ' ? file.index : file.working_dir + + return (code || 'M').toUpperCase() +} + +const isStaged = file => Boolean(file.index && file.index !== ' ' && file.index !== '?') + +async function reviewList(repoPath, scope, baseRef, gitBin) { + let cwd + + try { + cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review list' }) + } catch { + return { files: [], base: null } + } + + const git = gitFor(cwd, gitBin) + + try { + if (scope === 'branch' || scope === 'lastTurn') { + const base = scope === 'branch' ? await branchBase(git) : baseRef + + if (!base) { + return { files: [], base: null } + } + + const range = scope === 'branch' ? `${base}...HEAD` : base + const summary = await git.diffSummary([range]) + const files = summary.files.map(file => ({ + path: resolveRenamePath(file.file), + added: file.binary ? 0 : file.insertions, + removed: file.binary ? 0 : file.deletions, + status: 'M', + staged: false + })) + + // "Last turn" also surfaces files created since the baseline (untracked). + if (scope === 'lastTurn') { + const status = await git.status() + + for (const path of status.not_added) { + if (!files.some(f => f.path === path)) { + files.push({ path, added: 0, removed: 0, status: '?', staged: false }) + } + } + } + + files.sort((a, b) => a.path.localeCompare(b.path)) + await fillUntrackedCounts(cwd, files) + + return { files, base } + } + + // Default: uncommitted (staged + unstaged + untracked), one row per path. + const [status, staged, unstaged] = await Promise.all([ + git.status(), + git.diffSummary(['--cached']), + git.diffSummary([]) + ]) + const stagedCounts = countsByPath(staged) + const unstagedCounts = countsByPath(unstaged) + + const files = status.files.map(file => { + const filePath = resolveRenamePath(file.path) + const sc = stagedCounts.get(filePath) || { added: 0, removed: 0 } + const uc = unstagedCounts.get(filePath) || { added: 0, removed: 0 } + + return { + path: filePath, + added: sc.added + uc.added, + removed: sc.removed + uc.removed, + status: statusLetter(file), + staged: isStaged(file) + } + }) + + files.sort((a, b) => a.path.localeCompare(b.path)) + await fillUntrackedCounts(cwd, files) + + return { files, base: null } + } catch { + return { files: [], base: null } + } +} + +async function reviewDiff(repoPath, filePath, scope, baseRef, staged, gitBin) { + let cwd + + try { + cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review diff' }) + } catch { + return '' + } + + const git = gitFor(cwd, gitBin) + const safe = args => git.diff(args).catch(() => '') + + if (scope === 'branch') { + const base = await branchBase(git) + + return base ? safe([`${base}...HEAD`, '--', filePath]) : '' + } + + if (scope === 'lastTurn') { + return baseRef ? safe([baseRef, '--', filePath]) : '' + } + + if (staged) { + return safe(['--cached', '--', filePath]) + } + + const worktree = await safe(['--', filePath]) + + if (worktree.trim()) { + return worktree + } + + // Untracked file: no worktree diff exists, so synthesize an all-add diff via + // --no-index (exits non-zero by design when files differ, so go around + // simple-git's reject-on-nonzero with a raw execFile). + return new Promise(resolve => { + execFile( + gitBin || 'git', + ['diff', '--no-index', '--', '/dev/null', filePath], + { cwd, windowsHide: true, timeout: 30_000, maxBuffer: 32 * 1024 * 1024 }, + (_err, stdout) => resolve(String(stdout || '')) + ) + }) +} + +// Working-tree-vs-HEAD diff for ONE file — the "what changed since the last +// commit" view used by the file preview. Unlike reviewDiff this never synthesizes +// a full-add for a clean tracked file (so a pristine file shows no diff); it only +// all-adds a genuinely untracked file. +async function fileDiffVsHead(repoPath, filePath, gitBin) { + let cwd + + try { + cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'File diff' }) + } catch { + return '' + } + + const git = gitFor(cwd, gitBin) + const head = await git.diff(['HEAD', '--', filePath]).catch(() => '') + + if (head.trim()) { + return head + } + + // No tracked changes vs HEAD. Only synthesize an all-add diff for a file git + // doesn't know yet; a clean tracked file must return empty. + const status = await git.raw(['status', '--porcelain', '--', filePath]).catch(() => '') + + if (!status.trim().startsWith('??')) { + return '' + } + + return new Promise(resolve => { + execFile( + gitBin || 'git', + ['diff', '--no-index', '--', '/dev/null', filePath], + { cwd, windowsHide: true, timeout: 30_000, maxBuffer: 32 * 1024 * 1024 }, + (_err, stdout) => resolve(String(stdout || '')) + ) + }) +} + +async function reviewStage(repoPath, filePath, gitBin) { + const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review stage' }) + + await gitFor(cwd, gitBin).raw(filePath ? ['add', '--', filePath] : ['add', '-A']) + + return { ok: true } +} + +async function reviewUnstage(repoPath, filePath, gitBin) { + const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review unstage' }) + + await gitFor(cwd, gitBin).raw(filePath ? ['reset', '-q', 'HEAD', '--', filePath] : ['reset', '-q', 'HEAD']) + + return { ok: true } +} + +// Discard changes back to the committed state. Destructive — the renderer +// confirms first. Restores tracked files and removes untracked ones. +async function reviewRevert(repoPath, filePath, gitBin) { + const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review revert' }) + const git = gitFor(cwd, gitBin) + + if (filePath) { + await git.raw(['checkout', 'HEAD', '--', filePath]).catch(() => undefined) + await git.raw(['clean', '-fd', '--', filePath]).catch(() => undefined) + } else { + await git.raw(['checkout', 'HEAD', '--', '.']).catch(() => undefined) + await git.raw(['clean', '-fd']).catch(() => undefined) + } + + return { ok: true } +} + +// Resolve a ref to a commit sha (captures the turn baseline for "Last turn"). +async function reviewRevParse(repoPath, ref, gitBin) { + let cwd + + try { + cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review rev-parse' }) + } catch { + return null + } + + try { + return (await gitFor(cwd, gitBin).revparse([ref || 'HEAD'])).trim() || null + } catch { + return null + } +} + +// Commit the working tree. Mirrors VS Code: if nothing is staged, stage +// everything first ("commit all"), then commit. Optionally push afterward, +// setting upstream on the first push. +async function reviewCommit(repoPath, message, push, gitBin) { + const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review commit' }) + const git = gitFor(cwd, gitBin) + const status = await git.status() + + if (status.staged.length === 0) { + await git.raw(['add', '-A']) + } + + await git.commit(message) + + if (push) { + const fresh = await git.status() + + if (fresh.tracking) { + await git.push() + } else if (fresh.current) { + await git.raw(['push', '-u', 'origin', fresh.current]) + } + } + + return { ok: true } +} + +// Gather the context the model needs to draft a commit message: the diff of +// what *will* be committed (staged when anything is staged, else everything +// vs HEAD — mirroring reviewCommit's "stage all when nothing staged" rule), +// the names of untracked files (which carry no diff), and recent commit +// subjects for style. Diff is capped so the payload stays bounded. Reads only. +async function reviewCommitContext(repoPath, gitBin) { + let cwd + + try { + cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review commit context' }) + } catch { + return { diff: '', recent: '' } + } + + const git = gitFor(cwd, gitBin) + const safe = args => git.diff(args).catch(() => '') + + let status + try { + status = await git.status() + } catch { + return { diff: '', recent: '' } + } + + // What will land: staged changes if any, otherwise all tracked changes vs HEAD. + let diff = capText( + status.staged.length > 0 ? await safe(['--cached']) : await safe(['HEAD']), + COMMIT_CONTEXT_DIFF_MAX_CHARS, + 'diff truncated for commit-message generation' + ) + + // Untracked files have no diff — list them so new files aren't invisible. + const untracked = status.not_added || [] + if (untracked.length > 0) { + const visible = untracked.slice(0, COMMIT_CONTEXT_UNTRACKED_MAX) + const omitted = untracked.length - visible.length + const note = + `\n# New (untracked) files:\n${visible.map(p => `# ${p}`).join('\n')}\n` + + (omitted > 0 ? `# ... ${omitted} more omitted\n` : '') + + diff = diff ? `${diff}${note}` : note + } + + const recent = await git.raw(['log', '-n', '10', '--pretty=format:%s']).catch(() => '') + + return { diff: diff || '', recent: String(recent || '').trim() } +} + +async function reviewPush(repoPath, gitBin) { + const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review push' }) + const git = gitFor(cwd, gitBin) + const status = await git.status() + + if (status.tracking) { + await git.push() + } else if (status.current) { + await git.raw(['push', '-u', 'origin', status.current]) + } + + return { ok: true } +} + +// gh availability + auth + whether this branch already has a PR. Reads only; +// drives the PR button's enabled/label state. `ghReady` is false when gh is +// missing OR not authenticated — either way the PR action can't run. +async function reviewShipInfo(repoPath, ghBin) { + let cwd + + try { + cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review ship info' }) + } catch { + return { ghReady: false, pr: null } + } + + const auth = await runGh(['auth', 'status'], cwd, ghBin) + + if (!auth.ok) { + return { ghReady: false, pr: null } + } + + const view = await runGh(['pr', 'view', '--json', 'url,state,number'], cwd, ghBin) + + if (!view.ok) { + // gh exits non-zero when no PR exists for the branch — that's not an error. + return { ghReady: true, pr: null } + } + + try { + const pr = JSON.parse(view.stdout) + + return { ghReady: true, pr: pr && pr.url ? { url: pr.url, state: pr.state, number: pr.number } : null } + } catch { + return { ghReady: true, pr: null } + } +} + +// Create a PR for the current branch (pushing first so gh has a remote ref), +// letting gh fill title/body from the commits. Returns the new PR url. +async function reviewCreatePr(repoPath, gitBin, ghBin) { + const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review create PR' }) + + await reviewPush(repoPath, gitBin).catch(() => undefined) + + const created = await runGh(['pr', 'create', '--fill'], cwd, ghBin) + + if (!created.ok) { + throw new Error('gh pr create failed (is gh installed and authenticated?)') + } + + const url = created.stdout.trim().split('\n').filter(Boolean).pop() || '' + + return { url } +} + +// Compact working-tree status for the composer coding rail: branch, ahead/behind, +// per-state change counts, +/- vs HEAD, and a capped changed-file list. +async function repoStatus(repoPath, gitBin) { + let cwd + + try { + cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Repo status' }) + } catch { + return null + } + + // Session cwds can point at a deleted worktree for a moment (or forever in a + // stale row). simple-git throws at construction time on a missing baseDir, so + // fail soft and hide the coding rail instead of spamming IPC handler errors. + try { + const stat = await fs.stat(cwd) + if (!stat.isDirectory()) { + return null + } + } catch { + return null + } + + let git + try { + git = gitFor(cwd, gitBin) + } catch { + return null + } + let status + + try { + status = await git.status() + } catch { + // Not a repo / git unavailable / remote backend. + return null + } + + const detached = typeof status.detached === 'boolean' ? status.detached : !status.current + const files = status.files.map(file => ({ + path: file.path, + staged: isStaged(file), + unstaged: Boolean(file.working_dir && file.working_dir !== ' ' && file.working_dir !== '?'), + untracked: file.index === '?' || file.working_dir === '?', + conflicted: file.index === 'U' || file.working_dir === 'U' + })) + + const result = { + branch: detached ? null : status.current || null, + defaultBranch: await defaultBranchName(git), + detached, + ahead: status.ahead || 0, + behind: status.behind || 0, + staged: files.filter(f => f.staged).length, + unstaged: files.filter(f => f.unstaged).length, + untracked: status.not_added.length, + conflicted: status.conflicted.length, + changed: files.length, + added: 0, + removed: 0, + files: files.slice(0, 200) + } + + // +/- vs HEAD (staged + unstaged tracked changes). No HEAD yet → leave 0. + try { + const summary = await git.diffSummary(['HEAD']) + result.added = summary.insertions + result.removed = summary.deletions + } catch { + // No commits yet. + } + + // `git diff HEAD` ignores untracked files, so a turn that only creates new + // files (the common case — a fresh module, a demo dir) showed +0 in the rail + // while the review pane counted them. Fold untracked insertions into `added` + // so the rail matches reality. Bounded (size cap + concurrency) like the + // review tree; only the capped file slice is counted so a huge untracked tree + // can't stall the probe. + try { + const untracked = status.not_added.slice(0, 500) + for (let i = 0; i < untracked.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) { + const batch = await Promise.all( + untracked.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(path => untrackedInsertions(cwd, path)) + ) + result.added += batch.reduce((sum, n) => sum + n, 0) + } + } catch { + // Best-effort: a probe failure just leaves untracked lines uncounted. + } + + return result +} + +module.exports = { + branchBase, + fileDiffVsHead, + repoStatus, + resolveRenamePath, + reviewCommit, + reviewCommitContext, + reviewCreatePr, + reviewDiff, + reviewList, + reviewPush, + reviewRevParse, + reviewRevert, + reviewShipInfo, + reviewStage, + reviewUnstage +} diff --git a/apps/desktop/electron/git-review-ops.test.cjs b/apps/desktop/electron/git-review-ops.test.cjs new file mode 100644 index 00000000000..fdddd13df78 --- /dev/null +++ b/apps/desktop/electron/git-review-ops.test.cjs @@ -0,0 +1,22 @@ +'use strict' + +const assert = require('node:assert/strict') +const test = require('node:test') + +const { resolveRenamePath } = require('./git-review-ops.cjs') + +test('resolveRenamePath: plain path is unchanged', () => { + assert.equal(resolveRenamePath('src/a.ts'), 'src/a.ts') +}) + +test('resolveRenamePath: simple rename resolves to the new path', () => { + assert.equal(resolveRenamePath('old.ts => new.ts'), 'new.ts') +}) + +test('resolveRenamePath: brace rename resolves to the new path', () => { + assert.equal(resolveRenamePath('src/{old => new}/file.ts'), 'src/new/file.ts') +}) + +test('resolveRenamePath: brace rename collapsing a segment', () => { + assert.equal(resolveRenamePath('src/{lib => }/file.ts'), 'src/file.ts') +}) diff --git a/apps/desktop/electron/git-worktree-ops.cjs b/apps/desktop/electron/git-worktree-ops.cjs new file mode 100644 index 00000000000..486686e4e4a --- /dev/null +++ b/apps/desktop/electron/git-worktree-ops.cjs @@ -0,0 +1,339 @@ +'use strict' + +// Git-driven worktree operations for the desktop "Start work" flow: spin up a +// fresh worktree the lightest way (`git worktree add -b`), list real worktrees, +// and remove them. Git is the source of truth; the renderer just drives these. + +const path = require('node:path') +const fs = require('node:fs') +const { execFile } = require('node:child_process') + +const { resolveRequestedPathForIpc } = require('./hardening.cjs') + +function runGit(gitBin, args, cwd) { + return new Promise((resolve, reject) => { + execFile( + gitBin, + args, + { cwd, windowsHide: true, timeout: 30_000, maxBuffer: 8 * 1024 * 1024 }, + (err, stdout, stderr) => { + if (err) { + err.stderr = String(stderr || '') + reject(err) + + return + } + + resolve(String(stdout || '')) + } + ) + }) +} + +// Parse `git worktree list --porcelain`. The first record is the main worktree. +function parseWorktrees(out) { + const trees = [] + let cur = null + + for (const line of out.split('\n')) { + if (line.startsWith('worktree ')) { + if (cur) { + trees.push(cur) + } + + cur = { path: line.slice(9).trim(), branch: null, detached: false, bare: false, locked: false } + } else if (!cur) { + continue + } else if (line.startsWith('branch ')) { + cur.branch = line.slice(7).trim().replace(/^refs\/heads\//, '') + } else if (line === 'detached') { + cur.detached = true + } else if (line === 'bare') { + cur.bare = true + } else if (line.startsWith('locked')) { + cur.locked = true + } + } + + if (cur) { + trees.push(cur) + } + + return trees +} + +async function listWorktrees(repoPath, gitBin) { + let resolved + + try { + resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree list' }) + } catch { + return [] + } + + try { + const out = await runGit(gitBin, ['worktree', 'list', '--porcelain'], resolved) + + return parseWorktrees(out).map((tree, index) => ({ + path: tree.path, + branch: tree.branch, + isMain: index === 0, + detached: tree.detached, + locked: tree.locked + })) + } catch { + return [] + } +} + +// A git-ref-safe branch name (spaces → "-", drop forbidden chars, trim edges), +// or "" when nothing usable remains. Mirrors the renderer's `gitRef`, so a bad +// value can't reach `git` no matter the caller (the GUI also enforces live). +function sanitizeBranch(name) { + return String(name || '') + .replace(/\s+/g, '-') + .replace(/[^\w./-]/g, '') + .replace(/-{2,}/g, '-') + .replace(/\/{2,}/g, '/') + .replace(/\.{2,}/g, '.') + .replace(/^[-./]+|[-./]+$/g, '') +} + +function slugify(name) { + const slug = String(name || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40) + .replace(/-+$/g, '') + + return slug || 'work' +} + +const TRUNK_BRANCHES = ['main', 'master'] + +async function gitLine(gitBin, args, cwd) { + try { + return (await runGit(gitBin, args, cwd)).trim() + } catch { + return '' + } +} + +async function defaultBranch(gitBin, cwd) { + const remote = (await gitLine(gitBin, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], cwd)).replace( + /^origin\//, + '' + ) + + if (remote) { + return remote + } + + const configured = await gitLine(gitBin, ['config', '--get', 'init.defaultBranch'], cwd) + + if (configured) { + return configured + } + + for (const branch of TRUNK_BRANCHES) { + if (await gitLine(gitBin, ['show-ref', '--verify', `refs/heads/${branch}`], cwd)) { + return branch + } + } + + return '' +} + +// A brand-new project folder isn't a git repo — and a freshly-init'd one has no +// commit to branch from — so `git worktree add` would fail. Make the dir a repo +// with a root commit on the user's behalf so worktrees "just work". No-op for a +// repo that already has commits; never touches the user's files (the seed commit +// is `--allow-empty`), and never inits a dir that already lives inside a repo. +async function ensureGitRepo(gitBin, dir) { + let needsRoot = false + + try { + const inside = (await runGit(gitBin, ['rev-parse', '--is-inside-work-tree'], dir)).trim() + + if (inside !== 'true') { + await runGit(gitBin, ['init'], dir) + needsRoot = true + } else { + // Repo exists; a worktree still needs a HEAD to branch from. + try { + await runGit(gitBin, ['rev-parse', '--verify', 'HEAD'], dir) + } catch { + needsRoot = true + } + } + } catch { + await runGit(gitBin, ['init'], dir) + needsRoot = true + } + + if (needsRoot) { + // Inline identity so the seed commit lands even with no global git config. + await runGit( + gitBin, + ['-c', 'user.email=hermes@localhost', '-c', 'user.name=Hermes', 'commit', '--allow-empty', '-m', 'Initial commit'], + dir + ) + } +} + +// Resolve the repo's MAIN worktree root, so `.worktrees/` always nests under the +// primary checkout even when called from a linked worktree. +async function mainRoot(gitBin, cwd) { + const list = await listWorktrees(cwd, gitBin) + const main = list.find(tree => tree.isMain) + + return main ? main.path : cwd +} + +function uniqueDir(base) { + let dir = base + let n = 1 + + while (fs.existsSync(dir)) { + n += 1 + dir = `${base}-${n}` + } + + return dir +} + +async function addExistingBranchWorktree(gitBin, root, name) { + const branch = sanitizeBranch(name) + + if (!branch) { + throw new Error('Branch name is required.') + } + + if (branch === (await defaultBranch(gitBin, root))) { + await runGit(gitBin, ['switch', branch], root) + + return { path: root, branch, repoRoot: root } + } + + const dir = uniqueDir(path.join(root, '.worktrees', slugify(branch))) + await runGit(gitBin, ['worktree', 'add', dir, branch], root) + + return { path: dir, branch, repoRoot: root } +} + +async function addWorktree(repoPath, options, gitBin) { + const resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree add' }) + // A new project's folder may not be a git repo yet — init it (with a root + // commit) so the worktree has something to branch from. + await ensureGitRepo(gitBin, resolved) + const root = await mainRoot(gitBin, resolved) + const opts = options || {} + + if (opts.existingBranch) { + return addExistingBranchWorktree(gitBin, root, opts.existingBranch) + } + + const slug = slugify(opts.name || `work-${Date.now().toString(36)}`) + const branch = sanitizeBranch(opts.branch) || `hermes/${slug}` + const dir = uniqueDir(path.join(root, '.worktrees', slug)) + + const args = ['worktree', 'add', '-b', branch, dir] + + if (opts.base) { + args.push(String(opts.base)) + } + + try { + await runGit(gitBin, args, root) + } catch (err) { + // Branch name may already exist — retry checking out the existing branch + // into a fresh worktree dir instead of failing the whole flow. + if (/already exists/i.test(err.stderr || '')) { + await runGit(gitBin, ['worktree', 'add', dir, branch], root) + } else { + throw err + } + } + + return { path: dir, branch, repoRoot: root } +} + +async function removeWorktree(repoPath, worktreePath, options, gitBin) { + const resolvedRepo = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree remove (repo)' }) + const resolvedTree = resolveRequestedPathForIpc(worktreePath, { purpose: 'Worktree remove (tree)' }) + const root = await mainRoot(gitBin, resolvedRepo) + const args = ['worktree', 'remove'] + + if (options && options.force) { + args.push('--force') + } + + args.push(resolvedTree) + await runGit(gitBin, args, root) + + return { removed: resolvedTree } +} + +// List local branches for the "convert a branch into a worktree" picker, most +// recently committed first. Each carries whether it's already checked out in a +// worktree and, when checked out, that worktree's path. Empty on a non-repo / +// remote backend where the probe can't run. +async function listBranches(repoPath, gitBin) { + let resolved + + try { + resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Branch list' }) + } catch { + return [] + } + + try { + const out = await runGit( + gitBin, + ['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate', 'refs/heads'], + resolved + ) + const trees = await listWorktrees(resolved, gitBin) + const pathByBranch = new Map(trees.filter(tree => tree.branch).map(tree => [tree.branch, tree.path])) + const trunk = await defaultBranch(gitBin, resolved) + + return out + .split('\n') + .map(line => line.trim()) + .filter(Boolean) + .map(name => ({ + name, + checkedOut: pathByBranch.has(name), + isDefault: Boolean(trunk && name === trunk), + worktreePath: pathByBranch.get(name) || null + })) + } catch { + return [] + } +} + +async function switchBranch(repoPath, branch, gitBin) { + const resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Branch switch' }) + const target = sanitizeBranch(branch) + + if (!target) { + throw new Error('Branch name is required.') + } + + await runGit(gitBin, ['switch', target], resolved) + + return { branch: target } +} + +module.exports = { + addWorktree, + ensureGitRepo, + listBranches, + listWorktrees, + parseWorktrees, + removeWorktree, + sanitizeBranch, + switchBranch +} diff --git a/apps/desktop/electron/git-worktree-ops.test.cjs b/apps/desktop/electron/git-worktree-ops.test.cjs new file mode 100644 index 00000000000..b0865d4ad77 --- /dev/null +++ b/apps/desktop/electron/git-worktree-ops.test.cjs @@ -0,0 +1,214 @@ +'use strict' + +const assert = require('node:assert/strict') +const { execFileSync } = require('node:child_process') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const test = require('node:test') + +const { + addWorktree, + ensureGitRepo, + listBranches, + parseWorktrees, + sanitizeBranch, + switchBranch +} = require('./git-worktree-ops.cjs') + +test('sanitizeBranch: spaces → hyphens, forbidden chars dropped, edges trimmed', () => { + assert.equal(sanitizeBranch('beach vibes'), 'beach-vibes') + assert.equal(sanitizeBranch('feat/cool thing'), 'feat/cool-thing') + assert.equal(sanitizeBranch(' wip~^:? '), 'wip') + assert.equal(sanitizeBranch('///'), '') +}) + +test('parseWorktrees: main checkout + linked worktree', () => { + const out = [ + 'worktree /repo', + 'HEAD abc123', + 'branch refs/heads/main', + '', + 'worktree /repo/.worktrees/feat', + 'HEAD def456', + 'branch refs/heads/hermes/feat', + '' + ].join('\n') + + const trees = parseWorktrees(out) + + assert.equal(trees.length, 2) + assert.equal(trees[0].path, '/repo') + assert.equal(trees[0].branch, 'main') + assert.equal(trees[1].path, '/repo/.worktrees/feat') + assert.equal(trees[1].branch, 'hermes/feat') +}) + +test('parseWorktrees: detached + locked flags', () => { + const out = ['worktree /repo/wt', 'HEAD abc', 'detached', 'locked reason', ''].join('\n') + const trees = parseWorktrees(out) + + assert.equal(trees.length, 1) + assert.equal(trees[0].detached, true) + assert.equal(trees[0].locked, true) + assert.equal(trees[0].branch, null) +}) + +test('parseWorktrees: empty input', () => { + assert.deepEqual(parseWorktrees(''), []) +}) + +test('ensureGitRepo: inits a plain dir with a root commit so worktrees branch', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-wt-')) + const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim() + + try { + await ensureGitRepo('git', dir) + assert.match(git('rev-parse', '--verify', 'HEAD'), /^[0-9a-f]{7,}$/) + + // The whole point: a worktree can now branch off the seeded root commit. + execFileSync('git', ['worktree', 'add', '-b', 'wt', path.join(dir, '.worktrees', 'wt')], { cwd: dir }) + assert.ok(fs.existsSync(path.join(dir, '.worktrees', 'wt'))) + + // Idempotent: an already-committed repo gets no extra commit. + await ensureGitRepo('git', dir) + assert.equal(git('rev-list', '--count', 'HEAD'), '1') + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('switchBranch: switches a normal checkout branch', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-switch-')) + const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim() + + try { + await ensureGitRepo('git', dir) + execFileSync('git', ['branch', 'feature'], { cwd: dir }) + + await switchBranch(dir, 'feature', 'git') + + assert.equal(git('branch', '--show-current'), 'feature') + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('listBranches: lists locals and flags the checked-out branch', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-branches-')) + + try { + await ensureGitRepo('git', dir) + const current = execFileSync('git', ['branch', '--show-current'], { cwd: dir }).toString().trim() + execFileSync('git', ['branch', 'feature'], { cwd: dir }) + + const branches = await listBranches(dir, 'git') + const names = branches.map(b => b.name).sort() + + assert.deepEqual(names, [current, 'feature'].sort()) + // The repo's own checkout is flagged; the unused branch is convertible. + assert.equal(branches.find(b => b.name === current).checkedOut, true) + assert.equal(branches.find(b => b.name === current).isDefault, true) + assert.equal(fs.realpathSync(branches.find(b => b.name === current).worktreePath), fs.realpathSync(dir)) + assert.equal(branches.find(b => b.name === 'feature').checkedOut, false) + assert.equal(branches.find(b => b.name === 'feature').isDefault, false) + assert.equal(branches.find(b => b.name === 'feature').worktreePath, null) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('listBranches: flags a free default branch as default, not checked out', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-branches-default-')) + const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim() + + try { + await ensureGitRepo('git', dir) + const trunk = git('branch', '--show-current') + execFileSync('git', ['switch', '-c', 'rawr'], { cwd: dir }) + + const branches = await listBranches(dir, 'git') + const defaultBranch = branches.find(b => b.name === trunk) + + assert.equal(defaultBranch.checkedOut, false) + assert.equal(defaultBranch.isDefault, true) + assert.equal(defaultBranch.worktreePath, null) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('listBranches: a branch claimed by a worktree is flagged checked out', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-branches-wt-')) + + try { + await ensureGitRepo('git', dir) + execFileSync('git', ['branch', 'feature'], { cwd: dir }) + // addWorktree converts the existing "feature" branch into a worktree. + const result = await addWorktree(dir, { existingBranch: 'feature' }, 'git') + + assert.equal(result.branch, 'feature') + assert.ok(fs.existsSync(result.path)) + + const branches = await listBranches(dir, 'git') + + assert.equal(branches.find(b => b.name === 'feature').checkedOut, true) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('listBranches: empty on a non-repo path', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-nonrepo-')) + + try { + assert.deepEqual(await listBranches(dir, 'git'), []) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('addWorktree: existingBranch checks the branch out without a new branch', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-convert-')) + const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim() + + try { + await ensureGitRepo('git', dir) + execFileSync('git', ['branch', 'cool/feature'], { cwd: dir }) + + const before = git('branch', '--list').split('\n').length + const result = await addWorktree(dir, { existingBranch: 'cool/feature' }, 'git') + + // No new branch was created — only the existing one is checked out. + assert.equal(git('branch', '--list').split('\n').length, before) + assert.equal(result.branch, 'cool/feature') + // Dir is named off the branch slug, nested under the main repo's .worktrees. + assert.match(result.path, /[/\\]\.worktrees[/\\]cool-feature/) + assert.equal( + execFileSync('git', ['branch', '--show-current'], { cwd: result.path }).toString().trim(), + 'cool/feature' + ) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('addWorktree: existing default branch switches the main checkout, not .worktrees/main', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-convert-default-')) + const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim() + + try { + await ensureGitRepo('git', dir) + const trunk = git('branch', '--show-current') + execFileSync('git', ['switch', '-c', 'rawr'], { cwd: dir }) + + const result = await addWorktree(dir, { existingBranch: trunk }, 'git') + + assert.equal(result.branch, trunk) + assert.equal(fs.realpathSync(result.path), fs.realpathSync(dir)) + assert.equal(git('branch', '--show-current'), trunk) + assert.equal(fs.existsSync(path.join(dir, '.worktrees', trunk)), false) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/electron/git-worktrees.cjs b/apps/desktop/electron/git-worktrees.cjs deleted file mode 100644 index 570397b2c95..00000000000 --- a/apps/desktop/electron/git-worktrees.cjs +++ /dev/null @@ -1,174 +0,0 @@ -'use strict' - -// Resolve git-worktree relationships for a set of session cwds, reading git's -// on-disk metadata directly (no `git` spawn per path): -// -// - A normal checkout has a `.git` DIRECTORY at its root → it's the main -// worktree; its repo root IS that directory's parent. -// - A linked worktree has a `.git` FILE: `gitdir: /.git/worktrees/`. -// That admin dir's `commondir` points back at the shared `/.git`, whose -// parent is the main repo root. -// -// Grouping by repoRoot therefore clusters a repo's main checkout with all of its -// linked worktrees, regardless of how the worktree directories are named. The -// branch (read from the worktree's own HEAD) gives each worktree a meaningful -// label. - -const fs = require('node:fs') -const path = require('node:path') -const { resolveRequestedPathForIpc } = require('./hardening.cjs') - -// Walk up from `start` to the nearest ancestor that carries a `.git` entry -// (file for a linked worktree, dir for the main checkout). Capped so a stray -// path can't loop forever. -function findGitHost(start, fsImpl) { - let dir = start - - for (let i = 0; i < 64; i += 1) { - const dotgit = path.join(dir, '.git') - - try { - if (fsImpl.existsSync(dotgit)) { - return dir - } - } catch { - return null - } - - const parent = path.dirname(dir) - - if (parent === dir) { - return null - } - - dir = parent - } - - return null -} - -function readBranch(gitDir, fsImpl) { - try { - const head = fsImpl.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim() - const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/) - - if (ref) { - return ref[1] - } - - // Detached HEAD: surface a short sha so the worktree still gets a label. - return /^[0-9a-f]{7,40}$/i.test(head) ? head.slice(0, 8) : null - } catch { - return null - } -} - -// Given the directory that owns the `.git` entry, resolve its worktree identity. -function resolveFromHost(host, fsImpl) { - const dotgit = path.join(host, '.git') - let stat - - try { - stat = fsImpl.statSync(dotgit) - } catch { - return null - } - - if (stat.isDirectory()) { - return { - repoRoot: host, - worktreeRoot: host, - isMainWorktree: true, - branch: readBranch(dotgit, fsImpl) - } - } - - // Linked worktree: `.git` is a file pointing at the admin dir. - let contents - - try { - contents = fsImpl.readFileSync(dotgit, 'utf8').trim() - } catch { - return null - } - - const match = contents.match(/^gitdir:\s*(.+)$/m) - - if (!match) { - return null - } - - const adminDir = path.resolve(host, match[1].trim()) - - // `commondir` resolves to the shared `/.git`; fall back to walking two - // levels up from `/.git/worktrees/` if it's missing. - let commonDir - - try { - const rel = fsImpl.readFileSync(path.join(adminDir, 'commondir'), 'utf8').trim() - commonDir = path.resolve(adminDir, rel) - } catch { - commonDir = path.dirname(path.dirname(adminDir)) - } - - return { - repoRoot: path.dirname(commonDir), - worktreeRoot: host, - isMainWorktree: false, - branch: readBranch(adminDir, fsImpl) - } -} - -function resolveWorktree(startPath, fsImpl = fs) { - let resolved - - try { - resolved = resolveRequestedPathForIpc(startPath, { purpose: 'Worktree lookup' }) - } catch { - return null - } - - let start = resolved - - try { - const stat = fsImpl.statSync(resolved) - - if (!stat.isDirectory()) { - start = path.dirname(resolved) - } - } catch { - return null - } - - const host = findGitHost(start, fsImpl) - - if (!host) { - return null - } - - return resolveFromHost(host, fsImpl) -} - -// Batch entry point for the renderer: maps each requested cwd to its worktree -// info (or null when it isn't inside a git checkout / can't be read). Dedupes so -// many sessions sharing a cwd cost one lookup. -async function worktreesForIpc(cwds, options = {}) { - const fsImpl = options.fs || fs - const list = Array.isArray(cwds) ? cwds : [] - const out = {} - - for (const cwd of list) { - if (typeof cwd !== 'string' || !cwd.trim() || cwd in out) { - continue - } - - out[cwd] = resolveWorktree(cwd, fsImpl) - } - - return out -} - -module.exports = { - resolveWorktree, - worktreesForIpc -} diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index c07b988e170..67b31eb4d75 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -55,7 +55,23 @@ const { buildRelaunchScript } = require('./update-relaunch.cjs') const { gitRootForIpc } = require('./git-root.cjs') -const { worktreesForIpc } = require('./git-worktrees.cjs') +const { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } = require('./git-worktree-ops.cjs') +const { + fileDiffVsHead, + repoStatus, + reviewCommit, + reviewCommitContext, + reviewCreatePr, + reviewDiff, + reviewList, + reviewPush, + reviewRevParse, + reviewRevert, + reviewShipInfo, + reviewStage, + reviewUnstage +} = require('./git-review-ops.cjs') +const { scanGitRepos } = require('./git-repo-scan.cjs') const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs') const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs') const { runRebuildWithRetry } = require('./update-rebuild.cjs') @@ -1632,6 +1648,30 @@ function resolveGitBinary() { return _gitBinaryCache } +// resolveGhBinary — locate the GitHub CLI. GUI-launched apps get a minimal PATH +// that omits Homebrew (/opt/homebrew/bin, /usr/local/bin) where `gh` usually +// lives, so a bare spawn('gh') ENOENTs even though `gh` works in the user's +// terminal. Check the common install locations first, then PATH. Cached. +let _ghBinaryCache = null +function resolveGhBinary() { + if (_ghBinaryCache) return _ghBinaryCache + + const candidates = [] + + if (IS_WINDOWS) { + candidates.push(path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'GitHub CLI', 'gh.exe')) + if (process.env.LOCALAPPDATA) { + candidates.push(path.join(process.env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links', 'gh.exe')) + } + } else { + const home = app.getPath('home') + candidates.push('/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh', path.join(home, '.local', 'bin', 'gh')) + } + + _ghBinaryCache = candidates.find(fileExists) || findOnPath('gh') || 'gh' + return _ghBinaryCache +} + function recentHermesLog() { return hermesLog.slice(-20).join('\n') } @@ -3062,7 +3102,6 @@ async function ensureRuntime(backend) { return applyWindowsNoConsoleSpawnHints(backend) } - function fetchJson(url, token, options = {}) { return new Promise((resolve, reject) => { const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)) @@ -6756,7 +6795,164 @@ ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dir ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath)) -ipcMain.handle('hermes:fs:worktrees', async (_event, cwds) => worktreesForIpc(cwds)) +// Reveal a path in the OS file manager (Finder / Explorer / Files). +ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => { + const target = String(targetPath || '').trim() + + if (!target) { + return false + } + + try { + shell.showItemInFolder(target) + + return true + } catch { + return false + } +}) + +// Rename a file/folder in place. The renderer passes the existing path + a new +// base name; the destination is resolved in the SAME parent dir so a rename can +// never move the item elsewhere or traverse out. Rejects on a name collision. +ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => { + const src = String(targetPath || '').trim() + const name = String(newName || '').trim() + + if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) { + throw new Error('Invalid rename') + } + + const dst = path.join(path.dirname(src), name) + + if (dst === src) { + return { path: dst } + } + + if (fs.existsSync(dst)) { + throw new Error(`"${name}" already exists`) + } + + await fs.promises.rename(src, dst) + + return { path: dst } +}) + +// Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path +// is hardened (resolveRequestedPathForIpc) and the parent must already exist — +// this never creates directory trees or escapes the allowed roots, and content +// is size-capped so it can't be abused as a bulk-write primitive. +ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => { + const raw = String(filePath || '').trim() + + if (!raw) { + throw new Error('Invalid path') + } + + const text = String(content ?? '') + + if (text.length > 1_000_000) { + throw new Error('Content too large') + } + + const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' }) + + if (!directoryExists(path.dirname(resolved))) { + throw new Error('Parent directory does not exist') + } + + await fs.promises.writeFile(resolved, text, 'utf8') + + return { path: resolved } +}) + +// Move a file/folder to the OS trash (recoverable) — the VS Code "Delete" +// default. `shell.trashItem` routes to Finder/Explorer/Files trash per platform. +ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => { + const target = String(targetPath || '').trim() + + if (!target) { + throw new Error('Invalid delete') + } + + await shell.trashItem(target) + + return true +}) + +// Git-driven worktree management ("Start work" flow). Errors surface to the +// renderer as rejected promises so it can toast a friendly message. +ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => + listWorktrees(repoPath, resolveGitBinary()) +) + +ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) => + addWorktree(repoPath, options || {}, resolveGitBinary()) +) + +ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) => + removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary()) +) + +ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) => + switchBranch(repoPath, branch, resolveGitBinary()) +) + +ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => + listBranches(repoPath, resolveGitBinary()) +) + +// Compact repo status (branch, ahead/behind, change counts + files) for the +// composer coding rail. Returns null on a non-repo / remote backend so the rail +// hides cleanly rather than erroring. +ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary())) + +// Codex-style review pane: list changed files for a scope, fetch one file's +// unified diff, and stage / unstage / revert. Reads return empty on failure; +// mutations reject so the renderer can toast. +ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) => + reviewList(repoPath, scope, baseRef, resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) => + reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary()) +) +// Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view). +ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) => + fileDiffVsHead(repoPath, filePath, resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) => + reviewStage(repoPath, filePath ?? null, resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) => + reviewUnstage(repoPath, filePath ?? null, resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) => + reviewRevert(repoPath, filePath ?? null, resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) => + reviewRevParse(repoPath, ref, resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) => + reviewCommit(repoPath, message, Boolean(push), resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) => + reviewCommitContext(repoPath, resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary())) +ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary())) +ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) => + reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary()) +) + +// Repo-first project discovery: scan bounded roots for git repos (pure fs walk, +// no native addon). Never throws to the renderer — failures yield an empty list. +ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => { + try { + return await scanGitRepos(roots || [], options || {}) + } catch { + return [] + } +}) ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { if (!nodePty) { diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 4edba83cf82..aa8bcc16128 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -82,7 +82,35 @@ contextBridge.exposeInMainWorld('hermesDesktop', { getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'), readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath), gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath), - worktrees: cwds => ipcRenderer.invoke('hermes:fs:worktrees', cwds), + revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath), + renamePath: (targetPath, newName) => ipcRenderer.invoke('hermes:fs:rename', targetPath, newName), + writeTextFile: (filePath, content) => ipcRenderer.invoke('hermes:fs:writeText', filePath, content), + trashPath: targetPath => ipcRenderer.invoke('hermes:fs:trash', targetPath), + git: { + worktreeList: repoPath => ipcRenderer.invoke('hermes:git:worktreeList', repoPath), + worktreeAdd: (repoPath, options) => ipcRenderer.invoke('hermes:git:worktreeAdd', repoPath, options), + worktreeRemove: (repoPath, worktreePath, options) => + ipcRenderer.invoke('hermes:git:worktreeRemove', repoPath, worktreePath, options), + branchSwitch: (repoPath, branch) => ipcRenderer.invoke('hermes:git:branchSwitch', repoPath, branch), + branchList: repoPath => ipcRenderer.invoke('hermes:git:branchList', repoPath), + repoStatus: repoPath => ipcRenderer.invoke('hermes:git:repoStatus', repoPath), + fileDiff: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:fileDiff', repoPath, filePath), + scanRepos: (roots, options) => ipcRenderer.invoke('hermes:git:scanRepos', roots, options), + review: { + list: (repoPath, scope, baseRef) => ipcRenderer.invoke('hermes:git:review:list', repoPath, scope, baseRef), + diff: (repoPath, filePath, scope, baseRef, staged) => + ipcRenderer.invoke('hermes:git:review:diff', repoPath, filePath, scope, baseRef, staged), + stage: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:stage', repoPath, filePath), + unstage: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:unstage', repoPath, filePath), + revert: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:revert', repoPath, filePath), + revParse: (repoPath, ref) => ipcRenderer.invoke('hermes:git:review:revParse', repoPath, ref), + commit: (repoPath, message, push) => ipcRenderer.invoke('hermes:git:review:commit', repoPath, message, push), + commitContext: repoPath => ipcRenderer.invoke('hermes:git:review:commitContext', repoPath), + push: repoPath => ipcRenderer.invoke('hermes:git:review:push', repoPath), + shipInfo: repoPath => ipcRenderer.invoke('hermes:git:review:shipInfo', repoPath), + createPr: repoPath => ipcRenderer.invoke('hermes:git:review:createPr', repoPath) + } + }, terminal: { dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id), resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size), diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5ab50597139..62cb60c61e2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -37,7 +37,7 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/window-state.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/window-state.test.cjs", "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", @@ -93,6 +93,7 @@ "remark-math": "^6.0.0", "remend": "^1.3.0", "shiki": "^4.0.2", + "simple-git": "^3.36.0", "streamdown": "^2.5.0", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.4", diff --git a/apps/desktop/scripts/bundle-electron-main.mjs b/apps/desktop/scripts/bundle-electron-main.mjs new file mode 100644 index 00000000000..bb5b0ad061b --- /dev/null +++ b/apps/desktop/scripts/bundle-electron-main.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +// bundle-electron-main.mjs — bundles electron/main.cjs into a single +// self-contained file so the nix build doesn't need to ship node_modules/. +// +// `electron` is provided by the runtime; `node-pty` is staged separately +// via stage-native-deps.cjs. `preload.cjs` is NOT require()'d by main — +// Electron loads it via path.join(__dirname, 'preload.cjs') — so it stays +// as a separate file and doesn't need bundling. +import { build } from 'esbuild' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { renameSync } from 'node:fs' + +const here = dirname(fileURLToPath(import.meta.url)) +const root = resolve(here, '..') +const entry = resolve(root, 'electron/main.cjs') +const tmp = resolve(root, 'electron/main.bundled.cjs') + +await build({ + entryPoints: [entry], + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node20', + outfile: tmp, + external: ['electron', 'node-pty'], + logLevel: 'info' +}) + +// Overwrite the original with the bundled version. +renameSync(tmp, entry) + +console.log(`bundled ${entry}`) diff --git a/apps/desktop/src/app/agents/index.tsx b/apps/desktop/src/app/agents/index.tsx index 20958c00939..ed31a007bd5 100644 --- a/apps/desktop/src/app/agents/index.tsx +++ b/apps/desktop/src/app/agents/index.tsx @@ -4,9 +4,10 @@ import { type ReactNode, useEffect, useMemo, useState } from 'react' import { useElapsedSeconds } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' import { FadeText } from '@/components/ui/fade-text' +import { Codicon } from '@/components/ui/codicon' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { type Translations, useI18n } from '@/i18n' -import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons' +import { AlertCircle, CheckCircle2 } from '@/lib/icons' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' import { @@ -209,7 +210,7 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) { if (tree.length === 0) { return (
- +

{t.agents.emptyTitle}

{t.agents.emptyDesc}

diff --git a/apps/desktop/src/app/chat/composer/focus.ts b/apps/desktop/src/app/chat/composer/focus.ts index 3de3f5c9800..d3969b70020 100644 --- a/apps/desktop/src/app/chat/composer/focus.ts +++ b/apps/desktop/src/app/chat/composer/focus.ts @@ -10,8 +10,8 @@ * steal focus from the composer effect. */ -import { RICH_INPUT_SLOT } from './rich-editor' import type { InlineRefInput } from './inline-refs' +import { RICH_INPUT_SLOT } from './rich-editor' export type ComposerTarget = 'edit' | 'main' export type ComposerInsertMode = 'block' | 'inline' @@ -34,8 +34,14 @@ interface InsertRefsDetail { const FOCUS_EVENT = 'hermes:composer-focus' const INSERT_EVENT = 'hermes:composer-insert' const INSERT_REFS_EVENT = 'hermes:composer-insert-refs' +const SUBMIT_EVENT = 'hermes:composer-submit' const VOICE_TOGGLE_EVENT = 'hermes:composer-voice-toggle' +interface SubmitDetail { + target: ComposerTarget + text: string +} + let activeTarget: ComposerTarget = 'main' const resolve = (target: ComposerTarget | 'active') => (target === 'active' ? activeTarget : target) @@ -106,6 +112,23 @@ export const requestComposerInsertRefs = ( export const onComposerInsertRefsRequest = (handler: (detail: InsertRefsDetail) => void) => subscribe(INSERT_REFS_EVENT, handler) +/** Submit a prompt through a composer as if the user typed + sent it. Lets + * external panels (e.g. the review pane's "let the agent ship it" button) hand + * the agent a task without the user round-tripping through the input. */ +export const requestComposerSubmit = ( + text: string, + { target = 'active' }: { target?: ComposerTarget | 'active' } = {} +) => { + const trimmed = text.trim() + + if (trimmed) { + dispatch(SUBMIT_EVENT, { target: resolve(target), text: trimmed }) + } +} + +export const onComposerSubmitRequest = (handler: (detail: SubmitDetail) => void) => + subscribe(SUBMIT_EVENT, handler) + /** Toggle the active composer's voice conversation — the `composer.voice` * hotkey (Ctrl+B) reaching into the composer that owns the voice state. */ export const requestVoiceToggle = () => dispatch<{ at: number }>(VOICE_TOGGLE_EVENT, { at: Date.now() }) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index d4ec0a36a1d..8a0ec509b0b 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -45,8 +45,8 @@ import { $composerPoppedOut, POPOUT_WIDTH_REM, readPopoutBounds, - setComposerPoppedOut, - setComposerPopoutPosition + setComposerPopoutPosition, + setComposerPoppedOut } from '@/store/composer-popout' import { $queuedPromptsBySession, @@ -60,8 +60,10 @@ import { updateQueuedPrompt } from '@/store/composer-queue' import { $statusItemsBySession } from '@/store/composer-status' -import { $previewStatusBySession } from '@/store/preview-status' import { notify } from '@/store/notifications' +import { $previewStatusBySession } from '@/store/preview-status' +import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects' +import { toggleReview } from '@/store/review' import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session' import { $threadScrolledUp } from '@/store/thread-scroll' import { isSecondaryWindow } from '@/store/windows' @@ -80,6 +82,7 @@ import { onComposerFocusRequest, onComposerInsertRefsRequest, onComposerInsertRequest, + onComposerSubmitRequest, onComposerVoiceToggleRequest } from './focus' import { HelpHint } from './help-hint' @@ -108,6 +111,7 @@ import { slashChipElement } from './rich-editor' import { ComposerStatusStack } from './status-stack' +import { CodingStatusRow } from './status-stack/coding-row' import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils' import { ComposerTriggerPopover } from './trigger-popover' import type { ChatBarProps } from './types' @@ -1351,6 +1355,80 @@ export function ChatBar({ } }, [setComposerText]) + // Hand a worktree off to the controller: open a fresh session anchored there, + // carrying the composer draft as its first turn. Clearing here means the draft + // travels to the new session instead of getting stashed under this one. + const openInWorktree = useCallback( + (path: string) => { + const text = draftRef.current + clearDraft() + clearComposerAttachments() + requestStartWorkSession(path, text) + }, + [clearDraft] + ) + + // Branch off into a NEW worktree (base = branch name, or current HEAD). A + // create failure throws back to the row (which toasts) before we touch the + // draft; a missing cwd / remote backend no-ops (the row hides the affordance). + const handleBranchOff = useCallback( + async (branch: string, base?: string) => { + const repoPath = cwd?.trim() + const result = repoPath && (await startWorkInRepo(repoPath, { base, branch, name: branch })) + + if (result) { + openInWorktree(result.path) + } + }, + [cwd, openInWorktree] + ) + + // Convert an EXISTING branch into a fresh worktree + session (no new branch). + // Mirrors handleBranchOff's hand-off: create the worktree, then open a session + // anchored there carrying the draft. + const handleConvertBranch = useCallback( + async (branch: string, path?: null | string, isDefault?: boolean) => { + if (path?.trim()) { + openInWorktree(path) + + return + } + + const repoPath = cwd?.trim() + + if (repoPath && isDefault) { + await switchBranchInRepo(repoPath, branch) + openInWorktree(repoPath) + + return + } + + const result = repoPath && (await startWorkInRepo(repoPath, { existingBranch: branch })) + + if (result) { + openInWorktree(result.path) + } + }, + [cwd, openInWorktree] + ) + + const handleListBranches = useCallback(async () => { + const repoPath = cwd?.trim() + + return repoPath ? listRepoBranches(repoPath) : [] + }, [cwd]) + + const handleSwitchBranch = useCallback( + async (branch: string) => { + const repoPath = cwd?.trim() + + if (repoPath) { + await switchBranchInRepo(repoPath, branch) + } + }, + [cwd] + ) + const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => { draftRef.current = text setComposerText(text) @@ -1674,6 +1752,41 @@ export function ChatBar({ } }, [autoDrainNext, busy, queuedPrompts.length]) + // Esc cancels the in-flight turn when the CHAT has focus — not just the + // composer input (which has its own handler above). Clicking into the + // transcript and hitting Esc now stops the run, matching the Stop button. + // Intentional only: we bail if (a) the composer/another field already + // handled Esc (defaultPrevented), (b) focus is in any input/textarea/ + // contenteditable (you're typing, not stopping), or (c) a dialog/popover is + // open — Esc must close that overlay, never double as canceling the stream + // behind it. A latest-handler ref keeps the listener registered once. + const escCancelRef = useRef<(event: globalThis.KeyboardEvent) => void>(() => {}) + escCancelRef.current = (event: globalThis.KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented || !busy) { + return + } + + const active = document.activeElement as HTMLElement | null + if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) { + return + } + + if (document.querySelector('[role="dialog"],[role="alertdialog"],[data-radix-popper-content-wrapper]')) { + return + } + + event.preventDefault() + triggerHaptic('cancel') + void Promise.resolve(onCancel()) + } + + useEffect(() => { + const onKeyDown = (event: globalThis.KeyboardEvent) => escCancelRef.current(event) + window.addEventListener('keydown', onKeyDown) + + return () => window.removeEventListener('keydown', onKeyDown) + }, []) + // Queue-edit cleanup: on session swap the scope effect already stashed the // edit snapshot; only restore into the composer when still on the same scope. useEffect(() => { @@ -1706,6 +1819,22 @@ export function ChatBar({ .catch(restore) } + // External "submit this prompt" requests (e.g. the review pane's agent-ship + // button) route through the same send path. A ref keeps the listener stable + // while always calling the latest dispatchSubmit closure. + const dispatchSubmitRef = useRef(dispatchSubmit) + dispatchSubmitRef.current = dispatchSubmit + + useEffect( + () => + onComposerSubmitRequest(({ target, text }) => { + if (target === 'main' && !inputDisabled) { + dispatchSubmitRef.current(text) + } + }), + [inputDisabled] + ) + const submitDraft = () => { if (disabled) { return @@ -2099,7 +2228,7 @@ export function ChatBar({
+
{ + if (branch.checkedOut) { + return copy.branchOpenExisting + } + + return branch.isDefault ? copy.branchSwitchHome : copy.branchCreateWorktree +} + +interface CodingStatusRowProps { + /** Branch the current draft off into a fresh worktree + session, based on + * `base` (a branch name; omitted = current HEAD). The composer owns the + * draft, so it supplies the orchestration; the row just collects the new + * branch name + base. Omitted (e.g. remote backend) hides the affordance. */ + onBranchOff?: (branch: string, base?: string) => Promise + /** Check an existing branch out into a fresh worktree + session (no new + * branch). Drives the dialog's "convert a branch" picker. */ + onConvertBranch?: (branch: string, path?: null | string, isDefault?: boolean) => Promise + /** List the repo's local branches for the "convert a branch" picker. */ + onListBranches?: () => Promise + /** Open the review pane (changed files + diffs). */ + onOpen?: () => void + /** Jump into an existing worktree (open a fresh session anchored there). */ + onOpenWorktree?: (path: string) => void + /** Switch the current repo checkout to another branch. */ + onSwitchBranch?: (branch: string) => Promise +} + +/** + * The always-on coding-context row, the BASE of the composer status stack: + * current branch, dirty summary (+/-), and ahead/behind. A touch more prominent + * than the per-turn rows above it (larger branch label, accent glyph), and the + * entry point to the review pane. Hidden when the active session isn't in a + * local git repo (the probe returns null). + */ +export const CodingStatusRow = memo(function CodingStatusRow({ + onBranchOff, + onConvertBranch, + onListBranches, + onOpen, + onOpenWorktree, + onSwitchBranch +}: CodingStatusRowProps) { + const { t } = useI18n() + const s = t.statusStack.coding + const p = t.sidebar.projects + const status = useStore($repoStatus) + const worktrees = useStore($repoWorktrees) + + const [branchOpen, setBranchOpen] = useState(false) + const [branchName, setBranchName] = useState('') + const [branchBase, setBranchBase] = useState(undefined) + const [branchPending, setBranchPending] = useState(false) + const [convertMode, setConvertMode] = useState(false) + const [branches, setBranches] = useState([]) + const [branchesLoading, setBranchesLoading] = useState(false) + + const loadBranches = useCallback(async () => { + if (!onListBranches) { + return + } + + setBranchesLoading(true) + + try { + setBranches(await onListBranches()) + } catch { + setBranches([]) + } finally { + setBranchesLoading(false) + } + }, [onListBranches]) + + // Open the name dialog for a chosen base. Deferred so the dropdown finishes + // closing before the dialog grabs focus (Radix focus-trap handoff races + // otherwise). + const startBranch = (base: string | undefined) => { + setBranchBase(base) + setBranchName('') + setConvertMode(false) + setTimeout(() => setBranchOpen(true), 0) + } + + const startConvert = () => { + setBranchBase(undefined) + setBranchName('') + setConvertMode(true) + void loadBranches() + setTimeout(() => setBranchOpen(true), 0) + } + + const enterConvert = () => { + setConvertMode(true) + void loadBranches() + } + + const convertBranch = async (branch: HermesGitBranch) => { + if (branchPending || !branch || !onConvertBranch) { + return + } + + setBranchPending(true) + + try { + await onConvertBranch(branch.name, branch.worktreePath, branch.isDefault) + setBranchOpen(false) + } catch (err) { + notifyError(err, p.startWorkFailed) + } finally { + setBranchPending(false) + } + } + + // Global ⌘⇧B (workspace.newWorktree): open the name dialog for a worktree off + // current HEAD. The rail only renders inside a repo, so the hotkey naturally + // no-ops elsewhere. Guarded by a token ref so it fires on the keypress, not on + // mount or unrelated re-renders. + const worktreeReq = useStore($newWorktreeRequest) + const lastWorktreeReqRef = useRef(worktreeReq) + + useEffect(() => { + if (worktreeReq === lastWorktreeReqRef.current) { + return + } + + lastWorktreeReqRef.current = worktreeReq + + if (!onBranchOff) { + return + } + + setBranchBase(undefined) + setBranchName('') + setConvertMode(false) + setBranchOpen(true) + }, [onBranchOff, worktreeReq]) + + const submitBranch = async () => { + const branch = branchName.trim() + + if (branchPending || !branch || !onBranchOff) { + return + } + + setBranchPending(true) + + try { + await onBranchOff(branch, branchBase) + setBranchOpen(false) + setBranchName('') + } catch (err) { + notifyError(err, p.startWorkFailed) + } finally { + setBranchPending(false) + } + } + + const switchToBranch = async (branch: string) => { + if (!onSwitchBranch) { + return + } + + try { + await onSwitchBranch(branch) + } catch (err) { + notifyError(err, s.switchFailed(branch)) + } + } + + if (!status) { + return null + } + + const branchLabel = status.detached ? s.detached : status.branch || s.noBranch + // The kebab offers branching off the trunk and/or the current branch. The + // worktree-add bases the new branch on `base` (a branch name; undefined = + // current HEAD). We dedupe so "on main" shows a single trunk entry, and fall + // back to a plain off-HEAD branch when no trunk is detected. + const current = status.detached ? null : status.branch + const branchTargets: { base: string | undefined; label: string }[] = [] + + // Current branch first (the 99% "branch off where I am"), then the trunk just + // below it ("New branch from main"), deduped when they're the same. + if (current) { + branchTargets.push({ base: current, label: s.branchOffFrom(current) }) + } + + if (status.defaultBranch && status.defaultBranch !== current) { + branchTargets.push({ base: status.defaultBranch, label: s.branchOffFrom(status.defaultBranch) }) + } + + if (branchTargets.length === 0) { + branchTargets.push({ base: undefined, label: s.newBranch }) + } + + const switchTarget = onSwitchBranch && current && status.defaultBranch && status.defaultBranch !== current ? status.defaultBranch : null + + // Other worktrees to jump into — everything except the one we're already in + // (matched by its checked-out branch) and the bare/main placeholder entry. + const otherWorktrees = onOpenWorktree + ? worktrees.filter(w => w.path && !w.detached && w.branch && w.branch !== current) + : [] + + const hasLineDelta = status.added > 0 || status.removed > 0 + // Untracked files carry no line delta vs HEAD, so surface them as a count when + // they're the only change (otherwise +/- tells the story). + const untrackedOnly = !hasLineDelta && status.untracked > 0 + + return ( + <> + } + onActivate={onOpen} + > +
+ + {branchLabel} + + + {/* Branch actions kebab — same pattern as the session/worktree rows. + ALWAYS laid out; only its opacity flips on hover/focus/open, so + revealing it never reflows the row (no layout shift). pointer-events + follow opacity so the invisible trigger isn't clickable at rest. */} + {onBranchOff && ( + + + + + {/* The row sits at the bottom of the screen (above the composer), + so the menu opens upward. */} + + {s.newBranch} + {branchTargets.map(target => ( + startBranch(target.base)}> + {target.label} + + ))} + + {switchTarget && ( + void switchToBranch(switchTarget)}> + {s.switchTo(switchTarget)} + + )} + + + {s.worktrees} + {otherWorktrees.map(worktree => ( + onOpenWorktree?.(worktree.path)}> + {worktree.branch} + + ))} + {/* Create a fresh worktree off the current HEAD (the generic + "spin up a worktree here", mirroring the sidebar's + button). */} + startBranch(undefined)}> + {p.startWork} + + {/* Check an EXISTING branch out into a worktree (no new branch). */} + {onConvertBranch && ( + startConvert()}> + {p.convertBranch} + + )} + + + )} +
+ + {(status.ahead > 0 || status.behind > 0) && ( + + {status.ahead > 0 && ( + + + {status.ahead} + + )} + {status.behind > 0 && ( + + + {status.behind} + + )} + + )} + + {hasLineDelta ? ( + + ) : untrackedOnly ? ( + + {s.changed(status.untracked)} + + ) : null} +
+ + !branchPending && setBranchOpen(open)} open={branchOpen}> + + + {convertMode ? p.convertBranchTitle : p.newWorktreeTitle} + + {convertMode ? p.convertBranchDesc : p.newWorktreeDesc} + {!convertMode && branchBase && ( + {s.branchOffFrom(branchBase)} + )} + + + + {convertMode ? ( + (value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)} + > + + + {branchesLoading ? p.branchesLoading : p.noBranches} + + {branches.map(branch => ( + void convertBranch(branch)} + value={branch.name} + > + + {branch.name} + + {branchActionLabel(branch, p)} + + + ))} + + + + ) : ( + { + if (event.key === 'Enter') { + event.preventDefault() + void submitBranch() + } else if (event.key === 'Escape') { + setBranchOpen(false) + } + }} + onValueChange={setBranchName} + placeholder={p.branchPlaceholder} + sanitize={gitRef} + value={branchName} + /> + )} + + {convertMode ? ( + + + + ) : ( + + {onConvertBranch ? ( + + ) : ( + + )} +
+ + +
+
+ )} +
+
+ + ) +}) diff --git a/apps/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx index b9cf2ffb99c..93c8a2dc1af 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/index.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx @@ -30,6 +30,19 @@ import { StatusItemRow } from './status-row' // emit no event when they die). Only armed while a running row is on screen. const BACKGROUND_POLL_MS = 5_000 +// A localhost/loopback preview is only meaningful while its dev server is up, so +// we tie it to a live background process rather than persisting dismissals or +// letting dead URLs pile up. File previews (a real on-disk artifact) stand alone. +const isLocalhostPreview = (target: string): boolean => /\b(?:localhost|127\.0\.0\.1|0\.0\.0\.0)\b/i.test(target) + +// Real codicons per group (no sparkles): a checklist for todos, a bot for +// subagents, a background process glyph for background tasks. +const GROUP_ICON: Record = { + todo: 'checklist', + subagent: 'hubot', + background: 'server-process' +} + const groupLabel = (group: StatusGroup, s: Translations['statusStack']) => { if (group.type === 'todo') { return s.todos(group.items.filter(i => i.todoStatus === 'completed').length, group.items.length) @@ -74,6 +87,10 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro const hasRunningBackground = groups.some(g => g.type === 'background' && g.items.some(i => i.state === 'running')) + // Drop localhost previews once no dev server is left running — that's what made + // dead `localhost:5174` chips stick around. On-disk file previews are kept. + const visiblePreviews = previews.filter(item => hasRunningBackground || !isLocalhostPreview(item.target)) + useEffect(() => { if (!sessionId || !hasRunningBackground) { return @@ -89,6 +106,18 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro const openSubagent = (item: ComposerStatusItem) => item.sessionId ? void openSessionInNewWindow(item.sessionId, { watch: true }) : openAgents() + // Preview links live as child rows of the background group — a localhost dev + // server and its preview are the same thing — so they no longer float as an + // odd, differently-indented standalone block under the stack. + const previewRows = + visiblePreviews.length > 0 && sessionId + ? visiblePreviews.map(item => ( + dismissPreviewArtifact(sessionId, id)} /> + )) + : [] + + const hasBackgroundGroup = groups.some(g => g.type === 'background') + const sections: { key: string; node: ReactNode }[] = groups.map(group => ({ key: group.type, node: ( @@ -107,11 +136,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro ) : undefined } defaultCollapsed={group.type !== 'todo'} - icon={ - group.type === 'todo' ? ( - - ) : undefined - } + icon={} label={groupLabel(group, t.statusStack)} > {group.items.map(item => ( @@ -120,25 +145,20 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro key={item.id} onDismiss={sessionId ? id => dismissBackgroundProcess(sessionId, id) : undefined} onOpen={() => openSubagent(item)} - onStop={sessionId ? id => stopBackgroundProcess(sessionId, id) : undefined} + onStop={sessionId ? id => void stopBackgroundProcess(sessionId, id) : undefined} /> ))} + {group.type === 'background' && previewRows} ) })) - if (previews.length > 0 && sessionId) { + // No background group to host them (e.g. a standalone on-disk file preview): + // keep the previews as their own row block so they don't disappear. + if (previewRows.length > 0 && !hasBackgroundGroup) { sections.push({ key: 'preview', - // Not a collapsible group — preview links just sit there, one line each, - // each individually closeable. - node: ( -
- {previews.map(item => ( - dismissPreviewArtifact(sessionId, id)} /> - ))} -
- ) + node:
{previewRows}
}) } @@ -190,12 +210,10 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro return (
blurComposerInput()} ref={stackRef} > @@ -205,17 +223,19 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro Rounded top, square bottom; the bottom border is TRANSPARENT — the composer surface's visible top border (which sits at a higher z) is the single shared seam, so the two read as one fused capsule. */} -
-
- {sections.map(section => ( -
{section.node}
- ))} -
+
+ {sections.map(section => ( +
{section.node}
+ ))}
) diff --git a/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx index cc6893f0e64..f8c3cc520b3 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx @@ -6,7 +6,6 @@ import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' -import { ChevronRight, X } from '@/lib/icons' import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' import { cn } from '@/lib/utils' import { PREVIEW_PANE_ID } from '@/store/layout' @@ -76,50 +75,47 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss return ( } - onActivate={() => void togglePreview()} + leading={ + + } + // Plain click opens the link in the browser; ⌘/Ctrl-click opens it in the + // in-app preview pane instead. (isOpen still toggles the pane closed.) + onActivate={event => { + if (event.metaKey || event.ctrlKey) { + void togglePreview() + } else { + void openInBrowser() + } + }} trailing={ - - - - - - - - + + + } trailingVisible > - - {item.label} - - - {opening ? t.preview.opening : isOpen ? t.preview.hide : t.preview.openPreview} - + + {item.target} + {t.preview.linkHint} + + } + > + {item.label} + ) }) diff --git a/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx index 27a9ef0262c..bc54b92ffe9 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx @@ -8,7 +8,6 @@ import { DisclosureCaret } from '@/components/ui/disclosure-caret' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { Tip } from '@/components/ui/tooltip' import { type Translations, useI18n } from '@/i18n' -import { ArrowUpRight, X } from '@/lib/icons' import type { TodoStatus } from '@/lib/todos' import { cn } from '@/lib/utils' import type { ComposerStatusItem } from '@/store/composer-status' @@ -50,7 +49,7 @@ function leadingGlyph(item: ComposerStatusItem, s: Translations['statusStack']): return ( ) @@ -117,11 +116,11 @@ export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOp type="button" variant="ghost" > - + ) : canOpen ? ( - + ) : undefined } > diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 2b6586cf5a1..e4a80e61273 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -88,7 +88,10 @@ interface ChatViewProps extends Omit, 'onSubmit'> { onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void onEdit: (message: AppendMessage) => Promise onReload: (parentId: string | null) => Promise - onRestoreToMessage?: (messageId: string) => Promise + onRestoreToMessage?: ( + messageId: string, + target?: { text?: string; userOrdinal?: number | null } + ) => Promise onRetryResume: (sessionId: string) => void onTranscribeAudio?: (audio: Blob) => Promise onDismissError?: (messageId: string) => void diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.tsx index 6261200706c..ef0c7d185ff 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-file.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx @@ -6,7 +6,7 @@ import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react' -import { useEffect, useMemo, useState } from 'react' +import { Fragment, useEffect, useMemo, useState } from 'react' import ShikiHighlighter from 'react-shiki' import { Streamdown } from 'streamdown' @@ -14,15 +14,21 @@ import { requestComposerFocus, requestComposerInsertRefs } from '@/app/chat/comp import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs' import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions' import { isAddSelectionShortcut } from '@/app/right-sidebar/terminal/selection' +import { FileDiffPanel } from '@/components/chat/diff-lines' +import { chunkTextLines, useFixedRowWindow } from '@/components/chat/fixed-row-window' import { PageLoader } from '@/components/page-loader' import { translateNow, useI18n } from '@/i18n' -import { readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs' +import { desktopFileDiff, desktopGitRoot, readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs' +import { shikiLanguageForFilename } from '@/lib/markdown-code' import { cn } from '@/lib/utils' import type { PreviewTarget } from '@/store/preview' import { $currentCwd } from '@/store/session' const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const const TEXT_PREVIEW_MAX_BYTES = 512 * 1024 +const SOURCE_CHUNK_LINES = 200 +const SOURCE_LINE_PX = 20 +const SOURCE_OVERSCAN_LINES = 400 type EmptyStateTone = 'neutral' | 'warning' @@ -126,6 +132,8 @@ interface LocalPreviewState { binary?: boolean byteSize?: number dataUrl?: string + /** Working-tree-vs-HEAD unified diff, when the file has uncommitted changes. */ + diff?: string error?: string language?: string loading: boolean @@ -299,28 +307,44 @@ function MarkdownPreview({ text }: { text: string }) { ) } -function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) { +function PreviewModeSwitcher({ + active, + modes, + onSelect +}: { + active: PreviewViewMode + modes: PreviewViewMode[] + onSelect: (mode: PreviewViewMode) => void +}) { const { t } = useI18n() + const label: Record = { + diff: t.preview.diff, + rendered: t.preview.renderedPreview, + source: t.preview.source + } + return ( -
- +
+ {modes.map(mode => ( + + ))}
) } -// Gutter and Shiki output share `font-mono text-xs leading-relaxed py-3` so -// each line aligns vertically. The selection overlay relies on the same -// `text-xs * leading-relaxed = 1.21875rem` line-height to position itself. -const SOURCE_LINE_HEIGHT_REM = 1.21875 -const SOURCE_PAD_Y_REM = 0.75 - interface LineSelection { end: number start: number @@ -337,7 +361,18 @@ function startLineDrag(event: ReactDragEvent, filePath: string, { e function SourceView({ filePath, language, text }: { filePath: string; language: string; text: string }) { const { t } = useI18n() - const lineCount = useMemo(() => Math.max(1, text.split('\n').length), [text]) + const chunks = useMemo(() => chunkTextLines(text, SOURCE_CHUNK_LINES), [text]) + const lastChunk = chunks.at(-1) + const totalLines = lastChunk ? lastChunk.start + lastChunk.lines.length : 0 + + const { afterRows, beforeRows, endChunk, onScroll, scrollerRef, startChunk } = useFixedRowWindow({ + overscanRows: SOURCE_OVERSCAN_LINES, + rowPx: SOURCE_LINE_PX, + rowsPerChunk: SOURCE_CHUNK_LINES, + totalRows: totalLines + }) + + const visibleChunks = chunks.slice(startChunk, endChunk + 1) const [selection, setSelection] = useState(null) const inSelection = (line: number) => selection != null && line >= selection.start && line <= selection.end @@ -394,69 +429,76 @@ function SourceView({ filePath, language, text }: { filePath: string; language: }, [filePath, selection]) return ( -
-
- {Array.from({ length: lineCount }, (_, index) => { - const line = index + 1 - const selected = inSelection(line) - - return ( -
handleLineClick(event, line)} - onDragStart={event => handleDragStart(event, line)} - title={t.preview.sourceLineTitle} - > - {line} -
- ) - })} -
-
- {selection && ( -
+
+
+ {beforeRows > 0 && ( +
+ )} + {visibleChunks.map(chunk => ( + +
+ {chunk.lines.map((_lineText, offset) => { + const line = chunk.start + offset + 1 + const selected = inSelection(line) + + return ( +
handleLineClick(event, line)} + onDragStart={event => handleDragStart(event, line)} + title={t.preview.sourceLineTitle} + > + {line} +
+ ) + })} +
+
+ + {chunk.text} + +
+
+ ))} + {afterRows > 0 && ( +
)} - - {text} -
) } +type PreviewViewMode = 'diff' | 'rendered' | 'source' + export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) { const { t } = useI18n() const [state, setState] = useState({ loading: true }) const [forcePreview, setForcePreview] = useState(false) - const [renderMarkdownAsSource, setRenderMarkdownAsSource] = useState(false) + // User-picked view; null = auto (diff when changed, else rendered markdown, + // else source). Reset when the previewed file changes. + const [userMode, setUserMode] = useState(null) const filePath = filePathForTarget(target) const isImage = target.previewKind === 'image' + useEffect(() => { + setUserMode(null) + }, [filePath, reloadKey]) + // HTML files are rendered as source code, not in a webview - so they take // the same path as plain text files. `previewKind === 'binary'` arrives // when the file is forcibly previewed past the binary refusal screen. @@ -508,6 +550,22 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar text: shouldBlock ? undefined : result.text, truncated: result.truncated }) + + // Best-effort: fetch the file's working-tree-vs-HEAD diff so the + // preview can offer a DIFF view when there are uncommitted changes. + // Empty (clean file / not a repo / remote) just hides the option. + if (!shouldBlock) { + try { + const root = await desktopGitRoot(filePath) + const diff = root ? await desktopFileDiff(root, filePath) : '' + + if (active && diff.trim()) { + setState(prev => (prev.text === result.text ? { ...prev, diff } : prev)) + } + } catch { + // No diff available; the preview just shows source. + } + } } } catch (error) { if (active) { @@ -571,21 +629,50 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar if (isText && state.text !== undefined) { const isMarkdown = (state.language || target.language) === 'markdown' - const showRendered = isMarkdown && !renderMarkdownAsSource + const hasDiff = Boolean(state.diff && state.diff.trim()) + // Order the toggle reads left→right; default lands on the most useful view. + const modes: PreviewViewMode[] = [] + + if (isMarkdown) { + modes.push('rendered') + } + + modes.push('source') + + if (hasDiff) { + modes.push('diff') + } + + const autoMode: PreviewViewMode = hasDiff ? 'diff' : isMarkdown ? 'rendered' : 'source' + const mode = userMode && modes.includes(userMode) ? userMode : autoMode return ( -
+
{state.truncated && (
{t.preview.truncated}
)} - {isMarkdown && setRenderMarkdownAsSource(s => !s)} />} - {showRendered ? ( - - ) : ( - - )} + {modes.length > 1 && } +
+ {mode === 'rendered' ? ( + + ) : mode === 'diff' ? ( + + ) : ( + + )} +
) } diff --git a/apps/desktop/src/app/chat/right-rail/preview.tsx b/apps/desktop/src/app/chat/right-rail/preview.tsx index dec0e36f47b..97678cab106 100644 --- a/apps/desktop/src/app/chat/right-rail/preview.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview.tsx @@ -3,10 +3,19 @@ import { useEffect, useMemo } from 'react' import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls' import { Codicon } from '@/components/ui/codicon' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger +} from '@/components/ui/context-menu' import { Tip } from '@/components/ui/tooltip' import { translateNow, useI18n } from '@/i18n' +import { formatCombo } from '@/lib/keybinds/combo' import { cn } from '@/lib/utils' import { + $panesFlipped, $rightRailActiveTabId, RIGHT_RAIL_PREVIEW_TAB_ID, type RightRailTabId, @@ -16,8 +25,10 @@ import { $filePreviewTabs, $previewReloadRequest, $previewTarget, + closeOtherRightRailTabs, closeRightRail, closeRightRailTab, + closeRightRailTabsToRight, type PreviewTarget } from '@/store/preview' @@ -56,6 +67,7 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP const { t } = useI18n() const previewReloadRequest = useStore($previewReloadRequest) const activeTabId = useStore($rightRailActiveTabId) + const panesFlipped = useStore($panesFlipped) const filePreviewTabs = useStore($filePreviewTabs) const previewTarget = useStore($previewTarget) @@ -82,68 +94,92 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP const isPreview = activeTab.id === RIGHT_RAIL_PREVIEW_TAB_ID return ( -