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
+
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 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); +});