From d36328a6b274b2f5ed9ed7bd1ee7241700784ad5 Mon Sep 17 00:00:00 2001 From: Vincenzo Favara Date: Sat, 26 Sep 2026 22:07:00 +0200 Subject: [PATCH 1/6] Add list/tree view toggle to the Git tab's Changes list A List/Tree switch in the Changes heading. List keeps today's flat view and stays the default; Tree groups each status group's files into collapsible folders, with single-child folder chains compacted into one row (like VS Code's Source Control "View as Tree"). The choice is remembered in localStorage. Switching views or folding a folder repaints only the file list, leaving the selection and diff pane untouched, and keeps keyboard focus on the toggled folder. --- public/project-git-view.js | 184 +++++++++++++++++++++--- public/projects-view.js | 1 + public/style.css | 55 +++++++ test/project-git-view.test.js | 261 ++++++++++++++++++++++++++++++++++ 4 files changed, 483 insertions(+), 18 deletions(-) create mode 100644 test/project-git-view.test.js diff --git a/public/project-git-view.js b/public/project-git-view.js index a729dd927..008532756 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 === 'tree' ? 'tree' : 'list'; +} + +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,116 @@ 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; + row.title = tree ? `${renamed}\n${gitChangeMeta(change)}` : renamed; + row.innerHTML = tree + ? `${gitChangeCode(change)}${escapeHtml(pathBasename(change.path) || change.path)}` + : `${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) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'git-folder-row'; + 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 +276,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 +287,27 @@ 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') list.appendChild(createGitFolderRow(project, state, repo, body, collapsedSet, row)); + else list.appendChild(createGitFileRow(project, state, repo, body, list, row.change, selected?.path === row.change.path, { tree: true, depth: row.depth })); } } - 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 +350,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 +370,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..addcc22c4 100644 --- a/public/projects-view.js +++ b/public/projects-view.js @@ -248,6 +248,7 @@ 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) => ``, diff --git a/public/style.css b/public/style.css index d595ecdb1..18610c473 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,35 @@ 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-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-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/test/project-git-view.test.js b/test/project-git-view.test.js new file mode 100644 index 000000000..d4493855f --- /dev/null +++ b/test/project-git-view.test.js @@ -0,0 +1,261 @@ +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 "list" for missing, invalid, or throwing storage', () => { + assert.equal(loadContext({ getItem() { return null; }, setItem() {} }).gitChangesViewMode(), 'list'); + assert.equal(loadContext({ getItem() { return 'nonsense'; }, setItem() {} }).gitChangesViewMode(), 'list'); + assert.equal(loadContext({ getItem() { throw new Error('blocked'); }, setItem() {} }).gitChangesViewMode(), 'list'); + assert.equal(loadContext({ getItem() { return 'tree'; }, setItem() {} }).gitChangesViewMode(), 'tree'); +}); + +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 => el.className === '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); + assert.deepEqual(list.children.map(c => c.className), ['git-file-group', 'git-folder-row']); + // 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); +}); From 1c7175a82dd48569867489515f49ea9e9d4dbcb8 Mon Sep 17 00:00:00 2001 From: Vincenzo Favara Date: Wed, 30 Sep 2026 12:54:22 +0200 Subject: [PATCH 2/6] Git tab tree view: default to tree, show staged state, keep selection visible - Tree is now the default view; a stored 'list' choice is kept. - Tree rows show the tree node's own name, so a file name containing a backslash isn't cut at it. - Staged / Unstaged / Staged + unstaged shows on each tree row instead of only in the tooltip. - Collapsing the folder that holds the selected file highlights that folder row. --- public/project-git-view.js | 24 ++++++++++++++------ public/style.css | 2 ++ test/project-git-view.test.js | 41 ++++++++++++++++++++++++++++------- 3 files changed, 52 insertions(+), 15 deletions(-) diff --git a/public/project-git-view.js b/public/project-git-view.js index 008532756..15f1913fb 100644 --- a/public/project-git-view.js +++ b/public/project-git-view.js @@ -9,7 +9,7 @@ const GIT_CHANGES_VIEW_KEY = 'gitChangesView'; function gitChangesViewMode() { let stored = null; try { stored = localStorage.getItem(GIT_CHANGES_VIEW_KEY); } catch {} - return stored === 'tree' ? 'tree' : 'list'; + return stored === 'list' ? 'list' : 'tree'; } function setGitChangesViewMode(mode) { @@ -230,9 +230,13 @@ function createGitFileRow(project, state, repo, body, list, change, isSelected, 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; - row.title = tree ? `${renamed}\n${gitChangeMeta(change)}` : renamed; + 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(pathBasename(change.path) || change.path)}` + ? `${gitChangeCode(change)}${escapeHtml(opts.name || change.path)}${marker}` : `${gitChangeCode(change)} ${escapeHtml(change.path)}${escapeHtml(gitChangeMeta(change))}`; row.onclick = () => { @@ -243,10 +247,10 @@ function createGitFileRow(project, state, repo, body, list, change, isSelected, return row; } -function createGitFolderRow(project, state, repo, body, collapsedSet, row) { +function createGitFolderRow(project, state, repo, body, collapsedSet, row, holdsSelected) { const button = document.createElement('button'); button.type = 'button'; - button.className = 'git-folder-row'; + 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'); @@ -295,8 +299,14 @@ function paintGitChanges(project, state, repo, body, { repaintDiff = true } = {} continue; } for (const row of gitTreeRows(buildGitChangeTree(changes), collapsedSet, kind)) { - if (row.kind === 'folder') list.appendChild(createGitFolderRow(project, state, repo, body, collapsedSet, row)); - else list.appendChild(createGitFileRow(project, state, repo, body, list, row.change, selected?.path === row.change.path, { tree: true, depth: row.depth })); + 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 })); + } } } if (repaintDiff) paintGitDiff(project, state, repo, body, selected); diff --git a/public/style.css b/public/style.css index 18610c473..b4d967fce 100644 --- a/public/style.css +++ b/public/style.css @@ -5591,6 +5591,7 @@ body { display: flex; flex-direction: column; } } .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%; @@ -5608,6 +5609,7 @@ body { display: flex; flex-direction: column; } } .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; } diff --git a/test/project-git-view.test.js b/test/project-git-view.test.js index d4493855f..eb343200f 100644 --- a/test/project-git-view.test.js +++ b/test/project-git-view.test.js @@ -20,7 +20,7 @@ function loadContext(localStorageImpl) { }) }, window: { api: {} }, escapeHtml: (str) => String(str), - pathBasename: (p) => String(p).split('/').filter(Boolean).pop() || '', + pathBasename: (p) => String(p).split(/[\\/]/).filter(Boolean).pop() || '', formatDate: () => 'just now', PICONS: { branch: () => '', @@ -157,11 +157,11 @@ test('a collapsed folder hides its descendants but keeps its own row', () => { assert.equal(bRow.count, 2); }); -test('gitChangesViewMode reads the stored mode and falls back to "list" for missing, invalid, or throwing storage', () => { - assert.equal(loadContext({ getItem() { return null; }, setItem() {} }).gitChangesViewMode(), 'list'); - assert.equal(loadContext({ getItem() { return 'nonsense'; }, setItem() {} }).gitChangesViewMode(), 'list'); - assert.equal(loadContext({ getItem() { throw new Error('blocked'); }, setItem() {} }).gitChangesViewMode(), 'list'); - assert.equal(loadContext({ getItem() { return 'tree'; }, setItem() {} }).gitChangesViewMode(), 'tree'); +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', () => { @@ -196,7 +196,7 @@ function fakeBody(list, diffPane) { return null; }, querySelectorAll(selector) { - return selector === '.git-folder-row' ? list.children.filter(el => el.className === 'git-folder-row') : []; + return selector === '.git-folder-row' ? list.children.filter(el => String(el.className).split(' ').includes('git-folder-row')) : []; }, }; } @@ -253,9 +253,34 @@ test("a folder row's onclick toggles its collapsed state and repaints only the l folderRowA.onclick(); assert.equal(diffPane.innerHTML, paintedDiff); - assert.deepEqual(list.children.map(c => c.className), ['git-file-group', 'git-folder-row']); + // 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'); +}); From 50d3f52563f7537877a98fc7bd488d612731d4bc Mon Sep 17 00:00:00 2001 From: Vincenzo Favara Date: Wed, 30 Sep 2026 14:04:11 +0200 Subject: [PATCH 3/6] Add git read helpers for a Git Graph tab Log with parents, refs, stashes and file contents at a revision, all read-only and run through execFile with an argv array. --- git.js | 154 +++++++++++++++++++++++++++++++++++++ test/git.test.js | 192 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 346 insertions(+) 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/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'); +}); From 94b2c0d0b740069b5911f355529e7a11c21a6d09 Mon Sep 17 00:00:00 2001 From: Vincenzo Favara Date: Wed, 30 Sep 2026 14:04:11 +0200 Subject: [PATCH 4/6] Add a read-only Git Graph service and IPC Builds the graph payload (paging, stashes, uncommitted changes), commit details, comparisons and file contents for diffs, and watches the repository for changes. Every endpoint goes through projects.js and only accepts folders attached to the project; nothing here changes a repository. --- git-graph-service.js | 458 +++++++++++++++++++++++++++++++++ main.js | 17 ++ preload.js | 10 + projects.js | 45 ++++ test/git-graph-service.test.js | 256 ++++++++++++++++++ test/projects.test.js | 78 ++++++ 6 files changed, 864 insertions(+) create mode 100644 git-graph-service.js create mode 100644 test/git-graph-service.test.js 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/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/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/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(); + } +}); From c86600ca23412d6c21bd8e3f2abe645c114cc98a Mon Sep 17 00:00:00 2001 From: Vincenzo Favara Date: Wed, 30 Sep 2026 14:04:11 +0200 Subject: [PATCH 5/6] Add the Git Graph lane layout A pure function that assigns commits to lanes and computes edges for merges, octopus merges, multiple roots, stashes and the uncommitted row. --- public/git-graph-layout.js | 253 +++++++++++++++++++++ test/git-graph-layout.test.js | 402 ++++++++++++++++++++++++++++++++++ 2 files changed, 655 insertions(+) create mode 100644 public/git-graph-layout.js create mode 100644 test/git-graph-layout.test.js 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/projects-view.js b/public/projects-view.js index addcc22c4..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; @@ -253,6 +253,7 @@ const PICONS = { terminal: (s = 14) => ``, search: (s = 13) => ``, x: (s = 12) => ``, + tag: (s = 12) => ``, }; function cliIcon(session, size = 14) { @@ -1300,6 +1301,7 @@ function renderOverview() { + @@ -1310,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/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-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/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]; + } +});