diff --git a/public/project-git-view.js b/public/project-git-view.js
index a729dd92..00853275 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${changed ? `${changed} file${changed === 1 ? '' : 's'}` : 'Working tree clean'}
+ Changes${changed ? gitViewToggleHtml(gitChangesViewMode()) : ''}${changed ? `${changed} file${changed === 1 ? '' : 's'}` : 'Working tree clean'}
${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 46661ba0..addcc22c 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 d595ecdb..18610c47 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 00000000..d4493855
--- /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);
+});