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', '--', '.');