mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 07:29:42 +02:00
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:
co-authored by
Claude Sonnet 5.5
parent
cd9218c23e
commit
948c7c54dd
@@ -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}`));
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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', '--', '.');
|
||||
|
||||
Reference in New Issue
Block a user