From 948c7c54dd49e83096fd1f5d5191ec2d2ab87b65 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:41:03 +0800 Subject: [PATCH] feat(git-status): group changed files under collapsible folders (setting, default on) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Git window shows each group's files under their folders, collapsed until clicked, with single-child folder chains merged and open folders surviving the refresh. App Settings → Bottom bar → 'Git status: group files by folder' (per device) switches back to the flat list. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS --- src/web/public/git-status-ui.js | 66 +++++++++++++++++++++++++++++++-- src/web/public/index.html | 7 ++++ src/web/public/settings-ui.js | 5 ++- src/web/public/styles.css | 36 ++++++++++++++++++ test/git-status.browser.test.ts | 54 +++++++++++++++++++++++++++ 5 files changed, 164 insertions(+), 4 deletions(-) diff --git a/src/web/public/git-status-ui.js b/src/web/public/git-status-ui.js index 1af96de1..adcd8f0b 100644 --- a/src/web/public/git-status-ui.js +++ b/src/web/public/git-status-ui.js @@ -128,6 +128,13 @@ Object.assign(CodemanApp.prototype, { if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel(); }, + /** Whether the Git window groups changed files under collapsible folders (default on). */ + isGitStatusTree() { + const settings = this.loadAppSettingsFromStorage(); + const defaults = this.getDefaultSettings(); + return (settings.gitStatusTree ?? defaults.gitStatusTree ?? true) === true; + }, + /** The data for the session on screen, or null (not enabled, no session, not a repo, remote/docker, error). */ _currentGitStatus() { const s = this._gitStatus; @@ -408,7 +415,8 @@ Object.assign(CodemanApp.prototype, { if (!rows.length) continue; const group = el('div', `git-status-group git-status-group--${kind}`); group.append(el('div', 'git-status-group-title', `${label} (${data.counts[kind]})`)); - for (const f of rows) group.append(this._gitFileRow(f, data)); + if (this.isGitStatusTree()) group.append(...this._gitFileTree(rows, data, kind)); + else for (const f of rows) group.append(this._gitFileRow(f, data)); filesSection.append(group); } if (data.filesTruncated) { @@ -450,7 +458,58 @@ Object.assign(CodemanApp.prototype, { body.append(pushSection); }, - _gitFileRow(f, data) { + /** + * `rows` as folders (collapsed until clicked) holding their files. A folder with one child folder and + * nothing else is merged into it (`src/web/public` as one row) so a deep path is one click, not five. + * Which folders are open survives the 15 s re-render (`_gitTreeOpen`, keyed by repo, group and folder). + */ + _gitFileTree(rows, data, kind) { + const root = { dirs: new Map(), files: [] }; + for (const f of rows) { + const trailing = f.path.endsWith('/'); + const parts = f.path.replace(/\/$/, '').split('/'); + const leaf = parts.pop() + (trailing ? '/' : ''); + let node = root; + for (const part of parts) { + if (!node.dirs.has(part)) node.dirs.set(part, { dirs: new Map(), files: [] }); + node = node.dirs.get(part); + } + node.files.push({ f, leaf }); + } + const open = (this._gitTreeOpen = this._gitTreeOpen || new Set()); + const count = (n) => n.files.length + [...n.dirs.values()].reduce((sum, d) => sum + count(d), 0); + const build = (node, prefix) => { + const out = []; + for (const [name0, child0] of [...node.dirs].sort((a, b) => a[0].localeCompare(b[0]))) { + let name = name0; + let child = child0; + while (child.files.length === 0 && child.dirs.size === 1) { + const [n, c] = [...child.dirs][0]; + name += `/${n}`; + child = c; + } + const key = `${data.repoRoot}|${kind}|${prefix}${name}`; + const dir = this._gitEl('details', 'git-tree-dir'); + dir.open = open.has(key); + dir.addEventListener('toggle', () => (dir.open ? open.add(key) : open.delete(key))); + const summary = this._gitEl('summary', 'git-tree-summary'); + summary.append(this._gitEl('span', 'git-tree-name', `${name}/`)); + summary.append(this._gitEl('span', 'git-tree-count', String(count(child)))); + dir.append(summary); + const inner = this._gitEl('div', 'git-tree-children'); + inner.append(...build(child, `${prefix}${name}/`)); + dir.append(inner); + out.push(dir); + } + for (const { f, leaf } of node.files.sort((a, b) => a.leaf.localeCompare(b.leaf))) { + out.push(this._gitFileRow(f, data, leaf)); + } + return out; + }; + return build(root, ''); + }, + + _gitFileRow(f, data, displayName) { const el = (tag, cls, text) => this._gitEl(tag, cls, text); const row = el('div', 'git-status-file'); // Untracked entries have `?`; staged ones show the index letter, the rest the working-tree letter. @@ -459,7 +518,8 @@ Object.assign(CodemanApp.prototype, { const badge = el('span', `git-status-badge git-status-badge--${letter === '?' ? 'new' : letter}`, letter); badge.title = GIT_STATUS_BADGE_TITLE[letter] || letter; row.append(badge); - const name = el('span', 'git-status-path', f.path); + const name = el('span', 'git-status-path', displayName ?? f.path); + if (displayName) name.title = f.path; row.append(name); if (f.origPath) row.append(el('span', 'git-status-orig', `← ${f.origPath}`)); diff --git a/src/web/public/index.html b/src/web/public/index.html index a777344b..12d67698 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1965,6 +1965,13 @@ +
+
+ Git status: group files by folder device + In the Git window, show changed files under their folders, collapsed until you click a folder. Off lists every file by its full path. On by default. +
+ +
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index debca864..07e34667 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -451,6 +451,7 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsShowAwayDigestButton').checked = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? false; document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? false; document.getElementById('appSettingsShowGitStatus').checked = settings.showGitStatus ?? defaults.showGitStatus ?? false; + document.getElementById('appSettingsGitStatusTree').checked = settings.gitStatusTree ?? defaults.gitStatusTree ?? true; // Gesture control lives in the Input section (alongside Local Echo / CJK Input) // but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets // window.__codemanGestureAvailable). Hide just this item otherwise so the toggle @@ -2424,6 +2425,7 @@ Object.assign(CodemanApp.prototype, { showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked, showCronButton: document.getElementById('appSettingsShowCronButton').checked, showGitStatus: document.getElementById('appSettingsShowGitStatus').checked, + gitStatusTree: document.getElementById('appSettingsGitStatusTree').checked, gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked, subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked, subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked, @@ -2678,6 +2680,7 @@ Object.assign(CodemanApp.prototype, { showCronButton: _crb, // Per-device bottom-bar indicator, absent from SettingsUpdateSchema (.strict()): it must not reach the PUT. showGitStatus: _sgs, + gitStatusTree: _gst, showTabDetachButton: _tdb, // Phone-only home surface, and absent from SettingsUpdateSchema (.strict()). mobileOverviewEnabled: _mov, @@ -3969,7 +3972,7 @@ Object.assign(CodemanApp.prototype, { 'language', 'terminalWheelLocalScrollback', 'autoCopySelection', 'copyStripMargin', - 'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', + 'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'showTabDetachButton', 'mobileOverviewEnabled', 'sessionLineageLines', diff --git a/src/web/public/styles.css b/src/web/public/styles.css index de6a8db0..61b148a0 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -19634,3 +19634,39 @@ html .toolbar .btn-git-status.git-status--conflict { .git-diff-line--meta { color: var(--text-muted); } + +/* Git status panel: files grouped under collapsible folders */ +.git-tree-dir > .git-tree-summary { + display: flex; + align-items: baseline; + gap: 0.45rem; + padding: 0.15rem 0.25rem; + border-radius: 4px; + cursor: pointer; + min-width: 0; +} + +.git-tree-dir > .git-tree-summary:hover { + background: var(--bg-hover); +} + +.git-tree-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: 'SF Mono', Monaco, monospace; + font-size: 0.7rem; +} + +.git-tree-count { + font-size: 0.64rem; + color: var(--text-muted); +} + +.git-tree-children { + margin-left: 0.7rem; + padding-left: 0.4rem; + border-left: 1px solid var(--border); +} diff --git a/test/git-status.browser.test.ts b/test/git-status.browser.test.ts index 0d15bfed..0abe6a3a 100644 --- a/test/git-status.browser.test.ts +++ b/test/git-status.browser.test.ts @@ -214,6 +214,60 @@ describe('Git status indicator in a real browser', () => { expect(after).toBeLessThan(before - 100); }); + it('groups files under folders that start collapsed and expand on click; the setting turns it off', async () => { + mkdirSync(join(repo, 'deep/er/still'), { recursive: true }); + mkdirSync(join(repo, 'docs')); + write('deep/er/still/one.txt'); + write('docs/a.md'); + write('docs/b.md'); + // git reports an all-untracked folder as ONE `dir/` entry, so commit these first and then edit them. + git(repo, 'add', 'deep', 'docs'); + git(repo, 'commit', '-q', '-m', 'add folders'); + write('deep/er/still/one.txt', 'changed\n'); + write('docs/a.md', 'changed\n'); + write('docs/b.md', 'changed\n'); + await refresh(); + await page.waitForSelector('.git-tree-dir'); + // `deep/er/still` is a chain of single-child folders: one row, not three. + const names = await page.locator('.git-tree-name').allTextContents(); + expect(names).toContain('deep/er/still/'); + expect(names).toContain('docs/'); + expect(await page.locator('.git-tree-dir[open]').count()).toBe(0); + expect(await page.locator('.git-status-file:has-text("one.txt")').isVisible()).toBe(false); + expect(await page.locator('.git-tree-dir:has(.git-tree-name:text-is("docs/")) .git-tree-count').textContent()).toBe( + '2' + ); + await page.click('.git-tree-summary:has-text("docs/")'); + expect(await page.locator('.git-status-file:has-text("a.md")').isVisible()).toBe(true); + // The open folder survives the re-render a refresh causes. + await refresh(); + await page.waitForSelector('.git-tree-dir[open]'); + expect(await page.locator('.git-status-file:has-text("a.md")').isVisible()).toBe(true); + // A file in a folder still opens its diff, and shows only its own name. + await page.click('.git-status-file:has-text("a.md")'); + await page.waitForSelector('#gitStatusBody .git-diff-path'); + expect(await page.textContent('#gitStatusBody .git-diff-path')).toBe('docs/a.md'); + await page.click('#gitStatusBody button:has-text("Back")'); + + // Setting off: the flat list, every file by its full path. + await page.evaluate(() => (window as any).app.openAppSettings()); + await page.click('label.switch:has(#appSettingsGitStatusTree)'); + await page.evaluate(() => (window as any).app.saveAppSettings()); + await page.waitForTimeout(300); + await page.evaluate(() => (window as any).app.closeAppSettings()); + await page.evaluate(() => (window as any).app._renderGitStatusPanel()); + expect(await page.locator('.git-tree-dir').count()).toBe(0); + expect(await page.locator('.git-status-path', { hasText: 'docs/a.md' }).count()).toBe(1); + // Back on for the rest of the file. + await page.evaluate(() => (window as any).app.openAppSettings()); + await page.click('label.switch:has(#appSettingsGitStatusTree)'); + await page.evaluate(() => (window as any).app.saveAppSettings()); + await page.waitForTimeout(300); + await page.evaluate(() => (window as any).app.closeAppSettings()); + rmSync(join(repo, 'deep'), { recursive: true }); + rmSync(join(repo, 'docs'), { recursive: true }); + }, 30000); + it('once everything is committed and pushed the button says so, and the panel agrees', async () => { rmSync(join(repo, '.txt')); git(repo, 'checkout', '-q', '--', '.');