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