mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +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();
|
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). */
|
/** The data for the session on screen, or null (not enabled, no session, not a repo, remote/docker, error). */
|
||||||
_currentGitStatus() {
|
_currentGitStatus() {
|
||||||
const s = this._gitStatus;
|
const s = this._gitStatus;
|
||||||
@@ -408,7 +415,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (!rows.length) continue;
|
if (!rows.length) continue;
|
||||||
const group = el('div', `git-status-group git-status-group--${kind}`);
|
const group = el('div', `git-status-group git-status-group--${kind}`);
|
||||||
group.append(el('div', 'git-status-group-title', `${label} (${data.counts[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);
|
filesSection.append(group);
|
||||||
}
|
}
|
||||||
if (data.filesTruncated) {
|
if (data.filesTruncated) {
|
||||||
@@ -450,7 +458,58 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
body.append(pushSection);
|
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 el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||||
const row = el('div', 'git-status-file');
|
const row = el('div', 'git-status-file');
|
||||||
// Untracked entries have `?`; staged ones show the index letter, the rest the working-tree letter.
|
// 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);
|
const badge = el('span', `git-status-badge git-status-badge--${letter === '?' ? 'new' : letter}`, letter);
|
||||||
badge.title = GIT_STATUS_BADGE_TITLE[letter] || letter;
|
badge.title = GIT_STATUS_BADGE_TITLE[letter] || letter;
|
||||||
row.append(badge);
|
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);
|
row.append(name);
|
||||||
if (f.origPath) row.append(el('span', 'git-status-orig', `← ${f.origPath}`));
|
if (f.origPath) row.append(el('span', 'git-status-orig', `← ${f.origPath}`));
|
||||||
|
|
||||||
|
|||||||
@@ -1965,6 +1965,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowGitStatus"><span class="slider"></span></label>
|
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowGitStatus"><span class="slider"></span></label>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -451,6 +451,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
document.getElementById('appSettingsShowAwayDigestButton').checked = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? false;
|
document.getElementById('appSettingsShowAwayDigestButton').checked = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? false;
|
||||||
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? false;
|
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? false;
|
||||||
document.getElementById('appSettingsShowGitStatus').checked = settings.showGitStatus ?? defaults.showGitStatus ?? 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)
|
// 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
|
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
|
||||||
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
|
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
|
||||||
@@ -2424,6 +2425,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
|
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
|
||||||
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
||||||
showGitStatus: document.getElementById('appSettingsShowGitStatus').checked,
|
showGitStatus: document.getElementById('appSettingsShowGitStatus').checked,
|
||||||
|
gitStatusTree: document.getElementById('appSettingsGitStatusTree').checked,
|
||||||
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
|
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
|
||||||
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
||||||
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
||||||
@@ -2678,6 +2680,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
showCronButton: _crb,
|
showCronButton: _crb,
|
||||||
// Per-device bottom-bar indicator, absent from SettingsUpdateSchema (.strict()): it must not reach the PUT.
|
// Per-device bottom-bar indicator, absent from SettingsUpdateSchema (.strict()): it must not reach the PUT.
|
||||||
showGitStatus: _sgs,
|
showGitStatus: _sgs,
|
||||||
|
gitStatusTree: _gst,
|
||||||
showTabDetachButton: _tdb,
|
showTabDetachButton: _tdb,
|
||||||
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
|
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
|
||||||
mobileOverviewEnabled: _mov,
|
mobileOverviewEnabled: _mov,
|
||||||
@@ -3969,7 +3972,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
'language',
|
'language',
|
||||||
'terminalWheelLocalScrollback',
|
'terminalWheelLocalScrollback',
|
||||||
'autoCopySelection', 'copyStripMargin',
|
'autoCopySelection', 'copyStripMargin',
|
||||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus',
|
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree',
|
||||||
'showTabDetachButton',
|
'showTabDetachButton',
|
||||||
'mobileOverviewEnabled',
|
'mobileOverviewEnabled',
|
||||||
'sessionLineageLines',
|
'sessionLineageLines',
|
||||||
|
|||||||
@@ -19634,3 +19634,39 @@ html .toolbar .btn-git-status.git-status--conflict {
|
|||||||
.git-diff-line--meta {
|
.git-diff-line--meta {
|
||||||
color: var(--text-muted);
|
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);
|
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 () => {
|
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'));
|
rmSync(join(repo, '<img src=x onerror=window.__pwned=1>.txt'));
|
||||||
git(repo, 'checkout', '-q', '--', '.');
|
git(repo, 'checkout', '-q', '--', '.');
|
||||||
|
|||||||
Reference in New Issue
Block a user