feat(git-status): group changed files under collapsible folders (setting, default on)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
This commit is contained in:
Devvyn
2026-10-05 09:41:03 +08:00
co-authored by Claude Sonnet 5.5
parent cd9218c23e
commit 948c7c54dd
5 changed files with 164 additions and 4 deletions
+63 -3
View File
@@ -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}`));
+7
View File
@@ -1965,6 +1965,13 @@
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowGitStatus"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="git status folders tree flat list collapsed expand files">
<div class="set-row-text">
<span class="set-row-label">Git status: group files by folder <span class="set-scope">device</span></span>
<span class="set-row-desc">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.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsGitStatusTree" checked><span class="slider"></span></label>
</div>
</div>
</div>
+4 -1
View File
@@ -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',
+36
View File
@@ -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);
}
+54
View File
@@ -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, '<img src=x onerror=window.__pwned=1>.txt'));
git(repo, 'checkout', '-q', '--', '.');