diff --git a/git-graph-service.js b/git-graph-service.js new file mode 100644 index 000000000..40873a89d --- /dev/null +++ b/git-graph-service.js @@ -0,0 +1,458 @@ +// git-graph-service.js — assembles Git Graph tab payloads (paging, stashes, +// uncommitted changes), commit/comparison detail, file-at-revision and +// diff-between-revisions, and a debounced watcher on the repo's common git +// dir. Read-only: mutating actions live in a later PR. +// +// This module is a singleton initialised with init(ctx), the same shape as +// projects.js/session-cache.js, so tests can hand it a fake db/git without +// touching the real ones. `git.js` is the only thing this file shells out +// through; it never runs a git subprocess itself. +// +// Boundary note: this module does not check whether folderPath is attached +// to a project — that check happens one layer up, in projects.js, exactly +// like it already does for projectGitDiff. + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const EMPTY_TREE_HASH = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; // git's well-known empty-tree constant +const REFS_WALK_MAX_ENTRIES = 20_000; + +// Timing knobs, overridable via init(ctx) so tests can run in milliseconds +// instead of real minutes without touching the mechanism under test. +let REPO_WATCH_POLL_MS = 1500; +let REPO_WATCH_DEBOUNCE_MS = 300; +let REPO_WATCH_IDLE_SWEEP_MS = 5 * 60 * 1000; +let REPO_WATCH_IDLE_MAX_MS = 15 * 60 * 1000; + +let db, log, send, git; + +/** Wire dependencies. Mirrors projects.js's init(ctx) so tests can inject fakes. */ +function init(ctx = {}) { + db = ctx.db || null; + log = ctx.log || console; + send = typeof ctx.send === 'function' ? ctx.send : (() => {}); + git = ctx.git || require('./git'); + if (ctx.repoWatchPollMs) REPO_WATCH_POLL_MS = ctx.repoWatchPollMs; + if (ctx.repoWatchDebounceMs) REPO_WATCH_DEBOUNCE_MS = ctx.repoWatchDebounceMs; + if (ctx.repoWatchIdleSweepMs) REPO_WATCH_IDLE_SWEEP_MS = ctx.repoWatchIdleSweepMs; + if (ctx.repoWatchIdleMaxMs) REPO_WATCH_IDLE_MAX_MS = ctx.repoWatchIdleMaxMs; +} + +// --- Positional-argument validation for the read endpoints below --- + +const HASH_RE = /^[0-9a-f]{4,40}$/i; +const REF_BASELINE_INVALID_RE = /[\x00-\x1f\x7f]|\.\.|@\{|(^\/)|(\/$)|(\.lock$)/; + +function assertSafePositionalArg(value, kind) { + const label = kind || 'value'; + if (typeof value !== 'string' || value.length === 0) throw new Error(`${label}: a value is required`); + if (value.includes('\0')) throw new Error(`${label}: must not contain a NUL byte`); + if (value.startsWith('-')) throw new Error(`${label}: must not start with '-'`); + if (kind === 'hash') { + if (!HASH_RE.test(value)) throw new Error(`${label}: '${value}' is not a valid commit hash`); + } else if (REF_BASELINE_INVALID_RE.test(value)) { + throw new Error(`${label}: '${value}' is not a valid reference name`); + } + return value; +} + +function assertRev(value, label = 'revision') { + return assertSafePositionalArg(value, label); +} + +function assertHash(value, label = 'hash') { + return assertSafePositionalArg(value, 'hash'); +} + +/** Path-traversal containment for a repo-relative file path touching the real worktree. */ +function safeRelative(dir, relPath, label = 'path') { + if (typeof relPath !== 'string' || !relPath) throw new Error(`${label} is required`); + const normalized = relPath.replace(/\\/g, '/'); + const absolute = path.resolve(dir, normalized); + const relative = path.relative(dir, absolute); + if (!relative || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) { + throw new Error(`${label} is outside the repository`); + } + return { absolute, relative: relative.split(path.sep).join('/') }; +} + +function buildRevspec(opts = {}) { + const args = []; + const { branches, tags } = opts; + const allBranches = !Array.isArray(branches); + // An explicit branch list restricts the graph to those branches (plus any + // explicitly picked tags); remote branches and all tags only join when every + // branch is selected, or all tags when no branch is. + if (allBranches) { + args.push('--branches'); + if (opts.showRemote) args.push('--remotes'); + } else { + for (const name of branches) args.push(assertRev(name, 'branch')); + } + if (Array.isArray(tags)) { + for (const name of tags) args.push(`refs/tags/${assertRev(name, 'tag')}`); + } else if ((allBranches || !branches.length) && opts.showTags !== false) { + args.push('--tags'); + } + // Nothing selected: git would fall back to HEAD, so report no revisions instead. + if (!args.length) return null; + return args; +} + +// --- Uncommitted changes (built from git.js's already-exported primitives — +// no new frozen git.js function needed for this) --- + +const UNTRACKED_STAT_MAX_BYTES = 2 * 1024 * 1024; + +function countFileLines(absolutePath) { + try { + const stat = fs.statSync(absolutePath); + if (!stat.isFile() || stat.size > UNTRACKED_STAT_MAX_BYTES) return 0; + const content = fs.readFileSync(absolutePath); + if (content.includes(0)) return 0; // binary + if (!content.length) return 0; + return content.toString('utf8').split('\n').length - (content.at(-1) === 10 ? 1 : 0); + } catch { return 0; } +} + +function parseNumstat(output) { + const byPath = new Map(); + for (const line of String(output || '').split('\n')) { + if (!line) continue; + const [ins, del, ...rest] = line.split('\t'); + const filePath = rest.join('\t'); + if (!filePath) continue; + const arrow = filePath.split(' => '); + const finalPath = arrow.length > 1 ? arrow[1].replace(/[{}]/g, '').trim() : filePath; + byPath.set(finalPath, { + insertions: ins === '-' ? 0 : Number(ins) || 0, + deletions: del === '-' ? 0 : Number(del) || 0, + }); + } + return byPath; +} + +async function buildUncommitted(dir) { + const raw = await git.run(['-c', 'core.quotepath=false', '--no-optional-locks', 'status', '--porcelain=v1', '-z', '--untracked-files=all'], dir); + const changes = git.parsePorcelain(raw); + if (!changes.length) return null; + + let numstat = new Map(); + try { + const out = await git.run(['-c', 'core.quotepath=false', '--no-optional-locks', 'diff', '--no-color', '--no-ext-diff', '--no-textconv', '--numstat', 'HEAD'], dir); + numstat = parseNumstat(out); + } catch { /* unborn HEAD or nothing staged against HEAD yet */ } + + const files = changes.map((change) => { + const stat = numstat.get(change.path) || null; + let insertions = stat ? stat.insertions : 0; + const deletions = stat ? stat.deletions : 0; + if (change.status === 'untracked' && !stat) { + insertions = countFileLines(path.resolve(dir, change.path)); + } + return { + path: change.path, + oldPath: change.oldPath, + status: change.status, + insertions, + deletions, + }; + }); + + return { changeCount: files.length, changes: files }; +} + +// --- Read endpoints --- + +/** Current HEAD commit hash, or null on an unborn HEAD. Works whether HEAD is attached or detached. */ +async function currentHeadHash(dir) { + try { return await git.run(['--no-optional-locks', 'rev-parse', 'HEAD'], dir); } catch { return null; } +} + +/** getProjectGitGraph — assembles commits+refs+stashes+uncommitted, paged. */ +async function getProjectGitGraph(dir, opts = {}) { + const limit = Math.max(1, Math.min(5000, Number(opts.limit) || 300)); + const skip = Math.max(0, Number(opts.skip) || 0); + const order = opts.order === 'author-date' || opts.order === 'topo' ? opts.order : 'date'; + + const revspec = buildRevspec(opts); + const [raw, headHash] = await Promise.all([ + revspec ? git.logWithParents(dir, { revspec, order, skip, limit: limit + 1 }) : [], + currentHeadHash(dir), + ]); + const hasMore = raw.length > limit; + const commits = raw.slice(0, limit); + + let refs = null; + if (!opts.refsUnchanged) { + refs = await git.forEachRef(dir); + // showTags only gated the log traversal above (whether a tag-only commit + // is reachable at all); the ref set itself always comes back with every + // tag, so "Show Tags" off must also strip them here or every tag pill + // stays visible regardless of the toggle. + if (opts.showTags === false) refs.tags = []; + applyRefsToCommits(commits, refs, headHash); + } else { + markHead(commits, headHash); + } + + let stashes = []; + if (opts.showStashes !== false) { + try { stashes = await git.stashList(dir); } catch (err) { log.info?.('[git-graph] stashList failed', err.message); } + } + + let uncommitted = null; + if (opts.showUncommittedChanges !== false) { + try { uncommitted = await buildUncommitted(dir); } catch (err) { log.info?.('[git-graph] uncommitted diff failed', err.message); } + } + + // `commits` here is real commits only — the renderer merges in the + // stash/uncommitted pseudo-commits itself (gitGraphBuildLayoutInput), + // since its own paging already knows which base commits are loaded. + return { ok: true, commits, refs, stashes, uncommitted, hasMore }; +} + +function markHead(commits, headHash) { + if (!headHash) return; + const c = commits.find(item => item.hash === headHash); + if (c) c.isHead = true; +} + +// forEachRef (git.js) reports `isHead` per local-branch entry, but that +// only tells us *which branch* is checked out, not the commit HEAD actually +// points at while detached — so commit-level isHead is always derived from +// the separately rev-parsed headHash, never from refs.heads[].isHead. +function applyRefsToCommits(commits, refs, headHash) { + if (!refs) return; + const byHash = new Map(commits.map(c => [c.hash, c])); + for (const head of refs.heads || []) { + const c = byHash.get(head.hash); + if (c) { + c.refs = c.refs || { heads: [], remotes: [], tags: [] }; + c.refs.heads.push(head.name); + } + } + for (const remote of refs.remotes || []) { + const c = byHash.get(remote.hash); + if (c) { + c.refs = c.refs || { heads: [], remotes: [], tags: [] }; + c.refs.remotes.push({ remote: remote.remote, name: remote.name }); + } + } + for (const tag of refs.tags || []) { + const c = byHash.get(tag.hash); + if (c) { + c.refs = c.refs || { heads: [], remotes: [], tags: [] }; + c.refs.tags.push(tag.name); + } + } + markHead(commits, headHash); +} + +async function diffNameStatusAndStat(dir, fromRev, toRevOrNull) { + assertRev(fromRev, 'fromRev'); + const uncommitted = toRevOrNull === null || toRevOrNull === '#uncommitted' || toRevOrNull === undefined; + const toArgs = uncommitted ? [] : [assertRev(toRevOrNull, 'toRev')]; + + // `git diff` never reports a file it has no tracked blob for on either + // side, so a plain diff against the working tree always omits untracked + // files — listed here separately and merged in below, the same way + // buildUncommitted() already does for the Uncommitted Changes row's own + // file count. + const [nameStatusRaw, numstatRaw, untrackedRaw] = await Promise.all([ + git.run(['-c', 'core.quotepath=false', '--no-optional-locks', 'diff', '--no-color', '--no-ext-diff', '--no-textconv', '-M', '--name-status', '-z', fromRev, ...toArgs], dir), + git.run(['-c', 'core.quotepath=false', '--no-optional-locks', 'diff', '--no-color', '--no-ext-diff', '--no-textconv', '-M', '--numstat', '-z', fromRev, ...toArgs], dir), + uncommitted + ? git.run(['-c', 'core.quotepath=false', '--no-optional-locks', 'ls-files', '--others', '--exclude-standard', '-z'], dir) + : Promise.resolve(''), + ]); + + const statusByPath = new Map(); + const nsFields = nameStatusRaw.split('\0').filter(Boolean); + for (let i = 0; i < nsFields.length; i += 1) { + const code = nsFields[i]; + if (!/^[AMDRCU]/.test(code)) continue; + let filePath = nsFields[++i]; + let oldPath = null; + if (/^[RC]/.test(code)) { oldPath = filePath; filePath = nsFields[++i]; } + const kind = code[0] === 'A' ? 'added' : code[0] === 'D' ? 'deleted' + : code[0] === 'R' ? 'renamed' : code[0] === 'C' ? 'renamed' + : code[0] === 'U' ? 'conflicted' : 'modified'; + statusByPath.set(filePath, { path: filePath, oldPath, status: kind, insertions: 0, deletions: 0 }); + } + + const nsNum = numstatRaw.split('\0').filter(Boolean); + for (let i = 0; i < nsNum.length; i += 1) { + const insRaw = nsNum[i]; + const parts = insRaw.split('\t'); + let ins = parts[0], del = parts[1], filePath = parts[2]; + if (filePath === undefined) { filePath = nsNum[++i]; } + let oldPath = null; + if (filePath === '' || filePath === undefined) { oldPath = nsNum[++i]; filePath = nsNum[++i]; } + const entry = statusByPath.get(filePath) || statusByPath.get(oldPath); + if (entry) { + entry.insertions = ins === '-' ? 0 : Number(ins) || 0; + entry.deletions = del === '-' ? 0 : Number(del) || 0; + } + } + + if (uncommitted) { + for (const filePath of untrackedRaw.split('\0').filter(Boolean)) { + if (statusByPath.has(filePath)) continue; + statusByPath.set(filePath, { + path: filePath, oldPath: null, status: 'untracked', + insertions: countFileLines(path.resolve(dir, filePath)), deletions: 0, + }); + } + } + + return [...statusByPath.values()]; +} + +async function getGitGraphCommitDetail(dir, hash) { + assertHash(hash, 'hash'); + const fields = ['%H', '%h', '%P', '%an', '%ae', '%aI', '%cn', '%ce', '%cI', '%s'].join('%x00'); + const raw = await git.run(['--no-optional-locks', 'log', '-1', '--no-color', '--no-show-signature', `--pretty=format:${fields}%x00%b`, hash], dir); + const parts = raw.split('\0'); + const [rawHash, shortHash, parentsRaw, authorName, authorEmail, authorDate, committerName, committerEmail, commitDate, subject] = parts; + const body = parts.slice(10).join('\0'); + const parents = parentsRaw ? parentsRaw.split(' ').filter(Boolean) : []; + const fromRev = parents[0] || EMPTY_TREE_HASH; + const files = await diffNameStatusAndStat(dir, fromRev, rawHash); + return { + ok: true, + commit: { + hash: rawHash, shortHash, parents, + authorName, authorEmail, authorDate, + committerName, committerEmail, commitDate, + subject, body, + isHead: false, + refs: { heads: [], remotes: [], tags: [] }, + }, + files, + }; +} + +async function getGitGraphCompareDetail(dir, fromHash, toHash) { + assertHash(fromHash, 'fromHash'); + if (toHash !== null && toHash !== undefined) assertHash(toHash, 'toHash'); + const files = await diffNameStatusAndStat(dir, fromHash, toHash ?? null); + return { ok: true, files }; +} + +async function getGitGraphFileAtRevision(dir, rev, relPath) { + assertRev(rev, 'rev'); + const { relative } = safeRelative(dir, relPath, 'path'); + const content = await git.blobAtRevision(dir, rev, relative); + return { ok: true, content }; +} + +async function getGitGraphFileDiffBetween(dir, fromRev, toRevOrNull, relPath) { + assertRev(fromRev, 'fromRev'); + const { relative, absolute } = safeRelative(dir, relPath, 'path'); + let oldContent = ''; + try { oldContent = await git.blobAtRevision(dir, fromRev, relative); } catch { oldContent = ''; } + + let newContent = ''; + if (toRevOrNull === null || toRevOrNull === undefined) { + try { newContent = fs.readFileSync(absolute, 'utf8'); } catch { newContent = ''; } + } else { + assertRev(toRevOrNull, 'toRev'); + try { newContent = await git.blobAtRevision(dir, toRevOrNull, relative); } catch { newContent = ''; } + } + return { ok: true, oldContent, newContent }; +} + +// --- Repo-change watcher: polls the common git dir's HEAD/packed-refs/index +// mtimes plus a walk of refs/ for per-ref mtimes, debounced, and sends +// 'git-graph-repo-changed' on any change. Deliberately not relying on +// fs.watch's inconsistent recursive support. --- + +const watches = new Map(); // folderPath -> { commonDir, fingerprint, timer, debounceTimer, lastAccess, primed } +let idleSweepTimer = null; + +function statMtime(file) { + try { return fs.statSync(file).mtimeMs; } catch { return 0; } +} + +function computeFingerprint(commonDir) { + const parts = [statMtime(path.join(commonDir, 'HEAD')), statMtime(path.join(commonDir, 'packed-refs')), statMtime(path.join(commonDir, 'index'))]; + const refsRoot = path.join(commonDir, 'refs'); + let count = 0; + const stack = [refsRoot]; + while (stack.length && count < REFS_WALK_MAX_ENTRIES) { + const current = stack.pop(); + let entries; + try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { continue; } + for (const entry of entries) { + if (count >= REFS_WALK_MAX_ENTRIES) break; + const full = path.join(current, entry.name); + if (entry.isDirectory()) { stack.push(full); continue; } + parts.push(`${full}:${statMtime(full)}`); + count += 1; + } + } + return crypto.createHash('sha1').update(parts.join('|')).digest('hex'); +} + +function pollOnce(folderPath) { + const state = watches.get(folderPath); + if (!state) return; + const fp = computeFingerprint(state.commonDir); + if (!state.primed) { state.fingerprint = fp; state.primed = true; return; } + if (fp === state.fingerprint) return; + state.fingerprint = fp; + clearTimeout(state.debounceTimer); + state.debounceTimer = setTimeout(() => { try { send('git-graph-repo-changed', folderPath); } catch {} }, REPO_WATCH_DEBOUNCE_MS); +} + +function sweepIdleWatches() { + const now = Date.now(); + for (const [folderPath, state] of watches) { + if (now - state.lastAccess > REPO_WATCH_IDLE_MAX_MS) stopRepoWatch(folderPath); + } +} + +async function ensureRepoWatch(dir) { + const existing = watches.get(dir); + if (existing) { existing.lastAccess = Date.now(); return; } + let commonDir; + try { commonDir = await git.gitCommonDir(dir); } catch { return; } + const state = { commonDir, fingerprint: null, primed: false, debounceTimer: null, lastAccess: Date.now() }; + state.timer = setInterval(() => pollOnce(dir), REPO_WATCH_POLL_MS); + if (typeof state.timer.unref === 'function') state.timer.unref(); + watches.set(dir, state); + pollOnce(dir); // establish the baseline fingerprint without firing a spurious change + + if (!idleSweepTimer) { + idleSweepTimer = setInterval(sweepIdleWatches, REPO_WATCH_IDLE_SWEEP_MS); + if (typeof idleSweepTimer.unref === 'function') idleSweepTimer.unref(); + } +} + +function stopRepoWatch(dir) { + const state = watches.get(dir); + if (!state) return; + clearInterval(state.timer); + clearTimeout(state.debounceTimer); + watches.delete(dir); +} + +function stopAllRepoWatches() { + for (const folderPath of [...watches.keys()]) stopRepoWatch(folderPath); + if (idleSweepTimer) { clearInterval(idleSweepTimer); idleSweepTimer = null; } +} + +module.exports = { + init, + getProjectGitGraph, + getGitGraphCommitDetail, + getGitGraphCompareDetail, + getGitGraphFileAtRevision, + getGitGraphFileDiffBetween, + ensureRepoWatch, + stopRepoWatch, + stopAllRepoWatches, +}; diff --git a/git.js b/git.js index be7b57a31..3afc5ff12 100644 --- a/git.js +++ b/git.js @@ -283,8 +283,162 @@ async function fileDiff(dir, filePath) { return { path: relative, diff, truncated }; } +// --- Git Graph read functions --------------------------------------------- +// Everything below is read-only. Every call adds --no-optional-locks (never +// contend with a write in flight elsewhere) and, for anything that formats +// human-readable output, --no-color/--no-show-signature, so a user's own +// color.ui=always can never leak into text this code goes on to parse. +// Multi-field records use NUL between fields and, for git-log output, \x1e +// between records, the same convention parseCommits() already uses above. + +/** Normalize a path for use inside a git argv element (git wants '/' even on Windows). */ +function toGitPath(relPath) { + return String(relPath).split(path.sep).join('/').split('\\').join('/'); +} + +// Node's execFile refuses any argv string containing an actual NUL byte +// ("must be a string without null bytes") — so the *format strings* below +// use git's own textual escape for one (`%x00` for git-log's pretty-format +// engine, `%00` for for-each-ref's own, different, format language), and +// only the *parsed output* (which genuinely does contain raw NUL/RS bytes, +// written by git itself, safe for Node to read back) is split on the real +// characters. +const GG_FIELD_SEP = '\x00'; +const GG_RECORD_SEP = '\x1e'; +const LOG_FIELD_ESCAPE = '%x00'; +const LOG_RECORD_ESCAPE = '%x1e'; +const FOR_EACH_REF_FIELD_ESCAPE = '%00'; + +const LOG_ORDER_FLAGS = { date: '--date-order', 'author-date': '--author-date-order', topo: '--topo-order' }; + +// A revspec entry starting with '-' is only ever accepted from this fixed +// list (or one of the prefixes below) — anything else risks being read as +// a flag rather than a ref/branch-glob. +const REVSPEC_FLAGS = new Set(['--all', '--branches', '--tags', '--remotes', '--first-parent', '--reflog']); +const REVSPEC_FLAG_PREFIXES = ['--glob=', '--branches=', '--tags=', '--remotes=', '--exclude=']; + +function assertSafeRevspecEntry(entry) { + if (typeof entry !== 'string' || !entry || entry.includes('\0')) throw new Error('Invalid revision specifier'); + if (!entry.startsWith('-')) return entry; + if (REVSPEC_FLAGS.has(entry) || REVSPEC_FLAG_PREFIXES.some(prefix => entry.startsWith(prefix))) return entry; + throw new Error(`Revision specifier '${entry}' is not on the allowed flag list`); +} + +function logPrettyFormat() { + return ['%H', '%h', '%P', '%an', '%ae', '%aI', '%cn', '%ce', '%cI', '%s'].join(LOG_FIELD_ESCAPE) + LOG_RECORD_ESCAPE; +} + +function parseLogWithParents(output) { + return String(output || '') + .split(GG_RECORD_SEP) + .map(record => record.replace(/^\n+|\n+$/g, '')) + .filter(Boolean) + .map((record) => { + const [hash, shortHash, parentField, authorName, authorEmail, authorDate, + committerName, committerEmail, commitDate, subject] = record.split(GG_FIELD_SEP); + return { + hash, shortHash, + parents: parentField ? parentField.split(' ').filter(Boolean) : [], + authorName, authorEmail, authorDate, + committerName, committerEmail, commitDate, + subject: subject || '', + }; + }); +} + +/** + * Commit list with parent hashes, for graph layout. `order` selects + * `--date-order` (default) / `--author-date-order` / `--topo-order`. + * `revspec` is a ref/hash or array of them (default `['HEAD']`); a leading + * '-' entry must be on the fixed allow-list above. Degrades to `[]` on an + * unborn HEAD rather than throwing. + */ +async function logWithParents(dir, { revspec, order = 'date', skip, limit } = {}) { + const orderFlag = LOG_ORDER_FLAGS[order] || LOG_ORDER_FLAGS.date; + const revArgs = [].concat(revspec == null ? ['HEAD'] : revspec).map(assertSafeRevspecEntry); + const args = ['--no-optional-locks', 'log', '--no-color', '--no-show-signature', orderFlag]; + if (Number(skip) > 0) args.push(`--skip=${Math.floor(Number(skip))}`); + if (Number(limit) > 0) args.push(`--max-count=${Math.floor(Number(limit))}`); + args.push(`--pretty=format:${logPrettyFormat()}`, ...revArgs); + return parseLogWithParents(await runOr(args, dir, '')); +} + +const FOR_EACH_REF_FORMAT = ['%(HEAD)', '%(refname)', '%(objectname)', '%(*objectname)', '%(objecttype)', '%(upstream)'].join(FOR_EACH_REF_FIELD_ESCAPE); + +/** + * Local branches, remote-tracking branches, and tags, via `for-each-ref`. + * Each head carries `isHead` (which head is HEAD — not the same as which + * commit HEAD points at while detached). Degrades to empty arrays on a + * repo with no refs at all rather than throwing. + */ +async function forEachRef(dir) { + const output = await runOr(['--no-optional-locks', 'for-each-ref', '--no-color', `--format=${FOR_EACH_REF_FORMAT}`, + 'refs/heads', 'refs/remotes', 'refs/tags'], dir, ''); + const heads = []; + const remotes = []; + const tags = []; + for (const line of String(output).split('\n')) { + if (!line) continue; + const [headMarker, refname, objectName, peeledObjectName, objectType, upstream] = line.split(GG_FIELD_SEP); + if (refname.startsWith('refs/heads/')) { + heads.push({ + name: refname.slice('refs/heads/'.length), + hash: objectName, + isHead: headMarker === '*', + upstream: upstream ? upstream.replace(/^refs\/remotes\//, '') : null, + }); + } else if (refname.startsWith('refs/remotes/')) { + const rest = refname.slice('refs/remotes/'.length); + const slash = rest.indexOf('/'); + if (slash === -1) continue; + const name = rest.slice(slash + 1); + if (name === 'HEAD') continue; // the remote's own default-branch pointer, not a real branch + remotes.push({ remote: rest.slice(0, slash), name, hash: objectName }); + } else if (refname.startsWith('refs/tags/')) { + const annotated = objectType === 'tag'; + tags.push({ name: refname.slice('refs/tags/'.length), hash: annotated ? (peeledObjectName || objectName) : objectName, annotated }); + } + } + return { heads, remotes, tags }; +} + +const STASH_FORMAT = ['%H', '%gd', '%gs', '%aI'].join(LOG_FIELD_ESCAPE); +const STASH_MESSAGE_BRANCH_RE = /^(?:WIP on|On) (.+):/; + +/** + * Stashes, each with `baseCommitHash` — the commit it was taken from, + * resolved as `^1` (its own first parent) so a stash can be laid + * into the graph as a pseudo-commit attached to that commit. + */ +async function stashList(dir) { + const output = await runOr(['--no-optional-locks', 'stash', 'list', '--no-color', `--format=${STASH_FORMAT}`], dir, ''); + const entries = String(output).split('\n').filter(Boolean).map((line) => { + const [hash, gd, message, date] = line.split(GG_FIELD_SEP); + const indexMatch = /stash@\{(\d+)\}/.exec(gd || ''); + const branchMatch = STASH_MESSAGE_BRANCH_RE.exec(message || ''); + return { hash, index: indexMatch ? Number(indexMatch[1]) : null, branch: branchMatch ? branchMatch[1] : null, message: message || '', date }; + }); + const bases = await Promise.all(entries.map(entry => runOr(['--no-optional-locks', 'rev-parse', `${entry.hash}^1`], dir, ''))); + return entries.map((entry, i) => ({ ...entry, baseCommitHash: bases[i].trim() || null })); +} + +/** + * File content at a revision (a blob, via `git show :`), for the + * diff viewer. `--no-textconv` so a repo's own .gitattributes filter can't + * substitute different bytes than what's actually stored. `relPath` goes + * through the same containment check as fileDiff(), then is normalized to + * '/'-separated (git's pathspec syntax wants that even on Windows). + */ +async function blobAtRevision(dir, rev, relPath) { + if (typeof rev !== 'string' || !rev || rev.includes('\0') || rev.startsWith('-')) throw new Error('Invalid revision'); + const { relative } = safeRelativePath(dir, relPath); + return runRaw(['--no-optional-locks', 'show', '--no-color', '--no-textconv', `${rev}:${toGitPath(relative)}`], dir); +} + module.exports = { run, version, isGitRepo, repoRoot, branchExists, worktreeAdd, worktreeRemove, isDirtyWorktreeError, gitCommonDir, status, parsePorcelain, snapshot, fileDiff, + toGitPath, safeRelativePath, + logWithParents, forEachRef, stashList, blobAtRevision, }; diff --git a/main.js b/main.js index 20b7f954e..cd424327f 100644 --- a/main.js +++ b/main.js @@ -315,6 +315,14 @@ projects.initPlanWatch({ if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send(channel, ...args); }, }); +// --- Git Graph tab: read-only commit graph service --- +const gitGraphService = require('./git-graph-service'); +gitGraphService.init({ + log, + send: (channel, ...args) => { + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send(channel, ...args); + }, +}); // --- IPC: browse-folder --- ipcMain.handle('browse-folder', async () => { @@ -470,6 +478,15 @@ ipcMain.handle('get-project-git-status', guarded((id, opts) => projects.folderGi ipcMain.handle('get-project-git-info', guarded((id) => projects.projectGitInfo(id))); ipcMain.handle('get-project-git-diff', guarded((id, folderPath, filePath) => projects.projectGitDiff(id, folderPath, filePath))); ipcMain.handle('get-folder-git-status', guarded((folderPath) => projects.folderGitInfo(String(folderPath || '')))); + +// --- IPC: Git Graph tab (read-only) --- +// Every handler delegates one line into projects.js, same shape as the four +// git handlers above; projects.js re-checks the attached-folder boundary. +ipcMain.handle('get-project-git-graph', guarded((id, folderPath, opts) => projects.projectGitGraph(id, String(folderPath || ''), opts || {}))); +ipcMain.handle('get-git-graph-commit-detail', guarded((id, folderPath, hash) => projects.projectGitGraphCommitDetail(id, String(folderPath || ''), hash))); +ipcMain.handle('get-git-graph-compare-detail', guarded((id, folderPath, fromHash, toHash) => projects.projectGitGraphCompareDetail(id, String(folderPath || ''), fromHash, toHash))); +ipcMain.handle('get-git-graph-file-at-revision', guarded((id, folderPath, rev, filePath) => projects.projectGitGraphFileAtRevision(id, String(folderPath || ''), rev, filePath))); +ipcMain.handle('get-git-graph-file-diff-between', guarded((id, folderPath, fromRev, toRevOrNull, filePath) => projects.projectGitGraphFileDiffBetween(id, String(folderPath || ''), fromRev, toRevOrNull, filePath))); // The .env files a folder has, and the ones the dialog ticks by default, // so a new worktree can be offered its repository's local environment. ipcMain.handle('list-env-files', guarded((folderPath) => ({ diff --git a/preload.js b/preload.js index 420b643b1..dd4070b86 100644 --- a/preload.js +++ b/preload.js @@ -60,6 +60,16 @@ contextBridge.exposeInMainWorld('api', { getProjectGitInfo: (id) => ipcRenderer.invoke('get-project-git-info', id), getProjectGitDiff: (id, folderPath, filePath) => ipcRenderer.invoke('get-project-git-diff', id, folderPath, filePath), getFolderGitStatus: (folderPath) => ipcRenderer.invoke('get-folder-git-status', folderPath), + + // Git Graph tab (read-only) + getProjectGitGraph: (id, folderPath, opts) => ipcRenderer.invoke('get-project-git-graph', id, folderPath, opts), + getGitGraphCommitDetail: (id, folderPath, hash) => ipcRenderer.invoke('get-git-graph-commit-detail', id, folderPath, hash), + getGitGraphCompareDetail: (id, folderPath, fromHash, toHash) => ipcRenderer.invoke('get-git-graph-compare-detail', id, folderPath, fromHash, toHash), + getGitGraphFileAtRevision: (id, folderPath, rev, filePath) => ipcRenderer.invoke('get-git-graph-file-at-revision', id, folderPath, rev, filePath), + getGitGraphFileDiffBetween: (id, folderPath, fromRev, toRevOrNull, filePath) => ipcRenderer.invoke('get-git-graph-file-diff-between', id, folderPath, fromRev, toRevOrNull, filePath), + onGitGraphRepoChanged: (callback) => { + ipcRenderer.on('git-graph-repo-changed', (_event, folderPath) => callback(folderPath)); + }, listEnvFiles: (folderPath) => ipcRenderer.invoke('list-env-files', folderPath), saveProjectBrief: (id, content) => ipcRenderer.invoke('save-project-brief', id, content), createProjectFile: (id, name, content) => ipcRenderer.invoke('create-project-file', id, name, content), diff --git a/projects.js b/projects.js index 26c990ad6..f4c0e7445 100644 --- a/projects.js +++ b/projects.js @@ -21,6 +21,7 @@ const os = require('os'); const path = require('path'); const crypto = require('crypto'); const git = require('./git'); +const gitGraphService = require('./git-graph-service'); const planParser = require('./public/plan-parser'); const sessionConfig = require('./public/session-config'); @@ -1785,11 +1786,55 @@ async function projectGitDiff(projectId, folderPath, filePath) { return { ok: true, ...(await git.fileDiff(folder.path, filePath)) }; } +// --- Git Graph tab (read-only): every function re-checks the exact same +// attached-folder boundary as projectGitDiff above before doing anything +// with folderPath. Payload assembly lives one layer down in +// git-graph-service.js; this section stays a thin boundary. --- + +/** Throws the same way projectGitDiff does when folderPath is not attached to projectId. */ +function requireAttachedFolder(projectId, folderPath) { + if (!db.getProject(projectId)) throw new Error('Project not found'); + const folder = db.listProjectFolders(projectId).find(item => item.path === folderPath); + if (!folder) throw new Error('Repository is not attached to this project'); + return folder; +} + +/** Commits+refs+stashes+uncommitted for the Git Graph tab, paged. */ +async function projectGitGraph(projectId, folderPath, opts) { + const folder = requireAttachedFolder(projectId, folderPath); + gitGraphService.ensureRepoWatch(folder.path).catch(() => {}); + return gitGraphService.getProjectGitGraph(folder.path, opts || {}); +} + +/** Full detail (body, file list) for one commit. */ +async function projectGitGraphCommitDetail(projectId, folderPath, hash) { + const folder = requireAttachedFolder(projectId, folderPath); + return gitGraphService.getGitGraphCommitDetail(folder.path, hash); +} + +/** File list for the Comparison view between two revisions (or the working tree). */ +async function projectGitGraphCompareDetail(projectId, folderPath, fromHash, toHash) { + const folder = requireAttachedFolder(projectId, folderPath); + return gitGraphService.getGitGraphCompareDetail(folder.path, fromHash, toHash); +} + +async function projectGitGraphFileAtRevision(projectId, folderPath, rev, filePath) { + const folder = requireAttachedFolder(projectId, folderPath); + return gitGraphService.getGitGraphFileAtRevision(folder.path, rev, filePath); +} + +async function projectGitGraphFileDiffBetween(projectId, folderPath, fromRev, toRevOrNull, filePath) { + const folder = requireAttachedFolder(projectId, folderPath); + return gitGraphService.getGitGraphFileDiffBetween(folder.path, fromRev, toRevOrNull, filePath); +} + module.exports = { init, projectsRoot, slugify, uniqueSlug, defaultBrief, createProject, updateProject, deleteProject, attachFolder, detachFolder, folderGitStatus, folderGitInfo, projectGitInfo, projectGitDiff, + projectGitGraph, projectGitGraphCommitDetail, projectGitGraphCompareDetail, + projectGitGraphFileAtRevision, projectGitGraphFileDiffBetween, syncProjectBrief, syncAllProjectBriefs, saveBrief, createProjectFile, addProjectFiles, listAddedFiles, listRecentProjectFiles, launchContext, mergeAddDirs, worktreeParentFor, PROJECT_FILES, ADDED_FILES_DIR, diff --git a/public/codemirror-setup.js b/public/codemirror-setup.js index e0d4cdd01..82ef65d2b 100644 --- a/public/codemirror-setup.js +++ b/public/codemirror-setup.js @@ -496,7 +496,7 @@ function createMergeViewer(parent, originalContent, modifiedContent, filename) { }); } -function createUnifiedMergeViewer(parent, originalContent, modifiedContent, filename) { +function createUnifiedMergeViewer(parent, originalContent, modifiedContent, filename, { readOnly = false } = {}) { const langExt = getLanguageExt(filename); const state = EditorState.create({ doc: modifiedContent, @@ -516,12 +516,15 @@ function createUnifiedMergeViewer(parent, originalContent, modifiedContent, file dracula, syntaxHighlighting(markdownExtras), appThemePatch, + readOnly ? EditorView.editable.of(false) : [], + readOnly ? EditorState.readOnly.of(true) : [], unifiedMergeView({ original: originalContent, gutter: true, highlightChanges: true, syntaxHighlightDeletions: true, collapseUnchanged: { margin: 3, minSize: 4 }, + mergeControls: !readOnly, }), ], }); diff --git a/public/git-graph-layout.js b/public/git-graph-layout.js new file mode 100644 index 000000000..6fd630301 --- /dev/null +++ b/public/git-graph-layout.js @@ -0,0 +1,253 @@ +// git-graph-layout.js — pure DAG lane assignment for the Git Graph tab. +// +// Turns an ordered Commit[] (already sorted by whichever commit order the +// caller asked git for — date / author-date / topo — and already carrying +// any stash/Uncommitted pseudo-commits already merged in) into one +// lane/colour/edge assignment per commit. No DOM, no window, no git: this +// file only ever looks at `hash`/`parents`, so it loads unchanged in a +// + + + + diff --git a/public/project-git-graph-view.js b/public/project-git-graph-view.js new file mode 100644 index 000000000..d32a37563 --- /dev/null +++ b/public/project-git-graph-view.js @@ -0,0 +1,1242 @@ +// "Git Graph" project tab: a read-only commit graph for the project's +// attached repositories, sitting immediately after the existing read-only +// "Git" tab (which this file never touches). Layout and painting (this file +// + git-graph-render.js) are self-contained; context menus are built by +// git-graph-menus.js and only ever called through the small set of guarded +// globals below, so this tab still renders and its own tests still pass +// before that file exists. +// +// Mutating actions (checkout, branch/tag management, stash apply, …), the +// settings drawer, avatars and the committed repo config file are a later +// PR — everything here only ever reads. +// +// Depends on globals: escapeHtml, escapeAttr, formatDate, pathBasename +// (utils.js / projects-view.js), PICONS, showContextMenu (projects-view.js), +// createUnifiedMergeViewer, createReadOnlyViewer (codemirror-setup.js), +// selectedProject/projectTab (projects-view.js), buildGitChangeTree, +// gitTreeRows (project-git-view.js), and the render helpers in +// git-graph-render.js. + +const GG_INITIAL_LOAD = 300; +const GG_LOAD_MORE = 100; + +const gitGraphTabState = new Map(); + +function gitGraphState(projectId) { + if (!gitGraphTabState.has(projectId)) { + gitGraphTabState.set(projectId, { + repositories: null, + selectedRepo: null, + request: 0, + detailsRequest: 0, + error: '', + loading: false, + loadingMore: false, + rawCommits: [], + rows: [], // merged Commit[]+pseudo-commits, render order + layout: [], // parallel lane assignments + refs: null, + stashes: [], + uncommitted: null, + headHash: null, + headBranchName: null, + skip: 0, + hasMore: false, + branchSelection: 'all', + tagSelection: 'all', + refSort: 'asc', + showRemoteBranches: true, + showTags: true, + showStashesPref: true, + showUncommittedChangesPref: true, + columns: { date: true, author: true, commit: true }, + columnWidths: {}, + // Fixed display defaults — no settings drawer to change them from. + dateFormat: 'date-time', + dateType: 'author', + graphStyle: 'rounded', + uncommittedChangesStyle: 'openAtUncommitted', + combineLocalAndRemote: true, + muteMergeCommits: true, + selectedHash: null, + compareHash: null, + detailsData: null, + detailsLoading: false, + detailsJustOpened: false, + fileViewType: 'tree', + collapsedFolders: new Set(), + findOpen: false, + findQuery: '', + findCaseSensitive: false, + findAlsoOpenDetails: false, + findMatches: [], + findIndex: -1, + keydownHandler: null, + branchesMenuOpen: false, + branchesMenuTab: 'branches', + branchesFilter: '', + branchesMenuCleanup: null, + }); + } + return gitGraphTabState.get(projectId); +} + +// --- window.api wrapper: degrades gracefully if the preload bridge doesn't expose a given call yet --- + +function gitGraphApi(name, ...args) { + if (!window.api || typeof window.api[name] !== 'function') { + return Promise.resolve({ error: `${name} is not available yet` }); + } + return window.api[name](...args); +} + +// --- Per-viewer UI-state persistence: localStorage only, per PR scope — no +// server-side RepoConfig/GlobalPrefs; a reset on a cleared profile is +// acceptable, matching the existing tab-memory idiom. --- + +function gitGraphUiKey(projectId) { return `gitGraph.ui.${projectId}`; } + +function gitGraphLoadUiPrefs(projectId) { + try { + const raw = localStorage.getItem(gitGraphUiKey(projectId)); + return raw ? JSON.parse(raw) : {}; + } catch { return {}; } +} + +function gitGraphSaveUiPrefs(projectId, state) { + try { + localStorage.setItem(gitGraphUiKey(projectId), JSON.stringify({ + selectedRepo: state.selectedRepo, + columns: state.columns, + columnWidths: state.columnWidths, + branchSelection: state.branchSelection, + tagSelection: state.tagSelection, + refSort: state.refSort, + showRemoteBranches: state.showRemoteBranches, + fileViewType: state.fileViewType, + })); + } catch { /* private-browsing / disabled storage: ephemeral state only */ } +} + +// --- Pure helpers (unit-tested directly; no DOM) --- + +/** Case-sensitive/-insensitive substring match across message/date/author/hash/branch/tag. */ +function gitGraphFindMatches(rows, query, caseSensitive) { + if (!query) return []; + const q = caseSensitive ? query : query.toLowerCase(); + const norm = (v) => (caseSensitive ? String(v || '') : String(v || '').toLowerCase()); + const matches = []; + const refName = (typeof gitGraphRefName === 'function') ? gitGraphRefName : (entry => (typeof entry === 'string' ? entry : entry && entry.name)); + rows.forEach((row, index) => { + const refNames = [ + ...(row.refs?.heads || []).map(refName), + ...(row.refs?.remotes || []).map(r => r.name), + ...(row.refs?.tags || []).map(refName), + ]; + const haystacks = [row.subject, row.authorDate, row.commitDate, row.authorName, row.hash, ...refNames]; + if (haystacks.some(h => norm(h).includes(q))) matches.push(index); + }); + return matches; +} + +/** + * Escape → menu, then details panel, then Find, in that priority order. + * `hooks` are the closers for whichever of those is actually open; each + * returns true if it closed something. + */ +function gitGraphHandleEscapePriority(hooks) { + if (hooks.closeMenu && hooks.closeMenu()) return 'menu'; + if (hooks.closeDetails && hooks.closeDetails()) return 'details'; + if (hooks.closeFind && hooks.closeFind()) return 'find'; + return null; +} + +function gitGraphIsEditableTarget(target) { + if (!target) return false; + const tag = (target.tagName || '').toLowerCase(); + return tag === 'input' || tag === 'textarea' || target.isContentEditable; +} + +/** Builds the file-menu target object git-graph-menus.js's gitGraphBuildFileMenu expects. */ +function gitGraphBuildFileCtx(state, filePath) { + const data = state.detailsData || {}; + const file = (data.files || []).find(f => f.path === filePath) || { path: filePath }; + const repo = (state.repositories || []).find(r => r.path === state.selectedRepo); + return { + path: file.path, + relativePath: file.path, + absolutePath: repo ? `${repo.path}/${file.path}` : file.path, + }; +} + +/** Fills in the one target-specific field (commit/localBranch/remoteBranch/tag/file/columnHeader) git-graph-menus.js's ctx contract expects, from the frozen data-gg-* attributes. */ +function gitGraphPopulateTargetField(ctx, state, target) { + if (!target) return; + if (target.kind === 'commit') { + const row = state.rows.find(r => r.hash === target.hash); + if (row) ctx.commit = { hash: row.hash, shortHash: row.shortHash, subject: row.subject }; + } else if (target.kind === 'branch' && target.refType !== 'remote') { + ctx.localBranch = { name: target.refName, hash: target.hash }; + } else if (target.kind === 'remote-branch' || (target.kind === 'branch' && target.refType === 'remote')) { + ctx.remoteBranch = { remote: target.remote, name: target.refName, hash: target.hash }; + } else if (target.kind === 'tag') { + ctx.tag = { name: target.refName, hash: target.hash }; + } else if (target.kind === 'file') { + ctx.file = gitGraphBuildFileCtx(state, target.filePath); + } else if (target.kind === 'column-header') { + ctx.columnHeader = { columnVisibility: state.columns }; + } +} + +/** Assembles the ctx object git-graph-menus.js's gitGraphBuild*Menu functions read. */ +function gitGraphBuildMenuCtx(project, state, body, repo, target) { + const ctx = { + onColumnVisibilityChange: (col, visible) => { + state.columns[col] = visible; + gitGraphSaveUiPrefs(project.id, state); + gitGraphPaint(project, state, body); + }, + onViewDiff: (file) => gitGraphOpenFileDiff(project, state, body, repo, file.path), + onViewFileAtRevision: (file) => gitGraphViewFileAtRevision(project, state, body, repo, file), + }; + gitGraphPopulateTargetField(ctx, state, target); + return ctx; +} + +/** One call site for every right-click: builds ctx and opens the matching menu via git-graph-menus.js. */ +function gitGraphOpenContextMenu(project, state, body, el, position) { + const repo = (state.repositories || []).find(r => r.path === state.selectedRepo); + if (!repo || typeof gitGraphReadTargetAttrs !== 'function') return; + const target = gitGraphReadTargetAttrs(el); + if (!target || !target.kind) return; + const ctx = gitGraphBuildMenuCtx(project, state, body, repo, target); + if (typeof gitGraphShowContextMenu === 'function') gitGraphShowContextMenu(el, ctx, position); +} + +// --- Layout / row assembly --- + +function gitGraphMergedRows(state) { + return gitGraphBuildLayoutInput(state.rawCommits || [], state.stashes || [], state.uncommitted, state.headHash); +} + +function gitGraphRecomputeLayout(state) { + state.rows = gitGraphMergedRows(state); + const layoutFn = gitGraphResolveLayoutFn(); + state.layout = layoutFn(state.rows, 'date', {}); +} + +// --- Data loading --- + +function gitGraphRepoList(project) { + const seen = new Set(); + const list = []; + for (const path of [project.root, ...((project.folders || []).map(f => f.path))]) { + if (path && !seen.has(path)) { seen.add(path); list.push({ path }); } + } + return list; +} + +function gitGraphLoadRepositories(project, state) { + return gitGraphApi('getProjectGitInfo', project.id).then((result) => { + const list = (result && result.ok && result.repositories) ? result.repositories : gitGraphRepoList(project); + state.repositories = list.filter(r => r.git !== false); + if (!state.repositories.length) state.repositories = list; + return state.repositories; + }); +} + +function gitGraphLoadGraph(project, state, body, opts = {}) { + const repo = (state.repositories || []).find(r => r.path === state.selectedRepo) || (state.repositories || [])[0]; + if (!repo) return Promise.resolve(); + state.selectedRepo = repo.path; + const reset = opts.reset !== false; + if (reset) { state.skip = 0; state.rawCommits = []; } + const limit = reset ? GG_INITIAL_LOAD : GG_LOAD_MORE; + const request = ++state.request; + if (reset) { state.loading = true; state.error = ''; } else { state.loadingMore = true; } + // This interim repaint (setting the loading-more flag before the fetch + // even resolves) must preserve scroll too — Load More is triggered by + // scrolling near the bottom, so a bare gitGraphPaint() here would already + // reset the table to the top before any new rows arrive. + gitGraphPaint(project, state, body, { preserveScroll: !reset }); + + return gitGraphApi('getProjectGitGraph', project.id, repo.path, { + branches: state.branchSelection, + tags: state.tagSelection, + order: 'date', + limit, + skip: state.skip, + showRemote: state.showRemoteBranches, + showTags: state.showTags, + showStashes: state.showStashesPref, + showUncommittedChanges: state.showUncommittedChangesPref, + refsUnchanged: !reset, + }).then((result) => { + if (request !== state.request) return; + state.loading = false; + state.loadingMore = false; + if (!result || !result.ok) { + state.error = (result && result.error) || 'Could not load the commit graph.'; + gitGraphPaint(project, state, body); + return; + } + const incoming = result.commits || []; + state.rawCommits = reset ? incoming : [...(state.rawCommits || []), ...incoming]; + if (result.refs) state.refs = result.refs; + state.stashes = result.stashes || []; + state.uncommitted = state.showUncommittedChangesPref !== false ? (result.uncommitted || null) : null; + state.headHash = (state.rawCommits.find(c => c.isHead) || {}).hash || state.headHash; + state.headBranchName = ((state.refs && state.refs.heads || []).find(h => h.isHead) || {}).name || null; + state.hasMore = !!result.hasMore; + state.skip = state.rawCommits.length; + gitGraphRecomputeLayout(state); + // Load More only appends rows and must never disturb the table's scroll + // position; a fresh load (repo/filter change) naturally lands at the top + // of a different row set, so only the former asks gitGraphPaint to + // preserve it. + gitGraphPaint(project, state, body, { preserveScroll: !reset }); + }).catch((err) => { + if (request !== state.request) return; + state.loading = false; + state.loadingMore = false; + state.error = err?.message || 'Could not load the commit graph.'; + gitGraphPaint(project, state, body); + }); +} + +function gitGraphStillActive(project) { + return typeof selectedProject === 'function' && selectedProject()?.id === project.id && + typeof projectTab === 'function' && projectTab(project) === 'gitgraph'; +} + +// --- Entry point --- + +function renderProjectGitGraphTab(project, body) { + const state = gitGraphState(project.id); + const prefs = gitGraphLoadUiPrefs(project.id); + Object.assign(state, { + selectedRepo: state.selectedRepo || prefs.selectedRepo || null, + columns: prefs.columns || state.columns, + columnWidths: prefs.columnWidths || state.columnWidths, + branchSelection: prefs.branchSelection || state.branchSelection, + tagSelection: prefs.tagSelection || state.tagSelection, + refSort: prefs.refSort || state.refSort, + showRemoteBranches: prefs.showRemoteBranches !== undefined ? prefs.showRemoteBranches : state.showRemoteBranches, + fileViewType: prefs.fileViewType || state.fileViewType, + }); + body.className = 'ws-body gg-tab-body'; + gitGraphBindKeyboard(project, state, body); + gitGraphEnsureWatcherBound(); + + // project.folders can change while this tab isn't the one being painted + // (e.g. Attach Folder… from the Settings tab) — state.repositories is + // cached per project id across tab switches, so without this check a + // newly-attached folder would never appear here until the app restarts. + const freshPaths = gitGraphRepoList(project).map(r => r.path); + const cachedPaths = (state.repositories || []).map(r => r.path); + const repositoriesStale = !!state.repositories && !gitGraphSameStringSet(cachedPaths, freshPaths); + + if (state.repositories && !repositoriesStale) { gitGraphPaint(project, state, body); return; } + const firstLoad = !state.repositories; + if (firstLoad) body.innerHTML = '
Reading repository…
'; + gitGraphLoadRepositories(project, state).then(() => { + if (!body.isConnected || !gitGraphStillActive(project)) return; + if (!state.repositories.length) { gitGraphPaint(project, state, body); return; } + if (!firstLoad) { + // Only the repo list itself was stale — the currently selected repo's + // own loaded graph is still valid, so just refresh the picker rather + // than losing the user's place. + gitGraphPaint(project, state, body); + return; + } + gitGraphLoadGraph(project, state, body, { reset: true }); + }); +} + +function gitGraphSameStringSet(a, b) { + if (a.length !== b.length) return false; + const sa = [...a].sort(); + const sb = [...b].sort(); + return sa.every((v, i) => v === sb[i]); +} + +// --- Painting --- + +function gitGraphPaint(project, state, body, opts = {}) { + const prevWrap = opts.preserveScroll ? body.querySelector('.gg-table-wrap') : null; + const prevScrollTop = prevWrap ? prevWrap.scrollTop : null; + + const repos = state.repositories || []; + if (!repos.length) { + body.innerHTML = '
No folders attached
Attach a repository to this project to see its history here.
'; + return; + } + const repo = repos.find(r => r.path === state.selectedRepo) || repos[0]; + state.selectedRepo = repo.path; + + if (repo.git === false) { + body.innerHTML = `
${gitGraphRepoPickerHtml(repos, repo)}
Not a Git repository
${escapeHtml(repo.path)}
`; + gitGraphWireRepoPicker(project, state, body); + return; + } + + if (state.error) { + body.innerHTML = `
${gitGraphRepoPickerHtml(repos, repo)}
Git Graph could not load this repository
${escapeHtml(state.error)}
`; + body.querySelector('#gg-retry').onclick = () => gitGraphLoadGraph(project, state, body, { reset: true }); + gitGraphWireRepoPicker(project, state, body); + return; + } + + if (state.loading && !state.rows.length) { + body.innerHTML = `
${gitGraphRepoPickerHtml(repos, repo)}
Loading commits…
`; + gitGraphWireRepoPicker(project, state, body); + return; + } + + body.innerHTML = ` +
+ ${gitGraphRepoPickerHtml(repos, repo)} + ${gitGraphControlBarHtml(state)} + ${gitGraphFindWidgetHtml(state)} +
+ ${gitGraphHeaderHtml(state)} +
${gitGraphRowsHtml(state)}
+ ${state.hasMore ? '' : ''} +
+
`; + + gitGraphWireRepoPicker(project, state, body); + gitGraphWireControlBar(project, state, body); + gitGraphWireFindWidget(project, state, body); + gitGraphWireHeader(project, state, body); + gitGraphWireRows(project, state, body); + if (state.detailsData) { + // The inline details markup already came back inside #gg-tbody's own + // innerHTML (gitGraphRowsHtml/gitGraphInlineDetailsHtml), so only the + // event wiring — not a second render — is needed here. + const inline = body.querySelector('#gg-details-inline'); + if (inline) { gitGraphWireDetailsPanel(project, state, body, inline); gitGraphMaybeAutoCenterDetails(state, inline); } + } + + if (prevScrollTop != null) { + const newWrap = body.querySelector('.gg-table-wrap'); + if (newWrap) newWrap.scrollTop = prevScrollTop; + } +} + +function gitGraphRepoPickerHtml(repos, active) { + if (repos.length <= 1) return ''; + return `
${repos.map((r, i) => ``).join('')}
`; +} + +function gitGraphWireRepoPicker(project, state, body) { + body.querySelectorAll('.gg-repo-option').forEach((btn) => { + const repo = (state.repositories || [])[Number(btn.dataset.index)]; + if (!repo) return; + btn.onclick = () => { + state.selectedRepo = repo.path; + state.selectedHash = null; + state.detailsData = null; + gitGraphSaveUiPrefs(project.id, state); + gitGraphLoadGraph(project, state, body, { reset: true }); + }; + }); +} + +// --- Control bar --- + +function gitGraphControlBarHtml(state) { + const branchLabel = !Array.isArray(state.branchSelection) ? 'Show All' + : state.branchSelection.length + ? `${state.branchSelection.length} branch${state.branchSelection.length === 1 ? '' : 'es'}` + : 'None'; + const tagCount = Array.isArray(state.tagSelection) ? state.tagSelection.length : null; + const tagLabel = tagCount === null ? '' : ` · ${tagCount} tag${tagCount === 1 ? '' : 's'}`; + return ` +
+
+
+ +
+ +
+
+ + +
+
+ `; +} + +function gitGraphWireControlBar(project, state, body) { + body.querySelector('#gg-show-remote').onchange = (e) => { + state.showRemoteBranches = e.target.checked; + gitGraphSaveUiPrefs(project.id, state); + gitGraphLoadGraph(project, state, body, { reset: true }); + }; + body.querySelector('#gg-find-btn').onclick = () => { state.findOpen = !state.findOpen; gitGraphPaint(project, state, body); }; + body.querySelector('#gg-refresh-btn').onclick = () => gitGraphLoadGraph(project, state, body, { reset: true }); + const branchesBtn = body.querySelector('#gg-branches-btn'); + if (branchesBtn) { + branchesBtn.onclick = () => gitGraphToggleBranchesMenu(project, state, body, branchesBtn); + // Picking a branch reloads (and repaints) the graph; keep the menu open across that. + if (state.branchesMenuOpen) gitGraphOpenBranchesMenu(project, state, body, branchesBtn); + } +} + +function gitGraphCloseBranchesMenu(state) { + state.branchesMenuOpen = false; + if (state.branchesMenuCleanup) state.branchesMenuCleanup(); + state.branchesMenuCleanup = null; +} + +function gitGraphToggleBranchesMenu(project, state, body, anchor) { + if (state.branchesMenuOpen) { + gitGraphCloseBranchesMenu(state); + const menu = body.querySelector('#gg-branches-menu'); + if (menu) menu.style.display = 'none'; + return; + } + gitGraphOpenBranchesMenu(project, state, body, anchor); +} + +function gitGraphOpenBranchesMenu(project, state, body, anchor) { + const menu = body.querySelector('#gg-branches-menu'); + if (!menu) return; + if (state.branchesMenuCleanup) state.branchesMenuCleanup(); + state.branchesMenuOpen = true; + const tab = state.branchesMenuTab === 'tags' ? 'tags' : 'branches'; + const selKey = tab === 'tags' ? 'tagSelection' : 'branchSelection'; + let allNames; + if (tab === 'tags') { + allNames = (state.refs?.tags || []).map(t => t.name); + } else { + const heads = state.refs?.heads || []; + const remotes = state.showRemoteBranches ? (state.refs?.remotes || []) : []; + allNames = [...heads.map(h => h.name), ...remotes.map(r => `${r.remote}/${r.name}`)]; + } + // Natural order, so v1.10 sorts after v1.9. + const sortDir = state.refSort === 'desc' ? -1 : 1; + allNames.sort((a, b) => sortDir * a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })); + const selection = state[selKey] || 'all'; + const selected = selection === 'all' ? new Set(allNames) : new Set(selection); + menu.innerHTML = ` +
+ + + +
+ +
+ + + +
+
${allNames.length ? allNames.map(name => ``).join('') : `
No ${tab}.
`}
`; + menu.style.display = 'block'; + // Fixed to the button so it floats above the graph instead of sitting in the page flow. + const rect = anchor.getBoundingClientRect(); + menu.style.top = `${rect.bottom + 4}px`; + menu.style.left = `${rect.left}px`; + menu.style.maxHeight = `${Math.max(200, window.innerHeight - rect.bottom - 24)}px`; + const dismiss = (e) => { + if (e.type === 'keydown' ? e.key !== 'Escape' : (menu.contains(e.target) || anchor.contains(e.target))) return; + gitGraphCloseBranchesMenu(state); + menu.style.display = 'none'; + }; + document.addEventListener('pointerdown', dismiss, true); + document.addEventListener('keydown', dismiss, true); + state.branchesMenuCleanup = () => { + document.removeEventListener('pointerdown', dismiss, true); + document.removeEventListener('keydown', dismiss, true); + }; + menu.querySelectorAll('.gg-branches-tab').forEach((btn) => { + btn.onclick = () => { state.branchesMenuTab = btn.dataset.tab; state.branchesFilter = ''; gitGraphOpenBranchesMenu(project, state, body, anchor); }; + }); + menu.querySelector('#gg-branches-sort').onclick = () => { + state.refSort = sortDir === 1 ? 'desc' : 'asc'; + gitGraphSaveUiPrefs(project.id, state); + gitGraphOpenBranchesMenu(project, state, body, anchor); + }; + const filter = menu.querySelector('#gg-branches-filter'); + filter.value = state.branchesFilter || ''; + const applyFilter = () => { + const q = filter.value.toLowerCase(); + menu.querySelectorAll('.gg-branches-list .gg-branches-row').forEach((row) => { + row.style.display = row.dataset.name.toLowerCase().includes(q) ? '' : 'none'; + }); + }; + applyFilter(); + const commit = (next) => { + state[selKey] = next; + gitGraphSaveUiPrefs(project.id, state); + gitGraphLoadGraph(project, state, body, { reset: true }); + }; + menu.querySelectorAll('.gg-branches-actions button').forEach((btn) => { + btn.onclick = () => { + if (btn.dataset.act === 'all') return commit('all'); + if (btn.dataset.act === 'none') return commit([]); + const current = state[selKey] === 'all' ? new Set(allNames) : new Set(state[selKey]); + commit(allNames.filter(name => !current.has(name))); + }; + }); + menu.querySelectorAll('.gg-branches-list input[type="checkbox"]').forEach((cb) => { + cb.onchange = () => { + const current = state[selKey] === 'all' ? new Set(allNames) : new Set(state[selKey]); + if (cb.checked) current.add(cb.dataset.name); else current.delete(cb.dataset.name); + commit([...current]); + }; + }); + filter.oninput = () => { state.branchesFilter = filter.value; applyFilter(); }; +} + +// --- Table header --- + +const GG_COLUMNS = [ + { key: 'graph', label: 'Graph', hideable: false }, + { key: 'description', label: 'Description', hideable: false }, + { key: 'date', label: 'Date', hideable: true }, + { key: 'author', label: 'Author', hideable: true }, + { key: 'commit', label: 'Commit', hideable: true }, +]; + +function gitGraphHeaderHtml(state) { + const cells = GG_COLUMNS.filter(col => !col.hideable || state.columns[col.key]).map((col) => { + const width = state.columnWidths[col.key] ? ` style="width:${state.columnWidths[col.key]}px"` : ''; + return `
${escapeHtml(col.label)}
`; + }).join(''); + return `
${cells}
`; +} + +function gitGraphWireHeader(project, state, body) { + const thead = body.querySelector('#gg-thead'); + if (!thead) return; + thead.oncontextmenu = (e) => { + e.preventDefault(); + gitGraphOpenContextMenu(project, state, body, thead, { x: e.clientX, y: e.clientY }); + }; + thead.querySelectorAll('.gg-col-resizer').forEach((handle) => { + handle.onpointerdown = (e) => { + e.preventDefault(); + const col = handle.dataset.col; + const cell = handle.closest('.gg-th'); + const startX = e.clientX; + const startWidth = cell.getBoundingClientRect().width; + const onMove = (moveEvent) => { + const next = Math.max(30, startWidth + (moveEvent.clientX - startX)); + cell.style.width = `${next}px`; + state.columnWidths[col] = next; + if (col === 'graph') thead.closest('.gg-table-wrap')?.style.setProperty('--gg-graph-width', `${next}px`); + }; + const onUp = () => { + document.removeEventListener('pointermove', onMove); + document.removeEventListener('pointerup', onUp); + gitGraphSaveUiPrefs(project.id, state); + }; + document.addEventListener('pointermove', onMove); + document.addEventListener('pointerup', onUp); + }; + }); +} + +// --- Rows --- + +function gitGraphRowsHtml(state) { + if (!state.rows.length) return '
No commits yet.
'; + const showCols = { date: state.columns.date, author: state.columns.author, commit: state.columns.commit }; + return state.rows.map((row, index) => gitGraphRowHtml(row, state, showCols, index)).join('') + + (state.detailsData ? gitGraphInlineDetailsHtml(state) : ''); +} + +function gitGraphRowHtml(row, state, showCols, index) { + const kind = row.kind === 'uncommitted' ? 'uncommitted' : (row.kind === 'stash' ? 'stash' : 'commit'); + const selected = row.hash === state.selectedHash || row.hash === state.compareHash; + const classes = gitGraphRowClasses({ ...row, selected }, state); + const dateValue = state.dateType === 'commit' ? row.commitDate : row.authorDate; + const dateText = kind === 'uncommitted' ? gitGraphFormatDate(new Date().toISOString(), state.dateFormat) : gitGraphFormatDate(dateValue, state.dateFormat); + const authorText = kind === 'uncommitted' ? '*' : escapeHtml(row.authorName || ''); + const commitText = kind === 'uncommitted' ? '*' : escapeHtml(row.shortHash || (row.hash || '').slice(0, 8)); + const pills = kind === 'commit' || kind === 'stash' ? gitGraphRenderRefPills(row, { combineLocalAndRemote: state.combineLocalAndRemote, headBranchName: state.headBranchName }) : ''; + const stashBadge = kind === 'stash' ? `stash@{${row.stashIndex}}` : ''; + return ` +
+
+
${pills}${stashBadge}${escapeHtml(row.subject || '')}
+ ${showCols.date ? `
${escapeHtml(dateText)}
` : ''} + ${showCols.author ? `
${authorText}
` : ''} + ${showCols.commit ? `
${commitText}
` : ''} +
`; +} + +function gitGraphWireRows(project, state, body) { + const tbody = body.querySelector('#gg-tbody'); + if (!tbody) return; + const graphColumns = tbody.querySelectorAll('.gg-cell-graph'); + if (graphColumns.length) { + // One for the whole loaded window, absolutely positioned over + // .gg-tbody (already `position: relative`) so it lines up with every + // row's graph cell at once — each individual .gg-cell-graph stays an + // empty placeholder that only reserves the column's width. Building a + // second, separately-clipped copy of the full svg's markup per row + // (every row repeating every other row's paths/circles) was O(rows²) + // DOM nodes; this is O(rows). + const rowY = []; + tbody.querySelectorAll('.gg-row[data-row-index]').forEach((el) => { + rowY[Number(el.dataset.rowIndex)] = el.offsetTop + el.offsetHeight / 2; + }); + const measured = rowY.length === state.rows.length && rowY.some(y => y > 0); + const svg = gitGraphRenderGraphSvg(state.rows, state.layout, { + style: state.graphStyle, uncommittedChangesStyle: state.uncommittedChangesStyle, + rowY: measured ? rowY : null, height: measured ? tbody.scrollHeight : null, + }); + const clip = document.createElement('div'); + clip.className = 'gg-graph-clip'; // decorative only — clicks/right-clicks fall through to the row underneath + clip.innerHTML = svg; + const svgEl = clip.firstChild; + clip.style.height = `${svgEl.getAttribute('height')}px`; + // The column fits the graph unless the user has resized it; either way the + // clip keeps the lanes from ever drawing over the description text. + const graphWidth = state.columnWidths.graph || Math.max(60, Number(svgEl.getAttribute('width')) + 6); + body.querySelector('.gg-table-wrap')?.style.setProperty('--gg-graph-width', `${graphWidth}px`); + tbody.insertBefore(clip, tbody.firstChild); + } + + tbody.querySelectorAll('[data-gg-kind]').forEach((el) => { + // Every ref pill/stash badge nested inside a row also carries its own + // data-gg-kind, so this same querySelectorAll matches both a pill and + // its enclosing row. Without stopPropagation, a click/right-click on the + // pill would fire its own handler and then bubble to the row's. + el.onclick = (e) => { e.stopPropagation(); gitGraphHandleRowClick(project, state, body, el, e); }; + el.oncontextmenu = (e) => { + e.preventDefault(); + e.stopPropagation(); + gitGraphOpenContextMenu(project, state, body, el, { x: e.clientX, y: e.clientY }); + }; + el.onmouseenter = () => gitGraphShowTooltip(el, state); + }); + + const loadMoreBtn = body.querySelector('#gg-load-more'); + if (loadMoreBtn) loadMoreBtn.onclick = () => gitGraphLoadGraph(project, state, body, { reset: false }); + gitGraphBindScrollAutoLoad(project, state, body); +} + +function gitGraphBindScrollAutoLoad(project, state, body) { + const wrap = body.querySelector('.gg-table-wrap'); + if (!wrap) return; + wrap.onscroll = () => { + if (state.loadingMore || !state.hasMore) return; + if (wrap.scrollTop + wrap.clientHeight >= wrap.scrollHeight - 80) { + gitGraphLoadGraph(project, state, body, { reset: false }); + } + }; +} + +/** + * Whether `ancestorHash` is a (possibly indirect) parent of `descendantHash`, + * walking `.parents` through whichever commits are currently loaded — the + * only history available client-side. A pseudo-row (Uncommitted, a stash) + * has its own single-parent link into the real graph, so it walks the same + * way as any real commit. + */ +function gitGraphIsAncestor(rows, ancestorHash, descendantHash) { + const byHash = new Map((rows || []).map(r => [r.hash, r])); + const seen = new Set(); + const stack = [descendantHash]; + while (stack.length) { + const current = stack.pop(); + if (seen.has(current)) continue; + seen.add(current); + const row = byHash.get(current); + if (!row) continue; + for (const parentHash of row.parents || []) { + if (parentHash === ancestorHash) return true; + stack.push(parentHash); + } + } + return false; +} + +function gitGraphHandleRowClick(project, state, body, el, event) { + const hash = el.dataset.ggHash; + if (!hash) return; + const row = state.rows.find(r => r.hash === hash); + if (event.ctrlKey || event.metaKey) { + if (state.selectedHash && state.selectedHash !== hash) { + const first = state.selectedHash, second = hash; + // Order-independent: the comparison always reads from whichever side + // is actually the ancestor toward the descendant. Click order only + // decides it when neither is reachable from the other within the + // loaded window (e.g. unrelated histories) — the default below. + let fromHash = first, toHash = second; + if (gitGraphIsAncestor(state.rows, second, first)) { fromHash = second; toHash = first; } + state.compareHash = hash; + gitGraphOpenComparison(project, state, body, fromHash, toHash); + return; + } + } + state.selectedHash = hash; + state.compareHash = null; + gitGraphOpenDetails(project, state, body, row); +} + +function gitGraphShowTooltip(el, state) { + const hash = el.dataset.ggHash; + const row = state.rows.find(r => r.hash === hash); + if (!row) return; + el.title = row.isHead ? 'HEAD — the currently checked-out commit' : (row.subject || ''); +} + +// --- Commit Details / Comparison --- + +function gitGraphOpenDetails(project, state, body, row) { + if (!row) return; + const repo = (state.repositories || []).find(r => r.path === state.selectedRepo); + const request = ++state.detailsRequest; + state.detailsLoading = true; + state.detailsData = { mode: 'single', hash: row.hash, kind: row.kind }; + state.detailsJustOpened = true; + gitGraphPaint(project, state, body); + const fetch = row.kind === 'uncommitted' + ? gitGraphApi('getGitGraphCompareDetail', project.id, repo.path, state.headHash, null) + : gitGraphApi('getGitGraphCommitDetail', project.id, repo.path, row.hash); + fetch.then((result) => { + if (request !== state.detailsRequest) return; + state.detailsLoading = false; + if (result && result.ok) { + state.detailsData = { mode: 'single', hash: row.hash, kind: row.kind, commit: result.commit, files: result.files || [] }; + } else { + state.detailsData = { mode: 'single', hash: row.hash, kind: row.kind, error: (result && result.error) || 'Could not load commit details.' }; + } + gitGraphPaint(project, state, body); + }); +} + +function gitGraphOpenComparison(project, state, body, fromHash, toHash) { + const repo = (state.repositories || []).find(r => r.path === state.selectedRepo); + const request = ++state.detailsRequest; + state.detailsLoading = true; + state.detailsData = { mode: 'compare', fromHash, toHash }; + state.detailsJustOpened = true; + gitGraphPaint(project, state, body); + const toArg = toHash === '#uncommitted' ? null : toHash; + gitGraphApi('getGitGraphCompareDetail', project.id, repo.path, fromHash === '#uncommitted' ? toHash : fromHash, fromHash === '#uncommitted' ? null : toArg) + .then((result) => { + if (request !== state.detailsRequest) return; + state.detailsLoading = false; + if (result && result.ok) state.detailsData = { mode: 'compare', fromHash, toHash, files: result.files || [] }; + else state.detailsData = { mode: 'compare', fromHash, toHash, error: (result && result.error) || 'Could not load comparison.' }; + gitGraphPaint(project, state, body); + }); +} + +function gitGraphInlineDetailsHtml(state) { + return `
${gitGraphDetailsBodyHtml(state)}
`; +} + +function gitGraphRenderDetailsPanel(project, state, body) { + const container = body.querySelector('#gg-details-inline'); + if (!container) return; + container.innerHTML = gitGraphDetailsBodyHtml(state); + gitGraphWireDetailsPanel(project, state, body, container); +} + +/** Scrolls the panel into view exactly once per open — the moment it opens, + * not on every subsequent repaint of an already-open panel. + * `state.detailsJustOpened` is set by gitGraphOpenDetails/OpenComparison and + * cleared the first time this runs *with the real content loaded*. The + * request's first paint (still `detailsLoading`) shows only the tiny + * "Loading…" placeholder, so centering on it would land short of where the + * eventual, much taller panel actually ends up — wait for the repaint that + * carries the real commit/diff content before centering and clearing it. */ +function gitGraphMaybeAutoCenterDetails(state, container) { + if (!state.detailsJustOpened || state.detailsLoading) return; + state.detailsJustOpened = false; + if (container && typeof container.scrollIntoView === 'function') container.scrollIntoView({ block: 'center' }); +} + +function gitGraphDetailsBodyHtml(state) { + const data = state.detailsData; + if (!data) return ''; + if (state.detailsLoading) return '
Loading…
'; + if (data.error) return `
${escapeHtml(data.error)}
`; + const header = data.mode === 'compare' + ? `
Displaying all changes from ${escapeHtml((data.fromHash || '').replace('#uncommitted', 'the working tree'))} to ${escapeHtml((data.toHash || '').replace('#uncommitted', 'the working tree'))}.
` + : gitGraphSingleCommitHeaderHtml(data.commit, data.kind); + const files = data.files || []; + return ` + +
+
${header}
+
+
+ + +
+ ${gitGraphFileListHtml(files, { fileViewType: state.fileViewType, collapsedFolders: state.collapsedFolders })} +
+
`; +} + +function gitGraphSingleCommitHeaderHtml(commit, kind) { + if (kind === 'uncommitted' || !commit) return '
Working tree changes
'; + const parentsHtml = (commit.parents || []).map(p => `${escapeHtml(p)}`).join(', ') || 'none (root commit)'; + const authorDate = gitGraphFormatFullDate(commit.authorDate); + const commitDate = gitGraphFormatFullDate(commit.commitDate); + const bothDates = commit.authorDate !== commit.commitDate; + return ` +
Commit: ${escapeHtml(commit.hash)}
+
Subject: ${escapeHtml(commit.subject || '')}
+
Parents: ${parentsHtml}
+
Author: ${escapeHtml(commit.authorName)} <${escapeHtml(commit.authorEmail)}>
+
Date: ${escapeHtml(authorDate)}${bothDates ? ` (committed ${escapeHtml(commitDate)})` : ''}
+
Committer: ${escapeHtml(commit.committerName)}
+
${gitGraphLinkifyBody(commit.body || '')}
`; +} + +function gitGraphLinkifyBody(body) { + const escaped = escapeHtml(body); + return escaped + .replace(/\n/g, '
') + // Excludes '"'/"'" from the match itself (on top of the '<' escapeHtml + // already turned into an entity): a commit body is attacker-controlled + // text, and without this a quote character could close the href + // attribute early and start a new, live one right where the match ends. + // The matched text has already been through escapeHtml above, so once + // quotes can't appear in it, it's already fully safe to drop straight + // into this attribute. + .replace(/https?:\/\/[^\s<>"']+/g, (url) => `${url}`); +} + +// --- Commit-details file list: reuses #98's tree builder (buildGitChangeTree/ +// gitTreeRows from project-git-view.js) instead of a second tree implementation. --- + +function gitGraphFileListHtml(files, opts) { + if (!files.length) return '
No files changed.
'; + if (opts.fileViewType === 'list') { + return `
${files.map(f => gitGraphFileRowHtml(f, { name: f.path })).join('')}
`; + } + const tree = buildGitChangeTree(files); + const rows = gitTreeRows(tree, opts.collapsedFolders, ''); + return `
${rows.map(row => row.kind === 'folder' + ? gitGraphFolderRowHtml(row) + : gitGraphFileRowHtml(row.change, { name: row.name, depth: row.depth })).join('')}
`; +} + +function gitGraphFolderRowHtml(row) { + return ` + `; +} + +function gitGraphFileRowHtml(file, opts = {}) { + const depth = opts.depth || 0; + return ` +
+ ${escapeHtml(gitGraphStatusBadge(file.status))} + ${escapeHtml(opts.name || file.path)} + ${gitGraphDiffStatHtml(file)} +
`; +} + +function gitGraphWireDetailsPanel(project, state, body, container) { + const closeBtn = container.querySelector('#gg-details-close'); + if (closeBtn) closeBtn.onclick = () => gitGraphCloseDetails(project, state, body); + container.querySelectorAll('[data-view]').forEach((btn) => { + btn.onclick = () => { + state.fileViewType = btn.dataset.view; + gitGraphSaveUiPrefs(project.id, state); + gitGraphRenderDetailsPanel(project, state, body); + }; + }); + container.querySelectorAll('.gg-tree-folder').forEach((btn) => { + btn.onclick = () => { + const key = btn.dataset.ggFolderKey; + if (state.collapsedFolders.has(key)) state.collapsedFolders.delete(key); else state.collapsedFolders.add(key); + gitGraphRenderDetailsPanel(project, state, body); + }; + }); + const repo = (state.repositories || []).find(r => r.path === state.selectedRepo); + container.querySelectorAll('[data-gg-kind="file"]').forEach((row) => { + row.onclick = () => gitGraphOpenFileDiff(project, state, body, repo, row.dataset.ggFilePath); + row.oncontextmenu = (e) => { + e.preventDefault(); + gitGraphOpenContextMenu(project, state, body, row, { x: e.clientX, y: e.clientY }); + }; + }); + container.querySelectorAll('[data-gg-kind="link"][data-gg-hash]').forEach((link) => { + link.onclick = (e) => { + e.preventDefault(); + const target = state.rows.find(r => r.hash === link.dataset.ggHash); + if (target) { state.selectedHash = target.hash; gitGraphOpenDetails(project, state, body, target); } + }; + }); +} + +function gitGraphCloseDetails(project, state, body) { + state.detailsData = null; + state.selectedHash = null; + state.compareHash = null; + gitGraphPaint(project, state, body); +} + +function gitGraphOpenFileDiff(project, state, body, repo, filePath, opts = {}) { + if (!repo || !filePath || typeof createUnifiedMergeViewer !== 'function') return; + const data = state.detailsData; + const fromRev = data.mode === 'compare' ? (data.fromHash === '#uncommitted' ? null : data.fromHash) : `${data.hash}^`; + const toRev = opts.forceWorkingTree ? null : (data.mode === 'compare' ? (data.toHash === '#uncommitted' ? null : data.toHash) : (data.kind === 'uncommitted' ? null : data.hash)); + gitGraphApi('getGitGraphFileDiffBetween', project.id, repo.path, fromRev, toRev, filePath).then((result) => { + if (!result || !result.ok) return; + const host = gitGraphEnsureDiffHost(); + host.innerHTML = ''; + createUnifiedMergeViewer(host, result.oldContent || '', result.newContent || '', filePath, { readOnly: true }); + }); +} + +/** "View File at this Revision" (file-menu item) — read-only single-pane view of the blob. */ +function gitGraphViewFileAtRevision(project, state, body, repo, file) { + const data = state.detailsData || {}; + const rev = data.mode === 'single' ? data.hash : data.toHash; + if (!repo || !rev || rev === '#uncommitted') return; + gitGraphApi('getGitGraphFileAtRevision', project.id, repo.path, rev, file.path).then((result) => { + if (!result || !result.ok) return; + const host = gitGraphEnsureDiffHost(); + host.innerHTML = ''; + if (typeof createReadOnlyViewer === 'function') createReadOnlyViewer(host, result.content || '', file.path); + else if (typeof createUnifiedMergeViewer === 'function') createUnifiedMergeViewer(host, result.content || '', result.content || '', file.path, { readOnly: true }); + }); +} + +/** Returns the *content* element for the diff/file overlay — the close + * button lives on its permanent sibling, so callers are free to reset this + * div's innerHTML on every open without wiping the close affordance out. */ +function gitGraphEnsureDiffHost() { + let host = document.getElementById('gg-diff-host'); + if (!host) { + host = document.createElement('div'); + host.id = 'gg-diff-host'; + host.className = 'gg-diff-host'; + const close = document.createElement('button'); + close.type = 'button'; + close.className = 'gg-details-close'; + close.textContent = '×'; + close.onclick = () => gitGraphCloseDiffHost(); + const content = document.createElement('div'); + content.className = 'gg-diff-host-content'; + host.appendChild(close); + host.appendChild(content); + document.body.appendChild(host); + } + return host.querySelector('.gg-diff-host-content'); +} + +function gitGraphCloseDiffHost() { + const host = document.getElementById('gg-diff-host'); + if (host) host.remove(); +} + +// --- Find widget --- + +function gitGraphFindWidgetHtml(state) { + if (!state.findOpen) return ''; + return ` +
+ + ${state.findMatches.length ? `${state.findIndex + 1}/${state.findMatches.length}` : '0/0'} + + + + + +
`; +} + +function gitGraphWireFindWidget(project, state, body) { + const input = body.querySelector('#gg-find-input'); + if (!input) return; + const run = () => { + state.findMatches = gitGraphFindMatches(state.rows, input.value, state.findCaseSensitive); + state.findIndex = state.findMatches.length ? 0 : -1; + state.findQuery = input.value; + gitGraphApplyFindHighlight(project, state, body); + }; + input.oninput = run; + input.focus(); + body.querySelector('#gg-find-case').onchange = (e) => { state.findCaseSensitive = e.target.checked; run(); }; + body.querySelector('#gg-find-open-details').onchange = (e) => { state.findAlsoOpenDetails = e.target.checked; }; + body.querySelector('#gg-find-next').onclick = () => gitGraphFindStep(project, state, body, 1); + body.querySelector('#gg-find-prev').onclick = () => gitGraphFindStep(project, state, body, -1); + body.querySelector('#gg-find-close').onclick = () => { state.findOpen = false; gitGraphPaint(project, state, body); }; + input.onkeydown = (e) => { + if (e.key === 'Enter') { e.preventDefault(); gitGraphFindStep(project, state, body, e.shiftKey ? -1 : 1); } + }; +} + +function gitGraphFindStep(project, state, body, dir) { + if (!state.findMatches.length) return; + state.findIndex = (state.findIndex + dir + state.findMatches.length) % state.findMatches.length; + gitGraphApplyFindHighlight(project, state, body); +} + +function gitGraphApplyFindHighlight(project, state, body) { + const countEl = body.querySelector('.gg-find-count'); + if (countEl) countEl.textContent = state.findMatches.length ? `${state.findIndex + 1}/${state.findMatches.length}` : '0/0'; + body.querySelectorAll('.gg-tbody [data-gg-kind]').forEach(el => el.classList.remove('gg-find-match', 'gg-find-current')); + state.findMatches.forEach((rowIndex, i) => { + const el = body.querySelector(`.gg-tbody [data-row-index="${rowIndex}"]`); + if (!el) return; + el.classList.add('gg-find-match'); + if (i === state.findIndex) { + el.classList.add('gg-find-current'); + el.scrollIntoView({ block: 'nearest' }); + if (state.findAlsoOpenDetails) { + const row = state.rows[rowIndex]; + state.selectedHash = row.hash; + gitGraphOpenDetails(project, state, body, row); + } + } + }); +} + +// --- Keyboard shortcuts --- +// +// Deliberately avoids Cmd/Ctrl+Up/Down (already the app's shortcut for +// switching sessions) and Cmd/Ctrl+R (taken by the app for reload, so the +// Refresh button carries no shortcut hint) — plain Up/Down instead moves +// between commits when the details panel is open, Shift+Up/Down crosses to +// the other side of a fork/merge. + +function gitGraphBindKeyboard(project, state, body) { + if (state.keydownHandler) document.removeEventListener('keydown', state.keydownHandler); + const handler = (e) => { + if (!gitGraphStillActive(project) || !body.isConnected) return; + if (gitGraphIsEditableTarget(e.target) && e.key !== 'Escape') return; + const mod = e.ctrlKey || e.metaKey; + if (e.key === 'Escape') { + if (document.getElementById('gg-diff-host')) { gitGraphCloseDiffHost(); e.preventDefault(); return; } + const closed = gitGraphHandleEscapePriority({ + closeMenu: () => (state.branchesMenuOpen ? (gitGraphCloseBranchesMenu(state), gitGraphPaint(project, state, body), true) : false), + closeDetails: () => { if (state.detailsData) { gitGraphCloseDetails(project, state, body); return true; } return false; }, + closeFind: () => { if (state.findOpen) { state.findOpen = false; gitGraphPaint(project, state, body); return true; } return false; }, + }); + if (closed) e.preventDefault(); + return; + } + if (mod && (e.key === 'f' || e.key === 'F')) { e.preventDefault(); state.findOpen = true; gitGraphPaint(project, state, body); } + else if (!mod && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { + const dir = e.key === 'ArrowDown' ? 1 : -1; // Down toward parents/older, Up toward children/newer + if (!state.detailsData) { + const wrap = body.querySelector('.gg-table-wrap'); + if (wrap) wrap.scrollTop += dir * GG_ROW_HEIGHT; + return; + } + e.preventDefault(); + gitGraphMoveDetailsFocus(project, state, body, dir, e.shiftKey ? 'alternate' : 'sameBranch'); + } + }; + state.keydownHandler = handler; + document.addEventListener('keydown', handler); +} + +/** + * Same-branch parent/child of `hash`, via the loaded graph's own layout + * edges rather than a fresh git query — `dir > 0` walks toward the parent + * (the row's own 'same-lane' outgoing edge), `dir < 0` toward the child (an + * earlier row whose 'same-lane' edge points back at this hash). Returns null + * at a fork/merge with no plain same-lane edge in that direction, or when + * `hash` isn't in the currently loaded window. + */ +function gitGraphSameBranchNeighbor(state, hash, dir) { + const index = state.rows.findIndex(r => r.hash === hash); + if (index < 0) return null; + if (dir > 0) { + const edge = (state.layout[index] && state.layout[index].edges || []).find(e => e.style === 'same-lane'); + return edge ? edge.parentHash : null; + } + for (let i = index - 1; i >= 0; i--) { + const hasEdge = (state.layout[i] && state.layout[i].edges || []).some(e => e.style === 'same-lane' && e.parentHash === hash); + if (hasEdge) return state.rows[i].hash; + } + return null; +} + +/** + * The *other* branch at a fork/merge touching `hash` — a 'branch-out' or + * 'merge-in' edge instead of the plain 'same-lane' one gitGraphSameBranchNeighbor + * follows. Same direction convention: `dir > 0` toward a parent, `dir < 0` + * toward a child. + */ +function gitGraphAlternateBranchNeighbor(state, hash, dir) { + const index = state.rows.findIndex(r => r.hash === hash); + if (index < 0) return null; + if (dir > 0) { + const edge = (state.layout[index] && state.layout[index].edges || []).find(e => e.style !== 'same-lane'); + return edge ? edge.parentHash : null; + } + for (let i = index - 1; i >= 0; i--) { + const hasEdge = (state.layout[i] && state.layout[i].edges || []).some(e => e.style !== 'same-lane' && e.parentHash === hash); + if (hasEdge) return state.rows[i].hash; + } + return null; +} + +/** + * Moves the Commit Details/Comparison focus in response to an arrow key. + * `sameBranch` follows gitGraphSameBranchNeighbor; `alternate` follows + * gitGraphAlternateBranchNeighbor (the fork/merge's other side). Silently + * does nothing when there's no current row or no neighbor in that direction. + */ +function gitGraphMoveDetailsFocus(project, state, body, dir, mode) { + if (!state.selectedHash) return; + const targetHash = mode === 'alternate' + ? gitGraphAlternateBranchNeighbor(state, state.selectedHash, dir) + : gitGraphSameBranchNeighbor(state, state.selectedHash, dir); + if (!targetHash) return; + const row = state.rows.find(r => r.hash === targetHash); + if (!row) return; + state.selectedHash = targetHash; + state.compareHash = null; + gitGraphOpenDetails(project, state, body, row); +} + +// --- Auto-refresh on repo change --- +// +// Bound exactly once at module load (not per render, not per project): the +// #ws-body element this tab paints into is recreated by renderOverview() on +// every tab switch, so a handler that closed over a particular `body`/ +// `project` would silently stop firing the moment the user left and came +// back to this tab. Resolving the active project/state/body fresh, at event +// time, means one listener stays correct across the whole session. + +let gitGraphWatcherBound = false; + +function gitGraphEnsureWatcherBound() { + if (gitGraphWatcherBound) return; + gitGraphWatcherBound = true; + if (typeof window === 'undefined' || typeof window.api?.onGitGraphRepoChanged !== 'function') return; + window.api.onGitGraphRepoChanged((folderPath) => { + const project = typeof selectedProject === 'function' ? selectedProject() : null; + if (!project || !gitGraphStillActive(project)) return; + const state = gitGraphTabState.get(project.id); + if (!state || state.selectedRepo !== folderPath) return; + const body = typeof document !== 'undefined' ? document.getElementById('ws-body') : null; + if (!body) return; + gitGraphLoadGraph(project, state, body, { reset: true }); + }); +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + gitGraphState, gitGraphFindMatches, gitGraphHandleEscapePriority, + gitGraphBuildFileCtx, gitGraphPopulateTargetField, gitGraphBuildMenuCtx, gitGraphOpenContextMenu, + gitGraphLoadGraph, gitGraphLoadRepositories, gitGraphPaint, gitGraphApi, renderProjectGitGraphTab, + gitGraphSingleCommitHeaderHtml, gitGraphLinkifyBody, gitGraphFileRowHtml, gitGraphFileListHtml, + gitGraphOpenDetails, gitGraphOpenComparison, gitGraphCloseDetails, + gitGraphMaybeAutoCenterDetails, gitGraphIsAncestor, gitGraphHandleRowClick, gitGraphBindKeyboard, + gitGraphSameBranchNeighbor, gitGraphAlternateBranchNeighbor, gitGraphMoveDetailsFocus, + gitGraphSameStringSet, gitGraphRepoList, gitGraphEnsureWatcherBound, + }; +} diff --git a/public/project-git-view.js b/public/project-git-view.js index a729dd927..15f1913fb 100644 --- a/public/project-git-view.js +++ b/public/project-git-view.js @@ -3,6 +3,19 @@ const projectGitTabState = new Map(); +// 'list' or 'tree'; one preference for every project, like DIFF_MODE_KEY. +const GIT_CHANGES_VIEW_KEY = 'gitChangesView'; + +function gitChangesViewMode() { + let stored = null; + try { stored = localStorage.getItem(GIT_CHANGES_VIEW_KEY); } catch {} + return stored === 'list' ? 'list' : 'tree'; +} + +function setGitChangesViewMode(mode) { + try { localStorage.setItem(GIT_CHANGES_VIEW_KEY, mode); } catch {} +} + function gitTabState(projectId) { if (!projectGitTabState.has(projectId)) { projectGitTabState.set(projectId, { @@ -10,6 +23,7 @@ function gitTabState(projectId) { selectedRepo: null, selectedFiles: new Map(), diffs: new Map(), + collapsedFolders: new Map(), // repo path -> Set of collapsed tree folder keys request: 0, error: '', }); @@ -17,6 +31,11 @@ function gitTabState(projectId) { return projectGitTabState.get(projectId); } +function gitCollapsedSet(state, repoPath) { + if (!state.collapsedFolders.has(repoPath)) state.collapsedFolders.set(repoPath, new Set()); + return state.collapsedFolders.get(repoPath); +} + function gitRepoName(repo) { return pathBasename(repo.path) || repo.path; } @@ -136,7 +155,120 @@ function paintGitDiff(project, state, repo, body, change) { : `
${escapeHtml(change.path)}
No textual diff is available.
`; } -function paintGitChanges(project, state, repo, body) { +function gitNaturalCompare(a, b) { + return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' }); +} + +// Folders before files; a folder whose only child is a folder is merged into +// one row ("src/app"), like VS Code's compact folders. +function gitSortTreeLevel(nodes) { + const sorted = nodes.map((node) => { + if (node.kind !== 'folder') return node; + let name = node.name; + let children = gitSortTreeLevel(node.children); + while (children.length === 1 && children[0].kind === 'folder') { + name = `${name}/${children[0].name}`; + children = children[0].children; + } + return { kind: 'folder', name, children }; + }); + sorted.sort((a, b) => { + if (a.kind !== b.kind) return a.kind === 'folder' ? -1 : 1; + return gitNaturalCompare(a.name, b.name); + }); + return sorted; +} + +// Git reports paths with '/' on every platform; renames sit at their new path. +function buildGitChangeTree(changes) { + const root = new Map(); + for (const change of changes) { + const parts = String(change.path || '').split('/').filter(Boolean); + let level = root; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + let node = level.get(part); + if (!node || node.kind !== 'folder') { + node = { kind: 'folder', name: part, children: new Map() }; + level.set(part, node); + } + level = node.children; + } + const fileName = parts[parts.length - 1] || change.path; + level.set(`\0${fileName}`, { kind: 'file', name: fileName, change }); + } + const toArray = (map) => [...map.values()].map(node => node.kind === 'folder' ? { ...node, children: toArray(node.children) } : node); + return gitSortTreeLevel(toArray(root)); +} + +function gitTreeFileCount(node) { + let count = 0; + for (const child of node.children) count += child.kind === 'file' ? 1 : gitTreeFileCount(child); + return count; +} + +// Visible rows in display order; collapsed folders keep their own row only. +function gitTreeRows(tree, collapsedSet, keyPrefix, depth = 0) { + const rows = []; + for (const node of tree) { + const key = keyPrefix ? `${keyPrefix}/${node.name}` : node.name; + if (node.kind === 'folder') { + const collapsed = collapsedSet.has(key); + rows.push({ kind: 'folder', depth, name: node.name, key, count: gitTreeFileCount(node), collapsed }); + if (!collapsed) rows.push(...gitTreeRows(node.children, collapsedSet, key, depth + 1)); + } else { + rows.push({ kind: 'file', depth, name: node.name, key, change: node.change }); + } + } + return rows; +} + +function createGitFileRow(project, state, repo, body, list, change, isSelected, opts) { + const tree = !!(opts && opts.tree); + const row = document.createElement('button'); + row.type = 'button'; + row.className = 'git-file-row' + (tree ? ' tree' : '') + (isSelected ? ' selected' : ''); + if (tree) row.style.setProperty('--depth', String(opts.depth || 0)); + const renamed = change.oldPath ? `${change.oldPath} → ${change.path}` : change.path; + const meta = gitChangeMeta(change); + row.title = tree ? `${renamed}\n${meta}` : renamed; + // Untracked/Conflicted already name the group; staged state needs to stay visible. + const marker = tree && change.status !== 'untracked' && change.status !== 'conflicted' + ? `${escapeHtml(meta)}` : ''; + row.innerHTML = tree + ? `${gitChangeCode(change)}${escapeHtml(opts.name || change.path)}${marker}` + : `${gitChangeCode(change)} + ${escapeHtml(change.path)}${escapeHtml(gitChangeMeta(change))}`; + row.onclick = () => { + state.selectedFiles.set(repo.path, change.path); + list.querySelectorAll('.git-file-row').forEach(el => el.classList.toggle('selected', el === row)); + paintGitDiff(project, state, repo, body, change); + }; + return row; +} + +function createGitFolderRow(project, state, repo, body, collapsedSet, row, holdsSelected) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'git-folder-row' + (holdsSelected ? ' selected' : ''); + button.dataset.key = row.key; + button.style.setProperty('--depth', String(row.depth)); + button.setAttribute('aria-expanded', row.collapsed ? 'false' : 'true'); + button.innerHTML = ` + ${row.collapsed ? PICONS.chevronRight(11) : PICONS.chevronDown(11)} + ${PICONS.folder(13)} + ${escapeHtml(row.name)} + ${row.count ? `${row.count}` : ''}`; + button.onclick = () => { + if (collapsedSet.has(row.key)) collapsedSet.delete(row.key); else collapsedSet.add(row.key); + paintGitChanges(project, state, repo, body, { repaintDiff: false }); + // The list was rebuilt, so put keyboard focus back on the new copy of this row. + [...body.querySelectorAll('.git-folder-row')].find(el => el.dataset.key === row.key)?.focus(); + }; + return button; +} + +function paintGitChanges(project, state, repo, body, { repaintDiff = true } = {}) { const list = body.querySelector('#git-file-list'); if (!list) return; const groupOrder = ['conflicted', 'modified', 'added', 'deleted', 'renamed', 'untracked']; @@ -148,6 +280,9 @@ function paintGitChanges(project, state, repo, body) { let selected = repo.changes.find(change => change.path === selectedPath) || repo.changes[0] || null; if (selected) state.selectedFiles.set(repo.path, selected.path); + const tree = gitChangesViewMode() === 'tree'; + const collapsedSet = gitCollapsedSet(state, repo.path); + list.replaceChildren(); for (const kind of groupOrder) { const changes = repo.changes.filter(change => change.status === kind); @@ -156,24 +291,33 @@ function paintGitChanges(project, state, repo, body) { label.className = 'git-file-group'; label.textContent = groupLabels[kind]; list.appendChild(label); - for (const change of changes) { - const row = document.createElement('button'); - row.type = 'button'; - row.className = 'git-file-row' + (selected?.path === change.path ? ' selected' : ''); - row.title = change.oldPath ? `${change.oldPath} → ${change.path}` : change.path; - row.innerHTML = ` - ${gitChangeCode(change)} - ${escapeHtml(change.path)}${escapeHtml(gitChangeMeta(change))}`; - row.onclick = () => { - selected = change; - state.selectedFiles.set(repo.path, change.path); - list.querySelectorAll('.git-file-row').forEach(el => el.classList.toggle('selected', el === row)); - paintGitDiff(project, state, repo, body, change); - }; - list.appendChild(row); + + if (!tree) { + for (const change of changes) { + list.appendChild(createGitFileRow(project, state, repo, body, list, change, selected?.path === change.path, { tree: false })); + } + continue; + } + for (const row of gitTreeRows(buildGitChangeTree(changes), collapsedSet, kind)) { + if (row.kind === 'folder') { + // A collapsed folder hiding the selected file stands in for it. + const folderPath = row.key.slice(kind.length + 1); + const holdsSelected = row.collapsed && selected?.status === kind && selected.path.startsWith(`${folderPath}/`); + list.appendChild(createGitFolderRow(project, state, repo, body, collapsedSet, row, holdsSelected)); + } else { + list.appendChild(createGitFileRow(project, state, repo, body, list, row.change, selected?.path === row.change.path, { tree: true, depth: row.depth, name: row.name })); + } } } - paintGitDiff(project, state, repo, body, selected); + if (repaintDiff) paintGitDiff(project, state, repo, body, selected); +} + +function gitViewToggleHtml(mode) { + const btn = (view, label, icon) => ``; + return ` + ${btn('list', 'View as list', PICONS.list(13))} + ${btn('tree', 'View as tree', PICONS.tree(13))} + `; } function gitCommitsHtml(repo) { @@ -216,7 +360,7 @@ function paintGitRepository(project, state, body) { ${picker} ${gitOverviewHtml(repo)}
-
Changes
+
Changes${changed ? gitViewToggleHtml(gitChangesViewMode()) : ''}
${changed ? '
' : '
There are no staged, unstaged, or untracked files.
'}
@@ -236,6 +380,20 @@ function paintGitRepository(project, state, body) { paintGitRepository(project, state, body); }; }); + + body.querySelectorAll('.git-view-btn').forEach(button => { + button.onclick = () => { + setGitChangesViewMode(button.dataset.view); + // Read back what was stored so the buttons match what gets rendered. + const effective = gitChangesViewMode(); + body.querySelectorAll('.git-view-btn').forEach(btn => { + const on = btn.dataset.view === effective; + btn.classList.toggle('on', on); + btn.setAttribute('aria-pressed', String(on)); + }); + paintGitChanges(project, state, repo, body, { repaintDiff: false }); + }; + }); } function renderProjectGitTab(project, body) { diff --git a/public/projects-view.js b/public/projects-view.js index 46661ba04..b487df4e0 100644 --- a/public/projects-view.js +++ b/public/projects-view.js @@ -169,7 +169,7 @@ function saveProjectsUi() { sessionStorage.setItem('projects.archivedOpen', JSON.stringify(projectsUi.archivedOpen)); } -const PROJECT_WORKSPACE_TABS = new Set(['overview', 'plan', 'files', 'git', 'settings']); +const PROJECT_WORKSPACE_TABS = new Set(['overview', 'plan', 'files', 'git', 'gitgraph', 'settings']); function projectTab(projectOrId = projectsUi.selectedProjectId) { const projectId = typeof projectOrId === 'string' ? projectOrId : projectOrId?.id; @@ -248,10 +248,12 @@ const PICONS = { // The app's own archive glyph, so this view matches the Sessions tab. archive: (s = 14) => ICONS.archive(s), list: (s = 14) => ``, + tree: (s = 14) => ``, clock: (s = 14) => ``, terminal: (s = 14) => ``, search: (s = 13) => ``, x: (s = 12) => ``, + tag: (s = 12) => ``, }; function cliIcon(session, size = 14) { @@ -1299,6 +1301,7 @@ function renderOverview() { + @@ -1309,6 +1312,7 @@ function renderOverview() { else if (tabName === 'files') renderFilesTab(project, body); else if (tabName === 'plan') renderPlanTab(project, body); else if (tabName === 'git') renderProjectGitTab(project, body); + else if (tabName === 'gitgraph') renderProjectGitGraphTab(project, body); else renderOverviewBody(project, body); projectViewer.querySelectorAll('.ws-tab:not([disabled])').forEach(tab => { diff --git a/public/style.css b/public/style.css index d595ecdb1..b4d967fce 100644 --- a/public/style.css +++ b/public/style.css @@ -5520,6 +5520,32 @@ body { display: flex; flex-direction: column; } text-transform: none; } +.git-heading-right { display: flex; align-items: center; gap: 10px; } + +/* List/tree switch for the Changes section, styled like .pane-seg. */ +.git-view-toggle { + display: inline-flex; + border: 1px solid rgba(255,255,255,0.1); + border-radius: 6px; + overflow: hidden; +} + +.git-view-btn { + width: 22px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + background: none; + color: #7a7a90; + cursor: pointer; +} + +.git-view-btn:hover { color: #b0b0c4; } +.git-view-btn.on { background: rgba(120,130,255,0.16); color: #a6abff; } + .git-changes { display: grid; grid-template-columns: minmax(230px, 34%) minmax(0, 1fr); @@ -5558,6 +5584,37 @@ body { display: flex; flex-direction: column; } .git-file-row:hover { background: rgba(255,255,255,0.035); } .git-file-row.selected { background: rgba(122,132,255,0.11); } +/* Tree mode: compact single-line file rows and folder rows, both indented by depth. */ +.git-file-row.tree { + align-items: center; + padding: 5px 10px 5px calc(10px + var(--depth, 0) * 14px); +} + +.git-file-row.tree .git-file-path { flex: 1; min-width: 0; } +.git-file-row.tree .git-file-meta { flex-shrink: 0; } + +.git-folder-row { + width: 100%; + display: flex; + align-items: center; + gap: 6px; + border: 0; + padding: 6px 10px 6px calc(10px + var(--depth, 0) * 14px); + background: transparent; + color: #9a9ab0; + font-family: inherit; + font-size: 10.5px; + text-align: left; + cursor: pointer; +} + +.git-folder-row:hover { background: rgba(255,255,255,0.035); } +.git-folder-row.selected { background: rgba(122,132,255,0.11); } +.git-folder-chevron { display: inline-flex; flex-shrink: 0; color: #6f6f84; } +.git-folder-icon { display: inline-flex; flex-shrink: 0; color: #7d8dc9; } +.git-folder-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.git-folder-count { flex-shrink: 0; color: #5c5c70; font-size: 9.5px; } + .git-file-code { width: 17px; flex: 0 0 17px; diff --git a/public/utils.js b/public/utils.js index 1a754f3d1..7c6728b7a 100644 --- a/public/utils.js +++ b/public/utils.js @@ -58,6 +58,16 @@ function escapeHtml(str) { return div.innerHTML; } +// escapeHtml() leaves quotes alone; use this for values inside quoted attributes. +function escapeAttr(str) { + return String(str == null ? '' : str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + function shellEscape(path) { return "'" + path.replace(/'/g, "'\\''") + "'"; } diff --git a/test/git-graph-layout.test.js b/test/git-graph-layout.test.js new file mode 100644 index 000000000..d663dc45f --- /dev/null +++ b/test/git-graph-layout.test.js @@ -0,0 +1,402 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { computeGitGraphLayout } = require('../public/git-graph-layout'); + +// viaLane (the lane an edge runs down) is covered by its own test below. +const withoutVia = ({ viaLane, ...edge }) => edge; + +// Fixtures below list commits newest-first, exactly as `git log` (and so +// `git-graph-service.js`) hands them to the layout function — row 0 is the +// top of the graph. + +test('linear history: one lane, one colour, straight same-lane edges down to the root', () => { + const commits = [ + { hash: 'c4', parents: ['c3'] }, + { hash: 'c3', parents: ['c2'] }, + { hash: 'c2', parents: ['c1'] }, + { hash: 'c1', parents: [] }, + ]; + const rows = computeGitGraphLayout(commits, 'date'); + assert.equal(rows.length, 4); + for (const row of rows) assert.equal(row.lane, 0); + assert.equal(rows[0].colorIndex, rows[1].colorIndex); + assert.equal(rows[1].colorIndex, rows[2].colorIndex); + assert.deepEqual(rows[0].edges.map(withoutVia), [{ parentHash: 'c3', toLane: 0, style: 'same-lane' }]); + assert.deepEqual(rows[2].edges.map(withoutVia), [{ parentHash: 'c1', toLane: 0, style: 'same-lane' }]); + assert.deepEqual(rows[3].edges.map(withoutVia), [], 'root commit has no outgoing edges'); +}); + +test('two branches that never touch get distinct, stable lanes and colours', () => { + const commits = [ + { hash: 'b2', parents: ['b1'] }, + { hash: 'a2', parents: ['a1'] }, + { hash: 'b1', parents: [] }, + { hash: 'a1', parents: [] }, + ]; + const rows = computeGitGraphLayout(commits); + const byHash = Object.fromEntries(rows.map(r => [r.hash, r])); + assert.notEqual(byHash.b2.lane, byHash.a2.lane); + assert.notEqual(byHash.b2.colorIndex, byHash.a2.colorIndex); + assert.equal(byHash.b1.lane, byHash.b2.lane); + assert.equal(byHash.a1.lane, byHash.a2.lane); + for (const row of rows) { + for (const edge of row.edges) assert.notEqual(edge.style, 'merge-in', 'independent branches never converge'); + } +}); + +test('normal 2-parent merge: mainline colour survives; feature branch reconverges on the shared grandparent (step 2c)', () => { + // main1 and feat1 are each ordinary single-parent commits that happen to + // share a grandparent ('base') via their OWN first-parent link — a real, + // common shape (two branches independently forked from `base`, merged by + // `m`) — so `base` is a genuine multi-lane convergence (step 2c), not the + // merge-commit-reuse dedup path (step 2e, covered by its own test below). + const commits = [ + { hash: 'm', parents: ['main1', 'feat1'] }, + { hash: 'main1', parents: ['base'] }, + { hash: 'feat1', parents: ['base'] }, + { hash: 'base', parents: [] }, + ]; + const rows = computeGitGraphLayout(commits, 'topo'); + const byHash = Object.fromEntries(rows.map(r => [r.hash, r])); + + assert.equal(byHash.m.lane, 0); + assert.deepEqual(byHash.m.edges.map(withoutVia), [ + { parentHash: 'main1', toLane: 0, style: 'same-lane' }, + { parentHash: 'feat1', toLane: 1, style: 'branch-out' }, + ]); + assert.equal(byHash.main1.lane, 0); + assert.equal(byHash.main1.colorIndex, byHash.m.colorIndex); + assert.equal(byHash.feat1.lane, 1); + assert.notEqual(byHash.feat1.colorIndex, byHash.main1.colorIndex); + // Both main1 and feat1 independently await `base` via their own + // first-parent edge; main1's (lower-indexed, and it's the only candidate + // either way) wins, and feat1's own edge is corrected to merge in. + assert.equal(byHash.base.lane, 0); + assert.equal(byHash.base.colorIndex, byHash.main1.colorIndex); + assert.deepEqual(byHash.feat1.edges.map(withoutVia), [{ parentHash: 'base', toLane: 0, style: 'merge-in' }]); + const maxLane = Math.max(...rows.map(r => r.lane)); + assert.equal(maxLane, 1, 'no lane beyond the two genuinely concurrent lines was ever allocated'); +}); + +test('additional-parent lane dedup (step 2e): a merge\'s second parent already awaited by another lane gets no new lane', () => { + // z opens its own line toward `y`. By the time merge commit `m` is + // processed, z's lane is already awaiting `y` — so m's second parent + // must draw straight into that existing lane, not open a third one, and + // must NOT trigger any merge-in demotion at all (there is no ambiguity + // left to resolve once the edge already agrees on the target lane). + const commits = [ + { hash: 'z', parents: ['y'] }, + { hash: 'm', parents: ['x', 'y'] }, + { hash: 'x', parents: [] }, + { hash: 'y', parents: [] }, + ]; + const rows = computeGitGraphLayout(commits); + const byHash = Object.fromEntries(rows.map(r => [r.hash, r])); + + assert.deepEqual(byHash.z.edges.map(withoutVia), [{ parentHash: 'y', toLane: 0, style: 'same-lane' }], 'never demoted'); + assert.deepEqual(byHash.m.edges.map(withoutVia), [ + { parentHash: 'x', toLane: 1, style: 'same-lane' }, + { parentHash: 'y', toLane: 0, style: 'branch-out' }, + ]); + assert.equal(byHash.y.lane, 0); + assert.equal(byHash.x.lane, 1); + const maxLane = Math.max(...rows.map(r => r.lane)); + assert.equal(maxLane, 1, 'the reused lane means only two lanes were ever needed'); +}); + +test('octopus merge: every extra parent gets its own branch-out lane and edge', () => { + const commits = [ + { hash: 'm', parents: ['a', 'b', 'c'] }, + { hash: 'a', parents: [] }, + { hash: 'b', parents: [] }, + { hash: 'c', parents: [] }, + ]; + const rows = computeGitGraphLayout(commits); + const byHash = Object.fromEntries(rows.map(r => [r.hash, r])); + assert.deepEqual(byHash.m.edges.map(e => e.style), ['same-lane', 'branch-out', 'branch-out']); + const lanes = byHash.m.edges.map(e => e.toLane); + assert.deepEqual(new Set(lanes).size, 3, 'three distinct parents get three distinct lanes'); + assert.equal(byHash.a.lane, byHash.m.edges[0].toLane); + assert.equal(byHash.b.lane, byHash.m.edges[1].toLane); + assert.equal(byHash.c.lane, byHash.m.edges[2].toLane); +}); + +test('root commit reached mid-window frees its lane for reuse; a shallow-clone boundary is the identical case', () => { + const commits = [ + { hash: 'x2', parents: ['x1'] }, + { hash: 'x1', parents: [] }, // root (or a shallow-clone boundary — same zero-parent shape) + { hash: 'y1', parents: [] }, // a later, unrelated tip + ]; + const rows = computeGitGraphLayout(commits); + const byHash = Object.fromEntries(rows.map(r => [r.hash, r])); + assert.equal(byHash.x1.edges.length, 0); + // The freed lane (0) is reused for the next new tip... + assert.equal(byHash.y1.lane, 0); + // ...but colour is not forced to follow the lane: two unrelated branches + // never share both a column and a colour back to back. + assert.notEqual(byHash.y1.colorIndex, byHash.x2.colorIndex); +}); + +test('lane-reuse keeps column count minimal across many sequential open/close cycles', () => { + const commits = []; + for (let i = 0; i < 20; i++) commits.push({ hash: `t${i}`, parents: [] }); + const rows = computeGitGraphLayout(commits); + const maxLane = Math.max(...rows.map(r => r.lane)); + assert.equal(maxLane, 0, 'sequential, non-overlapping tips must all reuse lane 0, never grow monotonically'); + // Colour still advances every allocation, cycling through the palette. + assert.deepEqual(rows.map(r => r.colorIndex), Array.from({ length: 20 }, (_, i) => i % 12)); +}); + +test('merge-convergence colour continuity: the first-parent-descending lane wins even against a lower lane index', () => { + // m merges mainline (-> a) and a branch-out (-> w); p is a second, + // independent first-parent line that also happens to land on w. w is + // awaited by lane1 (branch-out, allocated first, lower index) AND lane2 + // (first-parent, allocated second, higher index) — the algorithm must + // pick lane2 anyway, and correct m's now-stale branch-out edge in place. + const commits = [ + { hash: 'm', parents: ['a', 'w'] }, + { hash: 'p', parents: ['w'] }, + { hash: 'a', parents: [] }, + { hash: 'w', parents: ['base'] }, + { hash: 'base', parents: [] }, + ]; + const rows = computeGitGraphLayout(commits); + const byHash = Object.fromEntries(rows.map(r => [r.hash, r])); + + // p is allocated only after m's own two lanes (0 for m itself, 1 for its + // branch-out toward w) already exist, so p's lane is the higher index — + // exactly the case that would lose under a plain "lowest index" rule. + assert.equal(byHash.p.lane, 2); + + assert.equal(byHash.w.lane, 2, 'the higher-indexed first-parent lane wins the convergence, not lane 1'); + assert.equal(byHash.w.colorIndex, byHash.p.colorIndex, 'w keeps the winning lane\'s colour'); + assert.notEqual(byHash.w.colorIndex, byHash.m.colorIndex, 'w does NOT inherit m\'s own lane colour'); + + // The losing lane's edge, recorded back when `m` was processed, is + // corrected in place — not replaced, not left stale — once the + // convergence at `w` resolves later in the same pass. + assert.deepEqual(withoutVia(byHash.m.edges[1]), { parentHash: 'w', toLane: 2, style: 'merge-in' }); + + assert.equal(byHash.base.lane, 2); + assert.equal(byHash.base.colorIndex, byHash.w.colorIndex); +}); + +test('stash and Uncommitted pseudo-commits lay out with zero special-casing, including converging on a shared parent', () => { + const commits = [ + { hash: '#uncommitted', parents: ['head'], kind: 'uncommitted' }, + { hash: 'stash1', parents: ['head'], kind: 'stash' }, + { hash: 'head', parents: ['base'] }, + { hash: 'base', parents: [] }, + ]; + const rows = computeGitGraphLayout(commits); + const byHash = Object.fromEntries(rows.map(r => [r.hash, r])); + + assert.equal(byHash['#uncommitted'].lane, 0); + assert.equal(byHash.stash1.lane, 1); + // Both pseudo-commits list `head` as their own first parent, so they + // independently converge on it exactly like two ordinary sibling commits + // would; the lower-indexed lane (Uncommitted's) wins the tie between two + // equally first-parent-tagged lanes, and stash1's edge is corrected. + assert.equal(byHash.head.lane, 0); + assert.deepEqual(withoutVia(byHash.stash1.edges[0]), { parentHash: 'head', toLane: 0, style: 'merge-in' }); + assert.deepEqual(byHash.head.edges.map(withoutVia), [{ parentHash: 'base', toLane: 0, style: 'same-lane' }]); + assert.equal(byHash.base.lane, 0); +}); + +test('a single stash cleanly attaches to its base commit\'s row when nothing else contends for it', () => { + const commits = [ + { hash: 'stash1', parents: ['base'], kind: 'stash' }, + { hash: 'head', parents: ['base'] }, + { hash: 'base', parents: [] }, + ]; + const rows = computeGitGraphLayout(commits); + const byHash = Object.fromEntries(rows.map(r => [r.hash, r])); + assert.equal(byHash.stash1.lane, 0); + assert.deepEqual(byHash.stash1.edges.map(withoutVia), [{ parentHash: 'base', toLane: 0, style: 'same-lane' }]); + // head, a second independent tip, gets its own lane and later converges + // on base too, exercising the same general mechanism once more. + assert.notEqual(byHash.head.lane, byHash.stash1.lane); + assert.equal(byHash.base.lane === byHash.stash1.lane || byHash.base.lane === byHash.head.lane, true); +}); + +test('output is stable and deterministic given the same input twice', () => { + const commits = [ + { hash: 'm', parents: ['a', 'w'] }, + { hash: 'p', parents: ['w'] }, + { hash: 'a', parents: [] }, + { hash: 'w', parents: ['base'] }, + { hash: 'base', parents: [] }, + ]; + const first = computeGitGraphLayout(commits, 'date'); + const second = computeGitGraphLayout(commits, 'date'); + assert.deepStrictEqual(first, second); +}); + +test('the order argument is accepted for the frozen call shape but never changes the result', () => { + const commits = [ + { hash: 'm', parents: ['main1', 'feat1'] }, + { hash: 'main1', parents: ['base'] }, + { hash: 'feat1', parents: ['base'] }, + { hash: 'base', parents: [] }, + ]; + const byDate = computeGitGraphLayout(commits, 'date'); + const byAuthorDate = computeGitGraphLayout(commits, 'author-date'); + const byTopo = computeGitGraphLayout(commits, 'topo'); + const noOrder = computeGitGraphLayout(commits); + assert.deepStrictEqual(byDate, byAuthorDate); + assert.deepStrictEqual(byDate, byTopo); + assert.deepStrictEqual(byDate, noOrder); +}); + +test('firstParentOnly suppresses every non-first parent\'s lane and edge entirely', () => { + const commits = [ + { hash: 'm', parents: ['a', 'b', 'c'] }, + { hash: 'a', parents: [] }, + { hash: 'b', parents: [] }, + { hash: 'c', parents: [] }, + ]; + const rows = computeGitGraphLayout(commits, 'date', { firstParentOnly: true }); + const byHash = Object.fromEntries(rows.map(r => [r.hash, r])); + assert.deepEqual(byHash.m.edges.map(withoutVia), [{ parentHash: 'a', toLane: 0, style: 'same-lane' }]); + // b and c are never awaited at all now: nothing in the whole output points + // at them (contrast the plain octopus test above, where m.edges lists + // both). They still surface as rows (the caller decided what's in the + // window, not this function) but purely as independent, unconnected tips. + const referenced = new Set(); + for (const row of rows) for (const edge of row.edges) referenced.add(edge.parentHash); + assert.equal(referenced.has('b'), false); + assert.equal(referenced.has('c'), false); + assert.equal(referenced.has('a'), true); +}); + +test('incremental re-layout ("Load More") matches laying out the combined window from scratch, including a cross-page convergence fix-up', () => { + const all = [ + { hash: 'm', parents: ['a', 'w'] }, + { hash: 'p', parents: ['w'] }, + { hash: 'a', parents: [] }, + { hash: 'w', parents: ['base'] }, + { hash: 'base', parents: [] }, + ]; + const fresh = computeGitGraphLayout(all, 'date'); + + const firstPage = all.slice(0, 2); // m, p + const secondPage = all.slice(2); // a, w, base + const firstResult = computeGitGraphLayout(firstPage, 'date'); + const secondResult = computeGitGraphLayout(secondPage, 'date', { priorState: firstResult.state }); + + // `row` is local to each call (always the index within that call's own + // array) and is expected to differ between the fresh and incremental + // runs; compare everything else. + const strip = rows => rows.map(({ row, ...rest }) => rest); + assert.deepStrictEqual(strip(secondResult), strip(fresh.slice(2))); + + // The convergence at `w` started on the first page (m's branch-out edge) + // but only resolves on the second page — proving the first page's + // already-returned row was corrected in place, not left stale. + assert.deepStrictEqual(firstResult[0].edges[1], fresh[0].edges[1]); + assert.deepEqual(withoutVia(firstResult[0].edges[1]), { parentHash: 'w', toLane: 2, style: 'merge-in' }); +}); + +test('incremental re-layout also matches from-scratch on a purely linear split (no cross-page convergence)', () => { + const all = []; + for (let i = 9; i >= 0; i--) all.push({ hash: `c${i}`, parents: i > 0 ? [`c${i - 1}`] : [] }); + const fresh = computeGitGraphLayout(all); + const firstResult = computeGitGraphLayout(all.slice(0, 4), null); + const secondResult = computeGitGraphLayout(all.slice(4), null, { priorState: firstResult.state }); + const strip = rows => rows.map(({ row, ...rest }) => rest); + assert.deepStrictEqual(strip(firstResult), strip(fresh.slice(0, 4))); + assert.deepStrictEqual(strip(secondResult), strip(fresh.slice(4))); +}); + +test('empty and single-commit inputs degrade gracefully', () => { + assert.deepEqual(computeGitGraphLayout([]), []); + assert.deepEqual(computeGitGraphLayout(null), []); + const one = computeGitGraphLayout([{ hash: 'only', parents: [] }]); + assert.equal(one.length, 1); + assert.equal(one[0].lane, 0); + assert.deepEqual(one[0].edges.map(withoutVia), []); +}); + +// --- G.1 performance smoke test: a large synthetic history, generated once, +// standing in for the plan's 10k-commit/2k-ref fixture. This module has no +// concept of "refs" (that is `git-graph-service.js`'s job) so the synthetic +// history instead drives roughly 2,000 fork events over 10,000 commits, +// bounding concurrent open lines the way a real repo's branch churn does, +// to give the O(N+E) claim something non-trivial to run against. --- + +function makeRng(seed) { + let state = seed >>> 0; + return function rng() { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 0x100000000; + }; +} + +function buildSyntheticHistory(commitCount, targetForkCount) { + const rng = makeRng(0x9e3779b9); + const commits = [{ hash: 'g0', parents: [] }]; // chronological (oldest-first) while building + let tips = ['g0']; + let forkCount = 0; + const maxConcurrentTips = 64; + + while (commits.length < commitCount) { + const roll = rng(); + if (tips.length > 1 && (roll < 0.15 || tips.length >= maxConcurrentTips)) { + // Merge two open tips back together. + const i = Math.floor(rng() * tips.length); + let j = Math.floor(rng() * tips.length); + if (j === i) j = (j + 1) % tips.length; + const hash = `g${commits.length}`; + commits.push({ hash, parents: [tips[i], tips[j]] }); + tips[i] = hash; + tips.splice(j, 1); + } else if (forkCount < targetForkCount && roll < 0.35 && tips.length < maxConcurrentTips) { + // Fork a new line off an existing tip (both continue from here on). + const i = Math.floor(rng() * tips.length); + tips.push(tips[i]); + forkCount += 1; + } else { + // Plain linear continuation of a random open tip. + const i = Math.floor(rng() * tips.length); + const hash = `g${commits.length}`; + commits.push({ hash, parents: [tips[i]] }); + tips[i] = hash; + } + } + commits.reverse(); // newest-first, matching `git log`'s own order + return { commits, forkCount }; +} + +test('performance smoke test: ~10k commits / ~2k fork events lay out well within budget', () => { + const { commits, forkCount } = buildSyntheticHistory(10000, 2000); + assert.equal(commits.length, 10000); + assert.ok(forkCount >= 500, `expected substantial forking activity, got ${forkCount} forks`); + + const startedAt = Date.now(); + const rows = computeGitGraphLayout(commits, 'topo'); + const elapsedMs = Date.now() - startedAt; + + assert.equal(rows.length, 10000); + for (const row of rows) { + assert.ok(Number.isInteger(row.lane) && row.lane >= 0); + assert.ok(Number.isInteger(row.colorIndex) && row.colorIndex >= 0); + } + const maxLane = Math.max(...rows.map(r => r.lane)); + assert.ok(maxLane < 200, `lane count should stay well bounded by reuse, got ${maxLane + 1} lanes`); + assert.ok(elapsedMs < 3000, `expected a linear-time layout well under 3s, took ${elapsedMs}ms`); +}); + +test('an edge keeps the lane it runs down (viaLane) when a convergence moves its parent into another lane', () => { + // m merges feat (lane 1); feat's line later converges into base on lane 0. + const rows = computeGitGraphLayout([ + { hash: 'm', parents: ['main1', 'feat1'] }, + { hash: 'main1', parents: ['base'] }, + { hash: 'feat1', parents: ['base'] }, + { hash: 'base', parents: [] }, + ], 'date'); + const feat = rows.find(r => r.hash === 'feat1'); + assert.equal(feat.edges[0].toLane, 0); + assert.equal(feat.edges[0].viaLane, feat.lane); + assert.notEqual(feat.edges[0].viaLane, feat.edges[0].toLane); +}); diff --git a/test/git-graph-menus.test.js b/test/git-graph-menus.test.js new file mode 100644 index 000000000..a79f46022 --- /dev/null +++ b/test/git-graph-menus.test.js @@ -0,0 +1,144 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +// git-graph-menus.js is a plain browser-global script (no bundler); it reads +// `window`, `PICONS` and the shared `showContextMenu` as bare globals, so +// tests stub those on Node's `global` before requiring it, the same way +// other renderer-logic tests in this repo stub `document`/`window`. +global.PICONS = { check: () => '' }; +global.showContextMenu = (items, position) => { global.showContextMenu.calls.push({ items, position }); }; +global.showContextMenu.calls = []; +global.window = { api: {} }; + +const menus = require('../public/git-graph-menus'); + +function resetWindowApi() { + global.showContextMenu.calls = []; + global.window.api = { writeClipboard: (text) => { global.window.api.writeClipboard.calls.push(text); } }; + global.window.api.writeClipboard.calls = []; +} + +function labels(items) { return items.filter(i => !i.sep).map(i => i.label); } + +function fakeEl(attrs, parent = null) { + return { + getAttribute(name) { return Object.prototype.hasOwnProperty.call(attrs, name) ? attrs[name] : null; }, + parentElement: parent, + }; +} + +// --- Copy-only menus --- + +test('commit menu only offers Copy Hash / Copy Subject, no mutating actions', () => { + resetWindowApi(); + const items = menus.gitGraphBuildCommitMenu({ commit: { hash: 'abc123', shortHash: 'abc123', subject: 'A subject' } }); + assert.deepEqual(labels(items), ['Copy Commit Hash to Clipboard', 'Copy Commit Subject to Clipboard']); + items[0].onClick(); + items[1].onClick(); + assert.deepEqual(global.window.api.writeClipboard.calls, ['abc123', 'A subject']); +}); + +test('local/remote branch and tag menus only offer Copy Name', () => { + resetWindowApi(); + const local = menus.gitGraphBuildLocalBranchMenu({ localBranch: { name: 'main' } }); + assert.deepEqual(labels(local), ['Copy Branch Name to Clipboard']); + local[0].onClick(); + + const remote = menus.gitGraphBuildRemoteBranchMenu({ remoteBranch: { remote: 'origin', name: 'main' } }); + assert.deepEqual(labels(remote), ['Copy Branch Name to Clipboard']); + remote[0].onClick(); + + const tag = menus.gitGraphBuildTagMenu({ tag: { name: 'v1' } }); + assert.deepEqual(labels(tag), ['Copy Tag Name to Clipboard']); + tag[0].onClick(); + + assert.deepEqual(global.window.api.writeClipboard.calls, ['main', 'main', 'v1']); +}); + +test('file menu offers View Diff, View File at this Revision, and Copy Absolute/Relative Path — nothing mutating', () => { + resetWindowApi(); + const calls = []; + const ctx = { + file: { path: 'a/b.txt', relativePath: 'a/b.txt', absolutePath: '/repo/a/b.txt' }, + onViewDiff: (f) => calls.push(['viewDiff', f.path]), + onViewFileAtRevision: (f) => calls.push(['viewFileAtRevision', f.path]), + }; + const items = menus.gitGraphBuildFileMenu(ctx); + assert.deepEqual(labels(items), [ + 'View Diff', 'View File at this Revision', + 'Copy Absolute File Path to Clipboard', 'Copy Relative File Path to Clipboard', + ]); + items[0].onClick(); + items[1].onClick(); + items[3].onClick(); + items[4].onClick(); + assert.deepEqual(calls, [['viewDiff', 'a/b.txt'], ['viewFileAtRevision', 'a/b.txt']]); + assert.deepEqual(global.window.api.writeClipboard.calls, ['/repo/a/b.txt', 'a/b.txt']); +}); + +test('column header menu only toggles Date/Author/Commit visibility, no order radios', () => { + resetWindowApi(); + const calls = []; + const ctx = { columnHeader: { columnVisibility: { date: true, author: false, commit: true } }, onColumnVisibilityChange: (col, visible) => calls.push([col, visible]) }; + const items = menus.gitGraphBuildColumnHeaderMenu(ctx); + assert.deepEqual(labels(items), ['Date', 'Author', 'Commit']); + assert.equal(items[0].checked, true); + assert.equal(items[1].checked, false); + items[1].onClick(); + assert.deepEqual(calls, [['author', true]]); +}); + +// --- Combined local+remote pill dual hit-region routing --- + +test('a click on the local-name region of a combined pill opens the local-branch menu', () => { + resetWindowApi(); + const row = fakeEl({ 'data-gg-kind': 'commit', 'data-gg-hash': 'h' }); + const localRegion = fakeEl({ 'data-gg-kind': 'branch', 'data-gg-ref-name': 'main', 'data-gg-ref-type': 'local' }, row); + const ctx = { localBranch: { name: 'main', hash: 'h' } }; + const items = menus.gitGraphBuildMenuItems(localRegion, ctx); + assert.deepEqual(labels(items), ['Copy Branch Name to Clipboard']); +}); + +test('a click on the remote-qualified region of a combined pill opens the remote-branch menu, never the local one', () => { + resetWindowApi(); + const row = fakeEl({ 'data-gg-kind': 'commit', 'data-gg-hash': 'h' }); + const remoteRegion = fakeEl({ 'data-gg-kind': 'remote-branch', 'data-gg-ref-name': 'main', 'data-gg-remote': 'origin', 'data-gg-ref-type': 'remote' }, row); + const ctx = { remoteBranch: { remote: 'origin', name: 'main', hash: 'h' } }; + const items = menus.gitGraphBuildMenuItems(remoteRegion, ctx); + assert.deepEqual(labels(items), ['Copy Branch Name to Clipboard']); +}); + +test('a plain (non-combined) branch region with no ref-type still opens the local-branch menu', () => { + resetWindowApi(); + const el = fakeEl({ 'data-gg-kind': 'branch', 'data-gg-ref-name': 'main' }); + const ctx = { localBranch: { name: 'main', hash: 'h' } }; + const items = menus.gitGraphBuildMenuItems(el, ctx); + assert.deepEqual(labels(items), ['Copy Branch Name to Clipboard']); +}); + +test('gitGraphReadTargetAttrs walks up to the nearest ancestor carrying data-gg-kind', () => { + const row = fakeEl({ 'data-gg-kind': 'commit', 'data-gg-hash': 'deadbeef' }); + const inner = fakeEl({}, row); // e.g. a click landing on the text inside the row + const attrs = menus.gitGraphReadTargetAttrs(inner); + assert.equal(attrs.kind, 'commit'); + assert.equal(attrs.hash, 'deadbeef'); +}); + +test('gitGraphShowContextMenu opens the resolved item list via the shared showContextMenu', () => { + resetWindowApi(); + const el = fakeEl({ 'data-gg-kind': 'tag', 'data-gg-ref-name': 'v1' }); + const items = menus.gitGraphShowContextMenu(el, { tag: { name: 'v1' } }, { x: 1, y: 2 }); + assert.equal(global.showContextMenu.calls.length, 1); + assert.deepEqual(global.showContextMenu.calls[0].items, items); + assert.deepEqual(global.showContextMenu.calls[0].position, { x: 1, y: 2 }); +}); + +test('an unknown or unmenued data-gg-kind (e.g. stash, uncommitted) resolves to no menu and calls showContextMenu zero times', () => { + resetWindowApi(); + for (const kind of ['something-new', 'stash', 'uncommitted', 'link']) { + const el = fakeEl({ 'data-gg-kind': kind }); + const items = menus.gitGraphShowContextMenu(el, {}, { x: 0, y: 0 }); + assert.equal(items, null, `expected no menu for kind=${kind}`); + } + assert.equal(global.showContextMenu.calls.length, 0); +}); diff --git a/test/git-graph-service.test.js b/test/git-graph-service.test.js new file mode 100644 index 000000000..5a13a82d0 --- /dev/null +++ b/test/git-graph-service.test.js @@ -0,0 +1,256 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const service = require('../git-graph-service'); +const realGit = require('../git'); + +const haveGit = spawnSync('git', ['--version'], { encoding: 'utf8' }).status === 0; + +function gitIn(repo, ...args) { + const r = spawnSync('git', args, { cwd: repo, encoding: 'utf8' }); + if (r.status !== 0) throw new Error(r.stderr || `git ${args[0]} failed`); + return r.stdout.trim(); +} + +/** A throwaway repository with one commit on main, identity set locally. */ +function makeRepo(prefix = 'switchboard-gg-') { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + gitIn(repo, 'init', '-q', '-b', 'main'); + gitIn(repo, 'config', 'user.email', 'test@example.com'); + gitIn(repo, 'config', 'user.name', 'Test'); + fs.writeFileSync(path.join(repo, 'README.md'), '# hello\n'); + gitIn(repo, 'add', 'README.md'); + gitIn(repo, 'commit', '-q', '-m', 'init'); + return repo; +} + +function rm(dir) { fs.rmSync(dir, { recursive: true, force: true }); } + +function setupService(extra = {}) { + const events = []; + service.init({ + log: { info() {}, error() {} }, + send: (channel, ...args) => events.push({ channel, args }), + git: realGit, + repoWatchPollMs: 30, + repoWatchDebounceMs: 20, + ...extra, + }); + return { events }; +} + +test.afterEach(() => { + service.stopAllRepoWatches(); +}); + +// --- getProjectGitGraph: payload assembly + paging --- + +test('getProjectGitGraph merges refs by hash, marks HEAD, and reports hasMore at the boundary', { skip: !haveGit && 'git not installed' }, async () => { + setupService(); + const repo = makeRepo(); + try { + fs.writeFileSync(path.join(repo, 'a.txt'), 'a\n'); + gitIn(repo, 'add', 'a.txt'); + gitIn(repo, 'commit', '-q', '-m', 'second'); + gitIn(repo, 'tag', '-a', 'v1', '-m', 'release'); + gitIn(repo, 'branch', 'feature'); + + const full = await service.getProjectGitGraph(repo, { limit: 10 }); + assert.equal(full.ok, true); + assert.equal(full.hasMore, false); + assert.ok(full.refs); + const [head, root] = full.commits; + assert.equal(head.isHead, true); + assert.ok(head.refs.heads.includes('main')); + assert.ok(head.refs.tags.includes('v1')); + assert.ok(head.refs.heads.includes('feature'), 'feature was branched from the current head, not the root'); + assert.equal(root.refs, undefined, 'the root commit has no refs pointing at it'); + + const paged = await service.getProjectGitGraph(repo, { limit: 1 }); + assert.equal(paged.hasMore, true); + assert.equal(paged.commits.length, 1); + } finally { rm(repo); } +}); + +test('getProjectGitGraph with showTags:false strips tags from both the ref set and the commits they were attached to', { skip: !haveGit && 'git not installed' }, async () => { + setupService(); + const repo = makeRepo(); + try { + gitIn(repo, 'tag', '-a', 'v1', '-m', 'release'); + + const shown = await service.getProjectGitGraph(repo, { limit: 10 }); + assert.deepEqual(shown.refs.tags.map(t => t.name), ['v1']); + assert.ok(shown.commits[0].refs.tags.includes('v1')); + + const hidden = await service.getProjectGitGraph(repo, { limit: 10, showTags: false }); + assert.deepEqual(hidden.refs.tags, []); + assert.deepEqual(hidden.commits[0].refs.tags, []); + } finally { rm(repo); } +}); + +test('getProjectGitGraph omits refs on an unchanged-filter "Load More" call', { skip: !haveGit && 'git not installed' }, async () => { + setupService(); + const repo = makeRepo(); + try { + for (let i = 0; i < 3; i += 1) { + fs.writeFileSync(path.join(repo, `f${i}.txt`), String(i)); + gitIn(repo, 'add', `f${i}.txt`); + gitIn(repo, 'commit', '-q', '-m', `commit ${i}`); + } + const page1 = await service.getProjectGitGraph(repo, { limit: 2 }); + assert.ok(page1.refs); + assert.equal(page1.hasMore, true); + + const page2 = await service.getProjectGitGraph(repo, { limit: 2, skip: 2, refsUnchanged: true }); + assert.equal(page2.refs, null); + assert.equal(page2.commits.length, 2); + } finally { rm(repo); } +}); + +test('getProjectGitGraph reports stashes and uncommitted changes alongside the commit window, without merging them into it', { skip: !haveGit && 'git not installed' }, async () => { + setupService(); + const repo = makeRepo(); + try { + fs.writeFileSync(path.join(repo, 'README.md'), '# stashed change\n'); + gitIn(repo, 'stash', 'push', '-q', '-m', 'wip'); + fs.writeFileSync(path.join(repo, 'untracked.txt'), 'dirty\n'); + + const payload = await service.getProjectGitGraph(repo, { limit: 10 }); + assert.equal(payload.stashes.length, 1); + assert.ok(payload.uncommitted); + assert.equal(payload.uncommitted.changeCount, 1); + // The renderer does its own merge of stashes/uncommitted into the row + // list (gitGraphBuildLayoutInput); if this endpoint merged them in too, + // every stash and the working-tree row would render twice. + assert.ok(!payload.commits.some(c => c.kind === 'uncommitted')); + assert.ok(!payload.commits.some(c => c.kind === 'stash')); + } finally { rm(repo); } +}); + +test('getProjectGitGraph degrades gracefully on an empty repo with an unborn HEAD', { skip: !haveGit && 'git not installed' }, async () => { + setupService(); + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-gg-empty-')); + gitIn(repo, 'init', '-q', '-b', 'main'); + try { + const payload = await service.getProjectGitGraph(repo, { limit: 10 }); + assert.equal(payload.ok, true); + assert.deepEqual(payload.commits, []); + assert.equal(payload.uncommitted, null); + } finally { rm(repo); } +}); + +test('getProjectGitGraph reports nothing selected as no revisions rather than falling back to HEAD', { skip: !haveGit && 'git not installed' }, async () => { + setupService(); + const repo = makeRepo(); + try { + const payload = await service.getProjectGitGraph(repo, { limit: 10, branches: [], showTags: false }); + assert.equal(payload.ok, true); + assert.deepEqual(payload.commits, []); + } finally { rm(repo); } +}); + +// --- Read endpoints: commit/compare/file-at-revision --- + +test('getGitGraphCommitDetail returns parents, body, and a files list', { skip: !haveGit && 'git not installed' }, async () => { + setupService(); + const repo = makeRepo(); + try { + fs.writeFileSync(path.join(repo, 'README.md'), '# changed\n'); + gitIn(repo, 'commit', '-q', '-am', 'second\n\nBody line'); + const hash = gitIn(repo, 'rev-parse', 'HEAD'); + const { commit, files } = await service.getGitGraphCommitDetail(repo, hash); + assert.equal(commit.subject, 'second'); + assert.match(commit.body, /Body line/); + assert.equal(commit.parents.length, 1); + assert.equal(files.length, 1); + assert.equal(files[0].path, 'README.md'); + assert.equal(files[0].status, 'modified'); + } finally { rm(repo); } +}); + +test('getGitGraphCommitDetail rejects a malformed hash before running anything', { skip: !haveGit && 'git not installed' }, async () => { + setupService(); + const repo = makeRepo(); + try { + await assert.rejects(service.getGitGraphCommitDetail(repo, '-not-a-hash'), /not a valid|must not start/); + await assert.rejects(service.getGitGraphCommitDetail(repo, 'zz'), /not a valid/); + } finally { rm(repo); } +}); + +test('getGitGraphFileDiffBetween reads a blob from a revision and from the working tree', { skip: !haveGit && 'git not installed' }, async () => { + setupService(); + const repo = makeRepo(); + try { + const firstHash = gitIn(repo, 'rev-parse', 'HEAD'); + fs.writeFileSync(path.join(repo, 'README.md'), '# working tree version\n'); + const result = await service.getGitGraphFileDiffBetween(repo, firstHash, null, 'README.md'); + assert.match(result.oldContent, /# hello/); + assert.match(result.newContent, /working tree version/); + } finally { rm(repo); } +}); + +test('getGitGraphCompareDetail against the working tree includes untracked files, not just tracked changes', { skip: !haveGit && 'git not installed' }, async () => { + setupService(); + const repo = makeRepo(); + try { + const headHash = gitIn(repo, 'rev-parse', 'HEAD'); + fs.writeFileSync(path.join(repo, 'README.md'), '# changed\n'); // tracked, unstaged change + fs.writeFileSync(path.join(repo, 'new-file.txt'), 'line one\nline two\n'); // untracked + + const { files } = await service.getGitGraphCompareDetail(repo, headHash, null); + const untracked = files.find(f => f.path === 'new-file.txt'); + assert.ok(untracked, 'an untracked file must still appear in the Uncommitted Changes file list'); + assert.equal(untracked.status, 'untracked'); + assert.equal(untracked.insertions, 2); + assert.ok(files.some(f => f.path === 'README.md'), 'the tracked change is still reported alongside it'); + } finally { rm(repo); } +}); + +test('getGitGraphFileAtRevision refuses a path that escapes the repository', { skip: !haveGit && 'git not installed' }, async () => { + setupService(); + const repo = makeRepo(); + try { + const hash = gitIn(repo, 'rev-parse', 'HEAD'); + await assert.rejects(service.getGitGraphFileAtRevision(repo, hash, '../outside.txt'), /outside the repository/); + } finally { rm(repo); } +}); + +// --- Repo-change watcher --- + +test('ensureRepoWatch fires git-graph-repo-changed after HEAD moves, debounced', { skip: !haveGit && 'git not installed' }, async () => { + const { events } = setupService({ repoWatchPollMs: 20, repoWatchDebounceMs: 15 }); + const repo = makeRepo(); + try { + await service.ensureRepoWatch(repo); + await new Promise(resolve => setTimeout(resolve, 60)); // let the baseline fingerprint settle + fs.writeFileSync(path.join(repo, 'b.txt'), 'b\n'); + gitIn(repo, 'add', 'b.txt'); + gitIn(repo, 'commit', '-q', '-m', 'moves HEAD'); + await new Promise(resolve => setTimeout(resolve, 150)); + const changed = events.filter(e => e.channel === 'git-graph-repo-changed' && e.args[0] === repo); + assert.ok(changed.length >= 1, 'expected at least one repo-changed event'); + } finally { + service.stopRepoWatch(repo); + rm(repo); + } +}); + +test('stopRepoWatch/stopAllRepoWatches actually stop polling (no more events after stop)', { skip: !haveGit && 'git not installed' }, async () => { + const { events } = setupService({ repoWatchPollMs: 15, repoWatchDebounceMs: 10 }); + const repo = makeRepo(); + try { + await service.ensureRepoWatch(repo); + await new Promise(resolve => setTimeout(resolve, 40)); + service.stopRepoWatch(repo); + const before = events.length; + fs.writeFileSync(path.join(repo, 'c.txt'), 'c\n'); + gitIn(repo, 'add', 'c.txt'); + gitIn(repo, 'commit', '-q', '-m', 'after stop'); + await new Promise(resolve => setTimeout(resolve, 80)); + assert.equal(events.length, before, 'no new events after the watch was stopped'); + } finally { rm(repo); } +}); diff --git a/test/git.test.js b/test/git.test.js index 5b1013042..3bf80e1b1 100644 --- a/test/git.test.js +++ b/test/git.test.js @@ -140,3 +140,195 @@ test('snapshot preserves rename paths from porcelain output', { skip: !haveGit & fs.rmSync(repo, { recursive: true, force: true }); } }); + +// --- Git Graph read functions -------------------------------------------- + +test('logWithParents reports parent hashes and degrades to [] on an unborn HEAD', { skip: !haveGit && 'git not installed' }, async () => { + const repo = await makeRepo(); + try { + fs.writeFileSync(path.join(repo, 'b.txt'), 'b\n'); + await git.run(['add', 'b.txt'], repo); + await git.run(['commit', '-q', '-m', 'second'], repo); + + const commits = await git.logWithParents(repo); + assert.equal(commits.length, 2); + assert.equal(commits[0].subject, 'second'); + assert.equal(commits[0].parents.length, 1); + assert.equal(commits[1].subject, 'init'); + assert.deepEqual(commits[1].parents, []); + assert.equal(commits[0].parents[0], commits[1].hash); + assert.match(commits[0].hash, /^[0-9a-f]{40}$/); + assert.ok(commits[0].authorEmail.includes('@')); + + const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-git-empty-')); + await git.run(['init', '-q', '-b', 'main'], empty); + try { + assert.deepEqual(await git.logWithParents(empty), []); + } finally { + fs.rmSync(empty, { recursive: true, force: true }); + } + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } +}); + +test('logWithParents reports a merge commit\'s two parents and honours skip/limit', { skip: !haveGit && 'git not installed' }, async () => { + const repo = await makeRepo(); + try { + await git.run(['checkout', '-q', '-b', 'topic'], repo); + fs.writeFileSync(path.join(repo, 'topic.txt'), 'x\n'); + await git.run(['add', 'topic.txt'], repo); + await git.run(['commit', '-q', '-m', 'topic work'], repo); + await git.run(['checkout', '-q', 'main'], repo); + fs.writeFileSync(path.join(repo, 'main.txt'), 'y\n'); + await git.run(['add', 'main.txt'], repo); + await git.run(['commit', '-q', '-m', 'main work'], repo); + await git.run(['merge', '--no-ff', '-q', '-m', 'merge topic', 'topic'], repo); + + const all = await git.logWithParents(repo, { revspec: ['--all'] }); + const merge = all.find(c => c.subject === 'merge topic'); + assert.equal(merge.parents.length, 2); + + const paged = await git.logWithParents(repo, { revspec: 'main', skip: 1, limit: 1 }); + assert.equal(paged.length, 1); + assert.equal(paged[0].subject, 'main work'); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } +}); + +// Two branch tips off the same parent (so neither is the other's ancestor — +// topology alone doesn't force an order) with author date and commit date +// deliberately pulling in opposite directions, so --date-order and +// --author-date-order are pinned against real out-of-order timestamps, not +// just naturally-ordered fixture commits. +test('logWithParents orders by author date vs. commit date against real out-of-order timestamps', { skip: !haveGit && 'git not installed' }, async () => { + const repo = await makeRepo(); + const commitWithDates = (branch, message, authorDate, committerDate) => new Promise((resolve, reject) => { + require('child_process').execFile('git', ['commit', '--allow-empty', '-q', '-m', message], { + cwd: repo, + env: { ...process.env, GIT_AUTHOR_DATE: authorDate, GIT_COMMITTER_DATE: committerDate }, + }, (err) => (err ? reject(err) : resolve())); + }); + try { + await git.run(['checkout', '-q', '-b', 'branch-a'], repo); + await commitWithDates('branch-a', 'authored early, committed late', '2021-06-01T00:00:00', '2023-01-01T00:00:00'); + await git.run(['checkout', '-q', 'main'], repo); + await git.run(['checkout', '-q', '-b', 'branch-b'], repo); + await commitWithDates('branch-b', 'authored late, committed early', '2022-06-01T00:00:00', '2020-01-01T00:00:00'); + + const byCommitDate = await git.logWithParents(repo, { revspec: ['branch-a', 'branch-b'], order: 'date' }); + assert.equal(byCommitDate[0].subject, 'authored early, committed late', 'newest commit date wins under --date-order'); + + const byAuthorDate = await git.logWithParents(repo, { revspec: ['branch-a', 'branch-b'], order: 'author-date' }); + assert.equal(byAuthorDate[0].subject, 'authored late, committed early', 'newest author date wins under --author-date-order'); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } +}); + +test('logWithParents rejects a revspec flag not on the allow-list', { skip: !haveGit && 'git not installed' }, async () => { + const repo = await makeRepo(); + try { + await assert.rejects(git.logWithParents(repo, { revspec: ['--upload-pack=/bin/sh'] }), /allowed flag list/); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } +}); + +test('forEachRef decodes heads/remotes/tags, marks HEAD, and degrades to empty on a repo with no refs', { skip: !haveGit && 'git not installed' }, async () => { + const repo = await makeRepo(); + try { + await git.run(['branch', 'feature'], repo); + await git.run(['tag', '-a', 'v1', '-m', 'release'], repo); + await git.run(['tag', 'v1-lightweight'], repo); + + const bare = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-git-bare-')); + await git.run(['init', '-q', '--bare', bare], repo); + await git.run(['remote', 'add', 'origin', bare], repo); + await git.run(['push', '-q', 'origin', 'main', 'feature'], repo); + + const refs = await git.forEachRef(repo); + const main = refs.heads.find(h => h.name === 'main'); + const feature = refs.heads.find(h => h.name === 'feature'); + assert.equal(main.isHead, true); + assert.equal(feature.isHead, false); + + assert.ok(refs.remotes.find(r => r.remote === 'origin' && r.name === 'main')); + assert.ok(!refs.remotes.find(r => r.name === 'HEAD'), 'the remote\'s own default-branch pointer is filtered out'); + + const annotated = refs.tags.find(t => t.name === 'v1'); + const lightweight = refs.tags.find(t => t.name === 'v1-lightweight'); + assert.equal(annotated.annotated, true); + assert.equal(lightweight.annotated, false); + assert.equal(lightweight.hash, main.hash); + + fs.rmSync(bare, { recursive: true, force: true }); + + const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-git-empty-')); + await git.run(['init', '-q', '-b', 'main'], empty); + try { + assert.deepEqual(await git.forEachRef(empty), { heads: [], remotes: [], tags: [] }); + } finally { + fs.rmSync(empty, { recursive: true, force: true }); + } + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } +}); + +test('stashList resolves each stash\'s base commit and, when present, its source branch', { skip: !haveGit && 'git not installed' }, async () => { + const repo = await makeRepo(); + try { + const head = await git.run(['rev-parse', 'HEAD'], repo); + fs.writeFileSync(path.join(repo, 'README.md'), '# stashed change\n'); + await git.run(['stash', 'push', '-q', '-m', 'my work'], repo); + + const stashes = await git.stashList(repo); + assert.equal(stashes.length, 1); + assert.equal(stashes[0].index, 0); + assert.equal(stashes[0].branch, 'main'); + assert.equal(stashes[0].baseCommitHash, head); + assert.match(stashes[0].message, /my work/); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } +}); + +test('blobAtRevision reads a file\'s content at a given revision and stays contained to the repo', { skip: !haveGit && 'git not installed' }, async () => { + const repo = await makeRepo(); + try { + const first = await git.run(['rev-parse', 'HEAD'], repo); + fs.writeFileSync(path.join(repo, 'README.md'), '# changed\n'); + await git.run(['commit', '-aq', '-m', 'change'], repo); + + assert.equal(await git.blobAtRevision(repo, first, 'README.md'), '# hello\n'); + assert.equal(await git.blobAtRevision(repo, 'HEAD', 'README.md'), '# changed\n'); + await assert.rejects(git.blobAtRevision(repo, 'HEAD', '../outside.txt'), /outside the repository/); + await assert.rejects(git.blobAtRevision(repo, '-x', 'README.md'), /Invalid revision/); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } +}); + +test('blobAtRevision normalizes a backslash-style relative path to git\'s forward-slash pathspec form', { skip: !haveGit && 'git not installed' }, async () => { + const repo = await makeRepo(); + try { + fs.mkdirSync(path.join(repo, 'sub')); + fs.writeFileSync(path.join(repo, 'sub', 'nested.txt'), 'nested\n'); + await git.run(['add', 'sub/nested.txt'], repo); + await git.run(['commit', '-q', '-m', 'add nested'], repo); + + // Simulates the shape path.relative() produces on Windows — git itself + // always wants '/'-separated pathspecs regardless of host OS. + assert.equal(await git.blobAtRevision(repo, 'HEAD', 'sub\\nested.txt'), 'nested\n'); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } +}); + +test('toGitPath converts backslashes to forward slashes regardless of host path.sep', () => { + assert.equal(git.toGitPath('sub\\nested.txt'), 'sub/nested.txt'); + assert.equal(git.toGitPath('a/b\\c/d'), 'a/b/c/d'); + assert.equal(git.toGitPath('plain.txt'), 'plain.txt'); +}); diff --git a/test/project-git-graph-view.test.js b/test/project-git-graph-view.test.js new file mode 100644 index 000000000..bf250447f --- /dev/null +++ b/test/project-git-graph-view.test.js @@ -0,0 +1,786 @@ +// Renderer-logic tests for the Git Graph tab (public/project-git-graph-view.js +// + public/git-graph-render.js), in the house `node --test` style used by +// test/git.test.js / test/projects.test.js: the modules are dedicated, +// side-effect-free-at-require-time files, so they are `require()`d directly +// (no jsdom/Electron) with the handful of browser globals they read +// (`escapeHtml`, `escapeAttr`, `PICONS`, `window.api`, `showContextMenu`, …) +// provided as plain stubs. +// +// Deliberately does NOT load git-graph-layout.js / git-graph-menus.js (files +// owned elsewhere): every call into them is behind a `typeof x === 'function'` +// guard, and this suite exists partly to prove that guard holds — the tab's +// own logic must not depend on those files being present. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const vm = require('node:vm'); +const fs = require('node:fs'); + +// escapeHtml/escapeAttr run as the *actual* public/utils.js functions, not a +// reimplementation — a security test here must fail the moment the real +// escaping logic regresses, the same way a change to it would break the +// running app. escapeHtml's only environment dependency is +// `document.createElement('div')` (set .textContent, read .innerHTML back); +// this stand-in implements just that one DOM text-serialization rule +// (browsers escape &/ in a text node, never quotes — quotes don't need it +// outside an attribute) so utils.js's own escapeHtml runs completely unmodified. +function fakeEscapingDiv() { + let text = ''; + return { + set textContent(value) { text = String(value); }, + get textContent() { return text; }, + get innerHTML() { return text.replace(/&/g, '&').replace(//g, '>'); }, + }; +} +const utilsContext = vm.createContext({ document: { createElement: () => fakeEscapingDiv() }, SessionConfig: {} }); +vm.runInContext(fs.readFileSync(path.join(__dirname, '../public/utils.js'), 'utf8'), utilsContext); +global.escapeHtml = utilsContext.escapeHtml; +global.escapeAttr = utilsContext.escapeAttr; +// buildGitChangeTree/gitTreeRows come from #98's public/project-git-view.js; +// a trimmed stand-in is enough here since that file's own tests cover the +// tree builder itself, and this suite only needs a tree shape to flow through. +global.buildGitChangeTree = (changes) => { + const root = new Map(); + for (const change of changes) { + const parts = String(change.path || '').split('/').filter(Boolean); + let level = root; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + let node = level.get(part); + if (!node || node.kind !== 'folder') { node = { kind: 'folder', name: part, children: new Map() }; level.set(part, node); } + level = node.children; + } + const fileName = parts[parts.length - 1] || change.path; + level.set(`\0${fileName}`, { kind: 'file', name: fileName, change }); + } + const toArray = (map) => [...map.values()].map(node => node.kind === 'folder' ? { ...node, children: toArray(node.children) } : node); + return toArray(root); +}; +global.gitTreeRows = (tree, collapsedSet, keyPrefix, depth = 0) => { + const rows = []; + for (const node of tree) { + const key = keyPrefix ? `${keyPrefix}/${node.name}` : node.name; + if (node.kind === 'folder') { + const collapsed = collapsedSet.has(key); + let count = 0; + const countFiles = (n) => { for (const c of n) count += c.kind === 'file' ? 1 : countFiles(c.children); }; + countFiles(node.children); + rows.push({ kind: 'folder', depth, name: node.name, key, count, collapsed }); + if (!collapsed) rows.push(...global.gitTreeRows(node.children, collapsedSet, key, depth + 1)); + } else { + rows.push({ kind: 'file', depth, name: node.name, key, change: node.change }); + } + } + return rows; +}; + +const render = require(path.join(__dirname, '../public/git-graph-render.js')); +Object.assign(global, render); + +const view = require(path.join(__dirname, '../public/project-git-graph-view.js')); +Object.assign(global, view); + +// --- Small fake-DOM element, enough for the paint/wire functions under test +// to run to completion without throwing. --- + +function fakeElement() { + const el = { + style: {}, dataset: {}, className: '', innerHTML: '', textContent: '', + isConnected: true, + appendChild(child) { return child; }, + prepend() {}, + remove() {}, + focus() {}, select() {}, scrollIntoView() {}, + closest() { return null; }, + getAttribute() { return null; }, + classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } }, + querySelector() { return fakeElement(); }, + querySelectorAll: () => [], + }; + return el; +} + +function fakeTargetElement(attrs, href) { + const dataset = { ...attrs }; + const el = { + dataset, + getAttribute(name) { return name === 'href' ? (href || null) : null; }, + href, + closest(selector) { + return selector === '[data-gg-kind]' && dataset.ggKind ? el : null; + }, + }; + return el; +} + +// === Pure helpers: git-graph-render.js === + +test('gitGraphFormatDate covers all five date-format variants', () => { + const iso = '2019-03-24T21:34:00Z'; + assert.match(render.gitGraphFormatDate(iso, 'date-time'), /24 Mar 2019 \d{2}:\d{2}/); + assert.equal(render.gitGraphFormatDate(iso, 'date-only'), '24 Mar 2019'); + assert.match(render.gitGraphFormatDate(iso, 'iso'), /^2019-03-24 \d{2}:\d{2}$/); + assert.equal(render.gitGraphFormatDate(iso, 'iso-date-only'), '2019-03-24'); + const relative = render.gitGraphFormatDate(new Date(Date.now() - 5 * 60000).toISOString(), 'relative'); + assert.match(relative, /minute/); +}); + +test('gitGraphBuildLayoutInput always puts Uncommitted at the top and attaches stashes as single-parent pseudo-commits', () => { + const commits = [ + { hash: 'c2', parents: ['c1'], authorDate: '2024-01-02T00:00:00Z', isHead: true, refs: { heads: [], remotes: [], tags: [] } }, + { hash: 'c1', parents: [], authorDate: '2024-01-01T00:00:00Z', refs: { heads: [], remotes: [], tags: [] } }, + ]; + const stashes = [{ hash: 's1', index: 0, baseCommitHash: 'c1', message: 'WIP', date: '2024-01-01T12:00:00Z' }]; + const merged = render.gitGraphBuildLayoutInput(commits, stashes, { changeCount: 3 }, 'c2'); + assert.equal(merged[0].kind, 'uncommitted'); + assert.equal(merged[0].hash, '#uncommitted'); + assert.equal(merged[0].parents[0], 'c2'); + assert.ok(merged.some(r => r.kind === 'stash' && r.hash === 's1' && r.parents[0] === 'c1')); + assert.ok(merged.some(r => r.hash === 'c2' && r.kind === 'commit')); +}); + +test('gitGraphBuildLayoutInput keeps git order, so a rebased parent with a newer author date never lands above its child', () => { + const commits = [ + { hash: 'child', parents: ['parent'], authorDate: '2024-01-01T00:00:00Z', commitDate: '2024-01-05T00:00:00Z', refs: { heads: [], remotes: [], tags: [] } }, + { hash: 'parent', parents: [], authorDate: '2024-01-03T00:00:00Z', commitDate: '2024-01-04T00:00:00Z', refs: { heads: [], remotes: [], tags: [] } }, + ]; + const stashes = [{ hash: 's1', index: 0, baseCommitHash: 'parent', message: 'WIP', date: '2024-01-06T00:00:00Z' }]; + const merged = render.gitGraphBuildLayoutInput(commits, stashes, null, 'child'); + const at = hash => merged.findIndex(r => r.hash === hash); + assert.ok(at('child') < at('parent')); + assert.ok(at('s1') < at('parent'), 'a stash stays above the commit it was taken from'); +}); + +test('gitGraphBuildLayoutInput holds back a stash until the commit it was taken from is loaded', () => { + const commits = [{ hash: 'c1', parents: ['c0'], commitDate: '2024-01-02T00:00:00Z', refs: { heads: [], remotes: [], tags: [] } }]; + const stashes = [{ hash: 's-old', index: 0, baseCommitHash: 'not-loaded', message: 'WIP', date: '2024-01-03T00:00:00Z' }]; + const merged = render.gitGraphBuildLayoutInput(commits, stashes, null, 'c1'); + assert.equal(merged.some(r => r.hash === 's-old'), false); +}); + +test('gitGraphFallbackLayout: a minimal one-lane-no-edges stand-in, used only when the real layout function is not loaded', () => { + const commits = [ + { hash: 'c', parents: ['b'] }, { hash: 'b', parents: ['a'] }, { hash: 'a', parents: [] }, + ]; + const layout = render.gitGraphFallbackLayout(commits); + assert.deepEqual(layout.map(r => r.hash), ['c', 'b', 'a']); + assert.deepEqual(layout.map(r => r.lane), [0, 0, 0]); + assert.deepEqual(layout.map(r => r.edges), [[], [], []]); +}); + +test('gitGraphResolveLayoutFn picks the real computeGitGraphLayout when it is loaded, the fallback otherwise', () => { + assert.equal(render.gitGraphResolveLayoutFn(), render.gitGraphFallbackLayout); + global.computeGitGraphLayout = () => []; + try { + assert.equal(render.gitGraphResolveLayoutFn(), global.computeGitGraphLayout); + } finally { + delete global.computeGitGraphLayout; + } +}); + +test('gitGraphRenderRefPills: combined local+remote pill carries the two independently-hit-testable regions', () => { + const commit = { + hash: 'abc123', isHead: true, + refs: { heads: ['main'], remotes: [{ remote: 'origin', name: 'main' }], tags: [] }, + }; + const html = render.gitGraphRenderRefPills(commit, { headBranchName: 'main' }); + assert.match(html, /data-gg-kind="branch"[^>]*data-gg-ref-type="local"/); + assert.match(html, /data-gg-kind="remote-branch"[^>]*data-gg-ref-type="remote"/); + assert.match(html, /gg-pill-head/); // HEAD emphasis on the matching local pill +}); + +test('gitGraphRenderRefPills: no HEAD emphasis when the branch name is not the checked-out one', () => { + const commit = { hash: 'x', isHead: false, refs: { heads: ['feature'], remotes: [], tags: [] } }; + const html = render.gitGraphRenderRefPills(commit, { headBranchName: 'main' }); + assert.equal(html.includes('gg-pill-head'), false); +}); + +test('gitGraphRenderRefPills: local branch with no same-named remote gets its own single pill, not a combined pair', () => { + const commit = { hash: 'x', refs: { heads: ['solo'], remotes: [{ remote: 'origin', name: 'other' }], tags: [] } }; + const html = render.gitGraphRenderRefPills(commit, {}); + assert.equal((html.match(/data-gg-kind="branch"/g) || []).length, 1); + assert.equal((html.match(/data-gg-kind="remote-branch"/g) || []).length, 1); + assert.equal(html.includes('gg-pill-combined'), false); +}); + +test('gitGraphRenderRefPills: a ref name with a quote or angle bracket never breaks out of its attribute', () => { + const commit = { hash: 'x', refs: { heads: ['a"onmouseover=alert(1)//, &), +// through all three ref kinds pills render (local branch, remote branch, tag) — +// not just the local-branch case above. +const GG_HOSTILE_REF = `it"'s &"onmouseover=alert(1)`; +const GG_HOSTILE_REF_ESCAPED_ATTR = 'it"'s <b>&"onmouseover=alert(1)</b>'; + +test('gitGraphRenderRefPills: a remote branch name with \', ", <, >, & never breaks out of its attributes', () => { + const commit = { hash: 'x', refs: { heads: [], remotes: [{ remote: 'origin', name: GG_HOSTILE_REF }], tags: [] } }; + const html = render.gitGraphRenderRefPills(commit, {}); + assert.doesNotMatch(html, /data-gg-ref-name="[^"]*" onmouseover=/); + assert.doesNotMatch(html, //); + assert.match(html, new RegExp(`data-gg-ref-name="${GG_HOSTILE_REF_ESCAPED_ATTR.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`)); +}); + +test('gitGraphRenderRefPills: a tag name with \', ", <, >, & never breaks out of its attributes', () => { + const commit = { hash: 'x', refs: { heads: [], remotes: [], tags: [GG_HOSTILE_REF] } }; + const html = render.gitGraphRenderRefPills(commit, {}); + assert.doesNotMatch(html, /data-gg-ref-name="[^"]*" onmouseover=/); + assert.doesNotMatch(html, //); + assert.match(html, new RegExp(`data-gg-kind="tag"[^>]*data-gg-ref-name="${GG_HOSTILE_REF_ESCAPED_ATTR.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`)); +}); + +test('a stash message with \', ", <, >, & is escaped as inert text, both in the row list and once its base-commit HTML entities pass back through it', () => { + const project = { id: 'proj-stash-xss' }; + const state = view.gitGraphState(project.id); + state.repositories = [{ path: '/repo', git: true }]; + state.selectedRepo = '/repo'; + state.rawCommits = []; + state.stashes = []; + state.uncommitted = null; + state.rows = [{ + hash: 's0', shortHash: 's0', kind: 'stash', stashIndex: 0, parents: [], + authorName: '', authorDate: '2024-01-01T00:00:00Z', commitDate: '2024-01-01T00:00:00Z', + subject: GG_HOSTILE_REF, refs: { heads: [], remotes: [], tags: [] }, + }]; + state.layout = [{ lane: 0, colorIndex: 0, edges: [] }]; + const body = fakeElement(); + view.gitGraphPaint(project, state, body); + // escapeHtml (text content) leaves quotes alone — only & < > need escaping + // outside an attribute — so the quotes may reappear literally; what must + // never reappear is a live '' tag. + assert.doesNotMatch(body.innerHTML, //); + assert.match(body.innerHTML, /gg-subject">it"'s <b>&"onmouseover=alert\(1\)<\/b> { + const merge = render.gitGraphRowClasses({ kind: 'commit', parents: ['a', 'b'] }, { muteMergeCommits: true }); + assert.ok(merge.includes('gg-muted')); + const stash = render.gitGraphRowClasses({ kind: 'stash', parents: ['a', 'b'] }, { muteMergeCommits: true }); + assert.equal(stash.includes('gg-muted'), false); + const off = render.gitGraphRowClasses({ kind: 'commit', parents: ['a', 'b'] }, { muteMergeCommits: false }); + assert.equal(off.includes('gg-muted'), false); +}); + +test('gitGraphDiffStatHtml / gitGraphStatusBadge', () => { + assert.match(render.gitGraphDiffStatHtml({ insertions: 3, deletions: 1 }), /\+3/); + assert.match(render.gitGraphDiffStatHtml({ insertions: 3, deletions: 1 }), /-1/); + assert.equal(render.gitGraphDiffStatHtml({ insertions: 0, deletions: 0 }), ''); + assert.equal(render.gitGraphStatusBadge('renamed'), 'R'); + assert.equal(render.gitGraphStatusBadge('untracked'), 'U'); +}); + +// === Pure helpers: project-git-graph-view.js === + +test('gitGraphFindMatches: matches message/date/author/hash/branch/tag substrings', () => { + const rows = [ + { hash: 'aa11', subject: 'Fix login bug', authorName: 'Ada', authorDate: '2024-05-01T00:00:00Z', refs: { heads: [], remotes: [], tags: [] } }, + { hash: 'bb22', subject: 'Unrelated', authorName: 'Grace', authorDate: '2024-05-02T00:00:00Z', refs: { heads: ['release-login'], remotes: [], tags: [] } }, + { hash: 'cc33', subject: 'Nothing matches here', authorName: 'Bob', authorDate: '2024-05-03T00:00:00Z', refs: { heads: [], remotes: [], tags: ['login-tag'] } }, + ]; + assert.deepEqual(view.gitGraphFindMatches(rows, 'login', false), [0, 1, 2]); + assert.deepEqual(view.gitGraphFindMatches(rows, 'ada', false), [0]); + assert.deepEqual(view.gitGraphFindMatches(rows, 'Ada', true), [0]); + assert.deepEqual(view.gitGraphFindMatches(rows, 'ada', true), []); + assert.deepEqual(view.gitGraphFindMatches(rows, '', false), []); +}); + +test('gitGraphHandleEscapePriority closes the menu, then details, then Find, in that order', () => { + const order = []; + view.gitGraphHandleEscapePriority({ + closeMenu: () => { order.push('menu'); return true; }, + closeDetails: () => { order.push('details'); return true; }, + closeFind: () => { order.push('find'); return true; }, + }); + assert.deepEqual(order, ['menu']); + + order.length = 0; + const result = view.gitGraphHandleEscapePriority({ + closeMenu: () => false, + closeDetails: () => { order.push('details'); return true; }, + closeFind: () => { order.push('find'); return true; }, + }); + assert.deepEqual(order, ['details']); + assert.equal(result, 'details'); + + order.length = 0; + const findResult = view.gitGraphHandleEscapePriority({ + closeMenu: () => false, + closeDetails: () => false, + closeFind: () => { order.push('find'); return true; }, + }); + assert.deepEqual(order, ['find']); + assert.equal(findResult, 'find'); +}); + +test('gitGraphSameStringSet compares two path lists regardless of order', () => { + assert.equal(view.gitGraphSameStringSet(['a', 'b'], ['b', 'a']), true); + assert.equal(view.gitGraphSameStringSet(['a'], ['a', 'b']), false); +}); + +test('renderProjectGitGraphTab notices a folder attached elsewhere and refreshes the repo list without losing the loaded graph', async () => { + const project = { id: 'proj-attach-1', root: '/repo-a', folders: [] }; + const body = fakeElement(); + const calls = []; + global.document = { addEventListener() {}, removeEventListener() {}, getElementById: () => null }; + global.selectedProject = () => project; + global.projectTab = () => 'gitgraph'; + global.pathBasename = (p) => String(p || '').split('/').filter(Boolean).pop() || ''; + global.window = { + api: { + getProjectGitInfo: (id) => { + calls.push(id); + const repos = [{ path: '/repo-a', git: true }, ...project.folders.map(f => ({ path: f.path, git: true }))]; + return Promise.resolve({ ok: true, repositories: repos }); + }, + }, + }; + + // First render populates the cache with just the root repo. + view.renderProjectGitGraphTab(project, body); + await new Promise(resolve => setImmediate(resolve)); + const state = view.gitGraphState(project.id); + assert.deepEqual(state.repositories.map(r => r.path), ['/repo-a']); + assert.equal(calls.length, 1); + + // A folder gets attached elsewhere (e.g. from the Settings tab) while this + // tab isn't the one being painted — project.folders changes, nothing else + // tells this tab about it. + project.folders = [{ path: '/repo-b' }]; + state.rows = [{ hash: 'kept' }]; // stands in for an already-loaded graph that must survive the refresh + view.renderProjectGitGraphTab(project, body); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(state.repositories.map(r => r.path).sort(), ['/repo-a', '/repo-b']); + assert.equal(calls.length, 2); + assert.deepEqual(state.rows, [{ hash: 'kept' }], 'the already-loaded graph for the still-selected repo is not thrown away'); +}); + +// === Commit Details arrow-key navigation === + +test('gitGraphSameBranchNeighbor follows the row\'s own same-lane edge for a parent, and the earlier row pointing back at it for a child', () => { + const state = { + rows: [{ hash: 'c3' }, { hash: 'c2' }, { hash: 'c1' }], + layout: [ + { edges: [{ parentHash: 'c2', style: 'same-lane' }] }, + { edges: [{ parentHash: 'c1', style: 'same-lane' }] }, + { edges: [] }, + ], + }; + assert.equal(view.gitGraphSameBranchNeighbor(state, 'c3', 1), 'c2', 'Down from c3 reaches its parent c2'); + assert.equal(view.gitGraphSameBranchNeighbor(state, 'c1', -1), 'c2', 'Up from c1 reaches its child c2'); + assert.equal(view.gitGraphSameBranchNeighbor(state, 'c1', 1), null, 'the root commit has no parent to follow'); +}); + +test('gitGraphAlternateBranchNeighbor follows a branch-out/merge-in edge instead of the same-lane one', () => { + const state = { + rows: [{ hash: 'merge' }, { hash: 'main1' }, { hash: 'feat1' }, { hash: 'base' }], + layout: [ + { edges: [{ parentHash: 'main1', style: 'same-lane' }, { parentHash: 'feat1', style: 'branch-out' }] }, + { edges: [{ parentHash: 'base', style: 'same-lane' }] }, + { edges: [{ parentHash: 'base', style: 'merge-in' }] }, + { edges: [] }, + ], + }; + assert.equal(view.gitGraphAlternateBranchNeighbor(state, 'merge', 1), 'feat1', 'the alternate parent at the merge is the other side, not main1'); + assert.equal(view.gitGraphAlternateBranchNeighbor(state, 'base', -1), 'feat1', 'the alternate child of base is the merge-in side, not main1'); +}); + +test('gitGraphMoveDetailsFocus (mode "sameBranch") steps along the loaded window\'s own same-lane edges', () => { + const state = view.gitGraphState('proj-nav-1'); + state.repositories = [{ path: '/repo', git: true }]; + state.selectedRepo = '/repo'; + state.rows = [{ hash: 'a', parents: ['b'] }, { hash: 'b', parents: ['c'] }, { hash: 'c', parents: [] }]; + state.layout = [ + { edges: [{ parentHash: 'b', style: 'same-lane' }] }, + { edges: [{ parentHash: 'c', style: 'same-lane' }] }, + { edges: [] }, + ]; + state.selectedHash = 'b'; + const project = { id: 'proj-nav-1' }; + const body = fakeElement(); + global.window = { api: { getGitGraphCommitDetail: () => Promise.resolve({ ok: true, commit: {}, files: [] }) } }; + + view.gitGraphMoveDetailsFocus(project, state, body, 1, 'sameBranch'); + assert.equal(state.selectedHash, 'c'); + view.gitGraphMoveDetailsFocus(project, state, body, -1, 'sameBranch'); + assert.equal(state.selectedHash, 'b'); + view.gitGraphMoveDetailsFocus(project, state, body, 1, 'sameBranch'); + view.gitGraphMoveDetailsFocus(project, state, body, 1, 'sameBranch'); + assert.equal(state.selectedHash, 'c', 'stepping past the root is a no-op'); +}); + +test('Escape closes the Find widget when it is the only thing open', () => { + const state = view.gitGraphState('proj-find-esc-1'); + state.repositories = [{ path: '/repo', git: true }]; + state.selectedRepo = '/repo'; + state.rows = []; + state.findOpen = true; + const project = { id: 'proj-find-esc-1' }; + const body = fakeElement(); + global.window = { api: {} }; + global.document = { addEventListener: (type, fn) => { if (type === 'keydown') global.document._handler = fn; }, removeEventListener() {}, getElementById: () => null }; + global.selectedProject = () => project; + global.projectTab = () => 'gitgraph'; + + view.gitGraphBindKeyboard(project, state, body); + global.document._handler({ key: 'Escape', target: {}, preventDefault() {} }); + assert.equal(state.findOpen, false); +}); + +test('the keydown handler never fires once the tab is no longer the active one', () => { + const state = view.gitGraphState('proj-inactive-1'); + state.findOpen = true; + const project = { id: 'proj-inactive-1' }; + const body = fakeElement(); + global.document = { addEventListener: (type, fn) => { if (type === 'keydown') global.document._handler = fn; }, removeEventListener() {}, getElementById: () => null }; + global.selectedProject = () => project; + global.projectTab = () => 'files'; // the project view moved to a different tab; #ws-body was never removed from the DOM + view.gitGraphBindKeyboard(project, state, body); + global.document._handler({ key: 'Escape', target: {}, preventDefault() {} }); + assert.equal(state.findOpen, true, 'a handler bound while this tab was active must still no-op once the tab changes'); +}); + +test('Ctrl/Cmd+ArrowDown is left alone (never preventDefault-ed) so the app\'s own session-switch shortcut still fires', () => { + const state = view.gitGraphState('proj-modarrow-1'); + state.rows = [{ hash: 'a' }, { hash: 'b' }]; + state.selectedHash = 'a'; + state.detailsData = { mode: 'single', hash: 'a' }; + const project = { id: 'proj-modarrow-1' }; + const body = fakeElement(); + global.document = { addEventListener: (type, fn) => { if (type === 'keydown') global.document._handler = fn; }, removeEventListener() {}, getElementById: () => null }; + global.selectedProject = () => project; + global.projectTab = () => 'gitgraph'; + global.window = { api: {} }; + view.gitGraphBindKeyboard(project, state, body); + let prevented = false; + global.document._handler({ key: 'ArrowDown', metaKey: true, target: {}, preventDefault() { prevented = true; } }); + assert.equal(prevented, false); + assert.equal(state.selectedHash, 'a', 'a Cmd-held arrow must not move the details focus either'); +}); + +test('gitGraphReadTargetAttrs (git-graph-menus.js) is used by gitGraphPopulateTargetField for every kept target kind', () => { + const state = { + rows: [{ hash: 'c1', shortHash: 'c1', subject: 'Do a thing' }], + detailsData: { mode: 'single', hash: 'c1', kind: 'commit', files: [{ path: 'a/b.txt', status: 'modified' }] }, + repositories: [{ path: '/repo' }], + selectedRepo: '/repo', + }; + + const commitCtx = {}; view.gitGraphPopulateTargetField(commitCtx, state, { kind: 'commit', hash: 'c1' }); + assert.equal(commitCtx.commit.subject, 'Do a thing'); + + const localCtx = {}; view.gitGraphPopulateTargetField(localCtx, state, { kind: 'branch', refType: 'local', refName: 'main', hash: 'h' }); + assert.deepEqual(localCtx.localBranch, { name: 'main', hash: 'h' }); + assert.equal(localCtx.remoteBranch, undefined); + + // The combined pill's *remote* hit-region (data-gg-kind="branch" data-gg-ref-type="remote") must + // populate remoteBranch, not localBranch — this is the dual-hit-region contract. + const combinedRemoteCtx = {}; view.gitGraphPopulateTargetField(combinedRemoteCtx, state, { kind: 'branch', refType: 'remote', refName: 'main', remote: 'origin', hash: 'h' }); + assert.deepEqual(combinedRemoteCtx.remoteBranch, { remote: 'origin', name: 'main', hash: 'h' }); + assert.equal(combinedRemoteCtx.localBranch, undefined); + + const tagCtx = {}; view.gitGraphPopulateTargetField(tagCtx, state, { kind: 'tag', refName: 'v1', hash: 'c1' }); + assert.equal(tagCtx.tag.name, 'v1'); + + const fileCtx = {}; view.gitGraphPopulateTargetField(fileCtx, state, { kind: 'file', filePath: 'a/b.txt' }); + assert.equal(fileCtx.file.relativePath, 'a/b.txt'); + assert.equal(fileCtx.file.absolutePath, '/repo/a/b.txt'); + + const colCtx = {}; view.gitGraphPopulateTargetField(colCtx, state, { kind: 'column-header' }); + assert.ok(colCtx.columnHeader); +}); + +test('gitGraphOpenContextMenu wires ctx.onColumnVisibilityChange/onViewDiff/onViewFileAtRevision through to git-graph-menus.js', () => { + const state = view.gitGraphState('proj-menu-ctx-1'); + state.repositories = [{ path: '/repo', git: true }]; + state.selectedRepo = '/repo'; + state.rows = [{ hash: 'c1', shortHash: 'c1', subject: 's' }]; + state.columns = { date: true, author: false, commit: true }; + const project = { id: 'proj-menu-ctx-1' }; + const body = fakeElement(); + + let shownItems = null; + global.showContextMenu = (items) => { shownItems = items; }; + global.gitGraphReadTargetAttrs = (el) => ({ kind: el.dataset.ggKind, hash: el.dataset.ggHash, refName: null, remote: null, refType: null, filePath: null }); + global.gitGraphShowContextMenu = (el, ctx) => { + const items = ctx.columnHeader ? [{ id: 'date', checked: !!ctx.columnHeader.columnVisibility.date, onClick: () => ctx.onColumnVisibilityChange('date', !ctx.columnHeader.columnVisibility.date) }] : []; + shownItems = items; + return items; + }; + try { + const header = fakeTargetElement({ ggKind: 'column-header' }); + view.gitGraphOpenContextMenu(project, state, body, header, { x: 0, y: 0 }); + assert.ok(shownItems); + assert.equal(shownItems[0].checked, true); + shownItems[0].onClick(); + assert.equal(state.columns.date, false, 'the real onColumnVisibilityChange callback ran and flipped state'); + } finally { + delete global.gitGraphReadTargetAttrs; + delete global.gitGraphShowContextMenu; + delete global.showContextMenu; + } +}); + +// === A light integration-shaped test: staleness guard on the main graph load === + +test('gitGraphLoadGraph discards an older reply once a newer load has started, regardless of resolution order', async () => { + const state = view.gitGraphState('proj-stale-1'); + state.repositories = [{ path: '/repo', git: true }]; + state.selectedRepo = '/repo'; + const project = { id: 'proj-stale-1' }; + const body = fakeElement(); + + const resolvers = []; + global.window = { + api: { + getProjectGitGraph: () => new Promise((resolve) => resolvers.push(resolve)), + }, + }; + + const p1 = view.gitGraphLoadGraph(project, state, body, { reset: true }); + const p2 = view.gitGraphLoadGraph(project, state, body, { reset: true }); + + assert.equal(resolvers.length, 2); + // Resolve the *second* (current) request first, then the stale first one late. + resolvers[1]({ ok: true, commits: [{ hash: 'winner', parents: [], authorDate: '2024-02-01T00:00:00Z', refs: { heads: [], remotes: [], tags: [] } }], refs: { heads: [], remotes: [], tags: [] }, hasMore: false }); + await p2; + resolvers[0]({ ok: true, commits: [{ hash: 'stale', parents: [], authorDate: '2024-01-01T00:00:00Z', refs: { heads: [], remotes: [], tags: [] } }], refs: { heads: [], remotes: [], tags: [] }, hasMore: false }); + await p1; + + assert.equal(state.rawCommits.length, 1); + assert.equal(state.rawCommits[0].hash, 'winner'); +}); + +test('gitGraphLoadGraph in Load-More mode (reset:false) appends to the existing window and advances skip', async () => { + const state = view.gitGraphState('proj-loadmore-1'); + state.repositories = [{ path: '/repo', git: true }]; + state.selectedRepo = '/repo'; + state.rawCommits = [{ hash: 'existing', parents: [], authorDate: '2024-01-01T00:00:00Z', refs: { heads: [], remotes: [], tags: [] } }]; + state.skip = 1; + const project = { id: 'proj-loadmore-1' }; + const body = fakeElement(); + + global.window = { + api: { + getProjectGitGraph: (_id, _folder, opts) => { + assert.equal(opts.skip, 1); + assert.equal(opts.refsUnchanged, true); + return Promise.resolve({ ok: true, commits: [{ hash: 'more', parents: [], authorDate: '2023-12-31T00:00:00Z', refs: { heads: [], remotes: [], tags: [] } }], refs: null, hasMore: false }); + }, + }, + }; + + await view.gitGraphLoadGraph(project, state, body, { reset: false }); + assert.deepEqual(state.rawCommits.map(c => c.hash), ['existing', 'more']); + assert.equal(state.skip, 2); +}); + +test('gitGraphLoadGraph surfaces a {error} reply as state.error rather than throwing', async () => { + const state = view.gitGraphState('proj-error-1'); + state.repositories = [{ path: '/repo', git: true }]; + state.selectedRepo = '/repo'; + const project = { id: 'proj-error-1' }; + const body = fakeElement(); + global.window = { api: { getProjectGitGraph: () => Promise.resolve({ error: 'git not found' }) } }; + await view.gitGraphLoadGraph(project, state, body, { reset: true }); + assert.equal(state.error, 'git not found'); + assert.equal(state.loading, false); +}); + +// === Load More must never jump the table back to the top === + +/** A body stub whose `.gg-table-wrap` survives an innerHTML replace with a + * fresh (scrollTop: 0) node each time — the same thing a real DOM does — + * so gitGraphPaint's own preserveScroll copy-forward is what's under test, + * not an artifact of the stub remembering the old node. */ +function fakeScrollBody() { + let wrap = { scrollTop: 0, querySelectorAll: () => [], addEventListener() {} }; + const body = { + isConnected: true, className: '', dataset: {}, + get innerHTML() { return this._html || ''; }, + set innerHTML(v) { this._html = v; wrap = { scrollTop: 0, querySelectorAll: () => [], addEventListener() {} }; }, + querySelector(sel) { + if (sel === '.gg-table-wrap') return wrap; + return fakeElement(); + }, + querySelectorAll: () => [], + }; + return body; +} + +test('Load More preserves the table\'s scroll position; a fresh (reset) load does not', async () => { + const state = view.gitGraphState('proj-scroll-1'); + state.repositories = [{ path: '/repo', git: true }]; + state.selectedRepo = '/repo'; + state.rawCommits = [{ hash: 'existing', parents: [], authorDate: '2024-01-01T00:00:00Z', refs: { heads: [], remotes: [], tags: [] } }]; + state.skip = 1; + const project = { id: 'proj-scroll-1' }; + const body = fakeScrollBody(); + body.querySelector('.gg-table-wrap').scrollTop = 400; + + global.window = { api: { getProjectGitGraph: () => Promise.resolve({ ok: true, commits: [{ hash: 'more', parents: [], authorDate: '2023-12-31T00:00:00Z', refs: { heads: [], remotes: [], tags: [] } }], refs: null, hasMore: false }) } }; + + await view.gitGraphLoadGraph(project, state, body, { reset: false }); + assert.equal(body.querySelector('.gg-table-wrap').scrollTop, 400, 'Load More must not reset scroll to the top'); + + body.querySelector('.gg-table-wrap').scrollTop = 777; + global.window = { api: { getProjectGitGraph: () => Promise.resolve({ ok: true, commits: [], refs: { heads: [], remotes: [], tags: [] }, hasMore: false }) } }; + await view.gitGraphLoadGraph(project, state, body, { reset: true }); + assert.equal(body.querySelector('.gg-table-wrap').scrollTop, 0, 'a fresh load lands on a new row set at the top'); +}); + +// === Avatars, signature status, markdown, issue linking, code review and the +// docked-details option are out of scope for this read-only PR — nothing +// here references them; see git-graph-menus.js for the read-only menus. === + +// === Commit Comparison is order-independent (ancestor always "from") === + +test('gitGraphIsAncestor walks parent links through the loaded window', () => { + const rows = [ + { hash: 'c3', parents: ['c2'] }, + { hash: 'c2', parents: ['c1'] }, + { hash: 'c1', parents: [] }, + ]; + assert.equal(view.gitGraphIsAncestor(rows, 'c1', 'c3'), true); + assert.equal(view.gitGraphIsAncestor(rows, 'c3', 'c1'), false); + assert.equal(view.gitGraphIsAncestor(rows, 'c1', 'c1'), false, 'a commit is not its own ancestor'); +}); + +test('Ctrl-clicking an older ancestor after a newer commit still compares from the ancestor, regardless of click order', () => { + const state = view.gitGraphState('proj-cmp-1'); + state.repositories = [{ path: '/repo', git: true }]; + state.selectedRepo = '/repo'; + state.rows = [ + { hash: 'newer', parents: ['older'] }, + { hash: 'older', parents: [] }, + ]; + state.selectedHash = 'newer'; // clicked first + const project = { id: 'proj-cmp-1' }; + const body = fakeElement(); + const calls = []; + global.window = { api: { getGitGraphCompareDetail: (...args) => { calls.push(args); return Promise.resolve({ ok: true, files: [] }); } } }; + + const el = { dataset: { ggHash: 'older' } }; // ctrl-clicked second + view.gitGraphHandleRowClick(project, state, body, el, { ctrlKey: true }); + + assert.equal(state.detailsData.fromHash, 'older'); + assert.equal(state.detailsData.toHash, 'newer'); + assert.deepEqual(calls[0].slice(2), ['older', 'newer']); +}); + +// === Commit-details file list reuses #98's tree builder === + +test('gitGraphFileListHtml (tree mode) builds folder rows via buildGitChangeTree/gitTreeRows and leaf rows carry the security-fixed escapeAttr path attribute', () => { + const files = [ + { path: 'src/app/a.js', status: 'modified', insertions: 1, deletions: 0 }, + { path: 'src/app/b"onload=alert(1)//.js', status: 'added', insertions: 2, deletions: 0 }, + ]; + const html = view.gitGraphFileListHtml(files, { fileViewType: 'tree', collapsedFolders: new Set() }); + assert.match(html, /gg-tree-folder/); + // The hostile text may still appear as inert, already-escaped text content + // (safe); what must never happen is the quote closing an attribute early. + assert.doesNotMatch(html, /file-path="[^"]*" onload=/); + assert.match(html, /data-gg-file-path="src\/app\/b"onload=alert\(1\)\/\/\.js"/); +}); + +test('gitGraphFileListHtml (list mode) shows the full relative path, not just the basename', () => { + const files = [{ path: 'src/app/a.js', status: 'modified' }]; + const html = view.gitGraphFileListHtml(files, { fileViewType: 'list', collapsedFolders: new Set() }); + assert.match(html, />src\/app\/a\.js { + const html = view.gitGraphLinkifyBody('see https://example.com for details'); + assert.match(html, /]*href="https:\/\/example\.com"[^>]*>https:\/\/example\.com<\/a>/); +}); + +test('gitGraphLinkifyBody never lets a quote character in the commit body break out of the href attribute', () => { + const hostile = 'see https://x" onmouseover="fetch(\'https://evil/\')'; + const html = view.gitGraphLinkifyBody(hostile); + const firstTag = /]*>/.exec(html); + assert.ok(firstTag, 'expected the first https:// match to still be linkified'); + assert.doesNotMatch(firstTag[0], /onmouseover/, 'the quote must end the href, not open a live attribute inside the tag'); + assert.match(firstTag[0], /^$/); +}); + +test('gitGraphSingleCommitHeaderHtml escapes a hostile subject/author rather than trusting repo content as markup', () => { + const commit = { + hash: 'abc', parents: [], subject: '', authorName: 'Eve', authorEmail: 'eve@example.com', + authorDate: '2024-01-01T00:00:00Z', commitDate: '2024-01-01T00:00:00Z', committerName: 'Eve', body: '', + }; + const html = view.gitGraphSingleCommitHeaderHtml(commit, 'commit'); + assert.doesNotMatch(html, /Eve/); +}); + +// === Auto-refresh watcher: bound once, resolves the current body fresh === +// +// gitGraphEnsureWatcherBound is a module-load singleton (see its own comment +// in project-git-graph-view.js) — it only ever runs its registration body +// once per process. To exercise that "once" behavior in isolation, this test +// loads a fresh copy of the module (dropping it from require's cache first) +// rather than relying on whichever earlier test in this file happened to +// trigger the real registration first. + +test('gitGraphEnsureWatcherBound binds the repo-change listener once and repaints into the CURRENT #ws-body, not whichever body was live when it first bound', async () => { + const modulePath = path.join(__dirname, '../public/project-git-graph-view.js'); + delete require.cache[modulePath]; + + let bindCount = 0; + let watcherCallback = null; + const bodyFromFirstRender = fakeElement(); + const bodyAfterTabSwitch = fakeElement(); + let currentBody = bodyFromFirstRender; + + global.document = { + addEventListener() {}, removeEventListener() {}, + getElementById: (id) => (id === 'ws-body' ? currentBody : null), + }; + global.window = { + api: { + onGitGraphRepoChanged: (cb) => { watcherCallback = cb; bindCount++; }, + getProjectGitGraph: () => Promise.resolve({ ok: true, commits: [], refs: { heads: [], remotes: [], tags: [] }, hasMore: false }), + }, + }; + const project = { id: 'proj-watcher-rebind' }; + global.selectedProject = () => project; + global.projectTab = () => 'gitgraph'; + + const freshView = require(modulePath); + try { + const state = freshView.gitGraphState(project.id); + state.repositories = [{ path: '/repo', git: true }]; + state.selectedRepo = '/repo'; + + // Simulating the tab being opened, then left and reopened (renderProjectGitGraphTab + // calls this on every render): only the FIRST call may actually register. + freshView.gitGraphEnsureWatcherBound(); + freshView.gitGraphEnsureWatcherBound(); + freshView.gitGraphEnsureWatcherBound(); + assert.equal(bindCount, 1, 'a second/third render must not add a second listener'); + assert.equal(typeof watcherCallback, 'function'); + + // The tab's own #ws-body element gets recreated on a tab switch (it is + // never removed, per gitGraphStillActive's own reasoning) — simulate + // that by pointing document.getElementById('ws-body') at a new element, + // the way the real DOM would after the user left and came back. + bodyFromFirstRender.innerHTML = 'untouched-by-a-later-event'; + currentBody = bodyAfterTabSwitch; + + watcherCallback('/repo'); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(bodyFromFirstRender.innerHTML, 'untouched-by-a-later-event', 'the stale first-render body must never be repainted into'); + assert.match(bodyAfterTabSwitch.innerHTML, /gg-tab-shell/, 'the watcher must resolve and repaint into the CURRENT #ws-body'); + } finally { + delete require.cache[modulePath]; + } +}); diff --git a/test/project-git-view.test.js b/test/project-git-view.test.js new file mode 100644 index 000000000..eb343200f --- /dev/null +++ b/test/project-git-view.test.js @@ -0,0 +1,286 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const source = fs.readFileSync(path.join(__dirname, '../public/project-git-view.js'), 'utf8'); + +/** public/project-git-view.js only defines functions and a Map at top level, so it loads whole in a vm context given the same globals the renderer provides. */ +function loadContext(localStorageImpl) { + const context = vm.createContext({ + document: { createElement: () => ({ + dataset: {}, + focus() { context.focused = this; }, + style: { setProperty() {} }, + classList: { add() {}, remove() {}, toggle() {} }, + setAttribute() {}, + appendChild() {}, + querySelectorAll: () => [], + }) }, + window: { api: {} }, + escapeHtml: (str) => String(str), + pathBasename: (p) => String(p).split(/[\\/]/).filter(Boolean).pop() || '', + formatDate: () => 'just now', + PICONS: { + branch: () => '', + list: () => '', + tree: () => '', + folder: () => '', + chevronDown: () => '', + chevronRight: () => '', + }, + localStorage: localStorageImpl || { getItem() { return null; }, setItem() {} }, + }); + vm.runInContext(source, context); + return context; +} + +function change(p, extra) { + return Object.assign({ path: p, status: 'modified' }, extra); +} + +// Values built inside the vm context are objects of that realm (its own +// Array/Object), so deepStrictEqual against a plain literal here would fail +// on prototype identity alone. A JSON round-trip normalizes to this realm. +function plain(value) { + return JSON.parse(JSON.stringify(value)); +} + +test('buildGitChangeTree nests by folder, sorting folders before files, natural + case-insensitive', () => { + const ctx = loadContext(); + const changes = [ + change('src/b.js'), + change('src/A.js'), + change('readme.md'), + change('src/file10.js'), + change('src/file2.js'), + change('assets/logo.png'), + ]; + const tree = ctx.buildGitChangeTree(changes); + + assert.deepEqual(plain(tree.map(n => [n.kind, n.name])), [ + ['folder', 'assets'], + ['folder', 'src'], + ['file', 'readme.md'], + ]); + + const src = tree.find(n => n.name === 'src'); + assert.deepEqual(plain(src.children.map(n => n.name)), ['A.js', 'b.js', 'file2.js', 'file10.js']); +}); + +test('root-level files with no folder appear as plain file nodes', () => { + const ctx = loadContext(); + const tree = ctx.buildGitChangeTree([change('package.json'), change('README.md')]); + assert.deepEqual(plain(tree.map(n => n.kind)), ['file', 'file']); + // Case-insensitive natural order: "package.json" vs "README.md" -> p < r. + assert.deepEqual(plain(tree.map(n => n.name)), ['package.json', 'README.md']); +}); + +test('compacts a folder chain of single children into one row, but not a folder with files or several subfolders', () => { + const ctx = loadContext(); + const changes = [ + change('backend/docs/ai-engine/notes.md'), + change('frontend/src/app.js'), + change('frontend/test/app.test.js'), + change('shared/util.js'), + change('shared/lib/helpers.js'), + ]; + const tree = ctx.buildGitChangeTree(changes); + + const backend = tree.find(n => n.name.startsWith('backend')); + assert.equal(backend.name, 'backend/docs/ai-engine'); + assert.equal(backend.children.length, 1); + assert.equal(backend.children[0].kind, 'file'); + + // frontend has two subfolders (src, test) -> not compacted. + const frontend = tree.find(n => n.name === 'frontend'); + assert.equal(frontend.name, 'frontend'); + assert.deepEqual(plain(frontend.children.map(c => c.name)), ['src', 'test']); + + // shared has a file (util.js) alongside a subfolder (lib/) -> not compacted. + const shared = tree.find(n => n.name === 'shared'); + assert.equal(shared.name, 'shared'); + assert.deepEqual(plain(shared.children.map(c => [c.kind, c.name])), [['folder', 'lib'], ['file', 'util.js']]); +}); + +test('renamed files are placed at their new path (change.path), not the old one', () => { + const ctx = loadContext(); + const tree = ctx.buildGitChangeTree([ + change('new/location/file.js', { status: 'renamed', oldPath: 'old/spot/file.js' }), + ]); + assert.equal(tree.length, 1); + assert.equal(tree[0].name, 'new/location'); + assert.equal(tree[0].children[0].change.path, 'new/location/file.js'); + assert.equal(tree[0].children[0].change.oldPath, 'old/spot/file.js'); +}); + +test('gitTreeRows flattens with depth and group-seeded keys', () => { + const ctx = loadContext(); + const tree = ctx.buildGitChangeTree([ + change('a/b/one.js'), + change('a/b/two.js'), + change('a/c.js'), + ]); + const rows = ctx.gitTreeRows(tree, new Set(), 'modified'); + assert.deepEqual(plain(rows.map(r => [r.kind, r.name, r.depth])), [ + ['folder', 'a', 0], + ['folder', 'b', 1], + ['file', 'one.js', 2], + ['file', 'two.js', 2], + ['file', 'c.js', 1], + ]); + const aRow = rows.find(r => r.name === 'a'); + const bRow = rows.find(r => r.name === 'b'); + assert.equal(aRow.key, 'modified/a'); + assert.equal(bRow.key, 'modified/a/b'); + // a's count must recurse through subfolder b (2 files) plus its own c.js. + assert.equal(aRow.count, 3); + assert.equal(bRow.count, 2); +}); + +test('a collapsed folder hides its descendants but keeps its own row', () => { + const ctx = loadContext(); + const tree = ctx.buildGitChangeTree([ + change('a/b/one.js'), + change('a/b/two.js'), + change('a/c.js'), + ]); + const allRows = ctx.gitTreeRows(tree, new Set(), 'modified'); + const bKey = allRows.find(r => r.name === 'b').key; + + const collapsed = new Set([bKey]); + const rows = ctx.gitTreeRows(tree, collapsed, 'modified'); + assert.deepEqual(plain(rows.map(r => r.name)), ['a', 'b', 'c.js']); + const bRow = rows.find(r => r.name === 'b'); + assert.equal(bRow.collapsed, true); + assert.equal(bRow.count, 2); +}); + +test('gitChangesViewMode reads the stored mode and falls back to "tree" for missing, invalid, or throwing storage', () => { + assert.equal(loadContext({ getItem() { return null; }, setItem() {} }).gitChangesViewMode(), 'tree'); + assert.equal(loadContext({ getItem() { return 'nonsense'; }, setItem() {} }).gitChangesViewMode(), 'tree'); + assert.equal(loadContext({ getItem() { throw new Error('blocked'); }, setItem() {} }).gitChangesViewMode(), 'tree'); + assert.equal(loadContext({ getItem() { return 'list'; }, setItem() {} }).gitChangesViewMode(), 'list'); +}); + +test('setGitChangesViewMode swallows a throwing localStorage', () => { + const ctx = loadContext({ getItem() { return null; }, setItem() { throw new Error('blocked'); } }); + assert.doesNotThrow(() => ctx.setGitChangesViewMode('tree')); +}); + +// --- paintGitChanges: exercises the actual list/tree render dispatch and +// the folder-row click wiring, not just the pure tree helpers above. --- + +/** Minimal '#git-file-list' stand-in: just enough for paintGitChanges/createGitFileRow to append rows to and query them back. */ +function fakeList() { + const children = []; + return { + children, + replaceChildren() { children.length = 0; }, + appendChild(el) { children.push(el); }, + querySelectorAll(selector) { + if (selector !== '.git-file-row') return []; + return children.filter(el => typeof el.className === 'string' && el.className.split(' ').includes('git-file-row')); + }, + }; +} + +/** Minimal 'body' stand-in dispatching the two selectors paintGitChanges/paintGitDiff query for. */ +function fakeBody(list, diffPane) { + return { + isConnected: true, + querySelector(selector) { + if (selector === '#git-file-list') return list; + if (selector === '#git-diff-pane') return diffPane || null; + return null; + }, + querySelectorAll(selector) { + return selector === '.git-folder-row' ? list.children.filter(el => String(el.className).split(' ').includes('git-folder-row')) : []; + }, + }; +} + +test('paintGitChanges renders a flat list in "list" mode and a folder tree in "tree" mode', () => { + const changes = [change('a/b/one.js'), change('a/b/two.js'), change('a/c.js'), change('readme.md')]; + const repo = { path: '/repo', changes }; + + const listCtx = loadContext({ getItem() { return 'list'; }, setItem() {} }); + const list1 = fakeList(); + listCtx.paintGitChanges({ id: 'p' }, listCtx.gitTabState('p'), repo, fakeBody(list1)); + assert.equal(list1.children.some(c => c.className === 'git-folder-row'), false); + assert.equal(list1.children.filter(c => typeof c.className === 'string' && c.className.includes('git-file-row')).length, 4); + + const treeCtx = loadContext({ getItem() { return 'tree'; }, setItem() {} }); + const list2 = fakeList(); + treeCtx.paintGitChanges({ id: 'p' }, treeCtx.gitTabState('p'), repo, fakeBody(list2)); + assert.equal(list2.children.some(c => c.className === 'git-folder-row'), true); +}); + +test('paintGitChanges only repaints the diff pane when repaintDiff is not false', () => { + const ctx = loadContext(); + const repo = { path: '/repo', changes: [change('a.js')] }; + const state = ctx.gitTabState('p'); + state.selectedFiles.set(repo.path, 'a.js'); + state.diffs.set('/repo\u0000a.js', { diff: '+hello' }); + const diffPane = { dataset: {}, innerHTML: 'UNTOUCHED' }; + const body = fakeBody(fakeList(), diffPane); + + ctx.paintGitChanges({ id: 'p' }, state, repo, body, { repaintDiff: false }); + assert.equal(diffPane.innerHTML, 'UNTOUCHED'); + + ctx.paintGitChanges({ id: 'p' }, state, repo, body); + assert.notEqual(diffPane.innerHTML, 'UNTOUCHED'); +}); + +test("a folder row's onclick toggles its collapsed state and repaints only the list, leaving the diff pane untouched", () => { + const ctx = loadContext({ getItem() { return 'tree'; }, setItem() {} }); + const repo = { path: '/repo', changes: [change('a/b/one.js'), change('a/b/two.js'), change('a/c.js')] }; + const state = ctx.gitTabState('p'); + state.selectedFiles.set(repo.path, 'a/c.js'); + state.diffs.set('/repo\u0000a/c.js', { diff: '+x' }); + const list = fakeList(); + const diffPane = { dataset: {}, innerHTML: '' }; + const body = fakeBody(list, diffPane); + + ctx.paintGitChanges({ id: 'p' }, state, repo, body); + const paintedDiff = diffPane.innerHTML; + assert.notEqual(paintedDiff, ''); + + // Folder 'a' (the outermost, first-rendered folder row) collapsed. + const folderRowA = list.children.find(c => c.className === 'git-folder-row'); + assert.ok(folderRowA); + folderRowA.onclick(); + + assert.equal(diffPane.innerHTML, paintedDiff); + // The selected file (a/c.js) is now hidden, so its folder is highlighted instead. + assert.deepEqual(list.children.map(c => c.className), ['git-file-group', 'git-folder-row selected']); + // The rebuilt row for the same folder gets keyboard focus back. + assert.notEqual(ctx.focused, folderRowA); + assert.equal(ctx.focused, list.children[1]); + assert.equal(ctx.focused.dataset.key, folderRowA.dataset.key); +}); + +test('tree rows show the tree node name (backslashes included) and keep the staged state visible', () => { + const ctx = loadContext({ getItem() { return 'tree'; }, setItem() {} }); + const repo = { path: '/repo', changes: [change('docs/a\\b.md', { indexStatus: 'M', worktreeStatus: ' ' }), change('docs/other.md', { indexStatus: ' ', worktreeStatus: 'M' })] }; + const list = fakeList(); + ctx.paintGitChanges({ id: 'p' }, ctx.gitTabState('p'), repo, fakeBody(list)); + const rows = list.children.filter(c => typeof c.className === 'string' && c.className.includes('git-file-row')); + const backslash = rows.find(r => r.innerHTML.includes('a\\b.md')); + assert.ok(backslash, 'the full node name is shown, not what follows the backslash'); + assert.match(backslash.innerHTML, /git-file-meta">Staged /git-file-meta">Unstaged { + const ctx = loadContext({ getItem() { return 'tree'; }, setItem() {} }); + const repo = { path: '/repo', changes: [change('src/a.js'), change('src/b.js'), change('top.js')] }; + const state = ctx.gitTabState('p'); + state.selectedFiles.set(repo.path, 'src/b.js'); + ctx.gitCollapsedSet(state, repo.path).add('modified/src'); + const list = fakeList(); + ctx.paintGitChanges({ id: 'p' }, state, repo, fakeBody(list), { repaintDiff: false }); + const folder = list.children.find(c => typeof c.className === 'string' && c.className.startsWith('git-folder-row')); + assert.equal(folder.className, 'git-folder-row selected'); +}); diff --git a/test/projects.test.js b/test/projects.test.js index 2eb6817c8..bdff4d16d 100644 --- a/test/projects.test.js +++ b/test/projects.test.js @@ -1343,3 +1343,81 @@ test('legacy omitted options preserve old defaults and invalid settings retry in assert.equal(projects.importLegacySchedules([bad, defaults]), 1, 'fixed files can be retried'); } finally { t.cleanup(); } }); + +// --- Git Graph tab (read-only): boundary check + delegation into git-graph-service.js --- + +const gitGraphService = require('../git-graph-service'); +const realGit = require('../git'); + +function initGitGraphServiceFor() { + gitGraphService.init({ log: { info() {}, error() {} }, git: realGit }); +} + +test('every Git Graph projects.js wrapper rejects a folderPath not attached to the project', { skip: !haveGit && 'git not installed' }, async () => { + const t = setup(); + const repo = makeRepo(); + const other = makeRepo('switchboard-other-repo-'); + initGitGraphServiceFor(); + try { + const created = await projects.createProject({ name: 'Git Graph boundary', folders: [{ path: repo, mode: 'in-place' }] }); + const id = created.project.id; + + const calls = [ + () => projects.projectGitGraph(id, other, {}), + () => projects.projectGitGraphCommitDetail(id, other, 'deadbeef'), + () => projects.projectGitGraphCompareDetail(id, other, 'deadbeef', 'beefdead'), + () => projects.projectGitGraphFileAtRevision(id, other, 'HEAD', 'README.md'), + () => projects.projectGitGraphFileDiffBetween(id, other, 'HEAD', null, 'README.md'), + ]; + for (const call of calls) { + await assert.rejects(call(), /not attached/, `${call} should reject a folderPath not attached to the project`); + } + } finally { + rm(other); + rm(repo); + t.cleanup(); + } +}); + +test('projectGitGraph delegates to git-graph-service for an attached repository and returns real commits', { skip: !haveGit && 'git not installed' }, async () => { + const t = setup(); + const repo = makeRepo(); + initGitGraphServiceFor(); + try { + const created = await projects.createProject({ name: 'Git Graph delegation', folders: [{ path: repo, mode: 'in-place' }] }); + const result = await projects.projectGitGraph(created.project.id, repo, { limit: 10 }); + assert.equal(result.ok, true); + assert.ok(result.commits.some(c => c.subject === 'init')); + } finally { + rm(repo); + t.cleanup(); + } +}); + +test('projectGitGraphCommitDetail/CompareDetail/FileAtRevision/FileDiffBetween all delegate for an attached repository', { skip: !haveGit && 'git not installed' }, async () => { + const t = setup(); + const repo = makeRepo(); + initGitGraphServiceFor(); + try { + const created = await projects.createProject({ name: 'Git Graph reads', folders: [{ path: repo, mode: 'in-place' }] }); + const id = created.project.id; + const head = (await realGit.logWithParents(repo, { limit: 1 }))[0].hash; + + const detail = await projects.projectGitGraphCommitDetail(id, repo, head); + assert.equal(detail.ok, true); + assert.equal(detail.commit.subject, 'init'); + + const compare = await projects.projectGitGraphCompareDetail(id, repo, head, null); + assert.equal(compare.ok, true); + + const atRevision = await projects.projectGitGraphFileAtRevision(id, repo, head, 'README.md'); + assert.equal(atRevision.ok, true); + assert.match(atRevision.content, /hello/); + + const diffBetween = await projects.projectGitGraphFileDiffBetween(id, repo, head, null, 'README.md'); + assert.equal(diffBetween.ok, true); + } finally { + rm(repo); + t.cleanup(); + } +});