mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
Merge #543: configurable git status repository limit and git timeout, unreadable repositories stay listed
# Conflicts: # src/web/public/styles.css
This commit is contained in:
commit
1ee566e7a1
13 files changed
+429
-48
No files matched your search
@@ -117,7 +117,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
const epoch = (this._gitStatusEpoch = (this._gitStatusEpoch || 0) + 1);
|
||||
this._gitStatusFetchedAt = Date.now();
|
||||
try {
|
||||
const data = await this._apiJson(`/api/sessions/${encodeURIComponent(sid)}/git-status${fresh ? '?fresh=1' : ''}`);
|
||||
const query = new URLSearchParams(this.gitStatusLimits());
|
||||
if (fresh) query.set('fresh', '1');
|
||||
const data = await this._apiJson(`/api/sessions/${encodeURIComponent(sid)}/git-status?${query}`);
|
||||
if (epoch !== this._gitStatusEpoch || sid !== this.activeSessionId || !this.isGitStatusEnabled()) return;
|
||||
this._gitStatus = data ? { sessionId: sid, data } : null;
|
||||
} catch {
|
||||
@@ -131,6 +133,23 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel();
|
||||
},
|
||||
|
||||
/**
|
||||
* How many repositories to list below a folder that is not itself a repository, and how long one
|
||||
* git command may run, in seconds (Settings → Bottom bar, per device). The server clamps both again.
|
||||
*/
|
||||
gitStatusLimits() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
const num = (v, min, max, fallback) => {
|
||||
const n = Math.trunc(Number(v));
|
||||
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback;
|
||||
};
|
||||
return {
|
||||
maxRepos: num(settings.gitStatusMaxRepos ?? defaults.gitStatusMaxRepos, 1, 50, 12),
|
||||
timeout: num(settings.gitStatusTimeoutSeconds ?? defaults.gitStatusTimeoutSeconds, 5, 120, 30),
|
||||
};
|
||||
},
|
||||
|
||||
/** Whether the Git window groups changed files under collapsible folders (default on). */
|
||||
isGitStatusTree() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
@@ -150,13 +169,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
let uncommitted = 0;
|
||||
let unpushed = 0;
|
||||
let conflicted = 0;
|
||||
let unreadable = 0;
|
||||
for (const r of overview.repos) {
|
||||
if (r.status.state === 'error') unreadable += 1;
|
||||
uncommitted += r.status.counts.uncommitted;
|
||||
unpushed += r.status.unpushedCount;
|
||||
conflicted += r.status.counts.conflicted;
|
||||
}
|
||||
const tone = conflicted > 0 ? 'conflict' : uncommitted > 0 || unpushed > 0 ? 'dirty' : 'clean';
|
||||
return { uncommitted, unpushed, conflicted, repos: overview.repos.length, tone };
|
||||
// A repository that could not be read is not "clean": it must not let the indicator say ✓.
|
||||
const tone = conflicted > 0 ? 'conflict' : uncommitted > 0 || unpushed > 0 || unreadable > 0 ? 'dirty' : 'clean';
|
||||
return { uncommitted, unpushed, conflicted, unreadable, repos: overview.repos.length, tone };
|
||||
},
|
||||
|
||||
/** One sentence for the tooltip and the screen-reader label. */
|
||||
@@ -168,6 +190,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (sum.conflicted) bits.push(plural(sum.conflicted, 'file with a merge conflict', 'files with merge conflicts'));
|
||||
if (sum.uncommitted) bits.push(plural(sum.uncommitted, 'uncommitted file', 'uncommitted files'));
|
||||
if (sum.unpushed) bits.push(plural(sum.unpushed, 'commit not pushed', 'commits not pushed'));
|
||||
if (sum.unreadable)
|
||||
bits.push(plural(sum.unreadable, 'repository could not be read', 'repositories could not be read'));
|
||||
if (!bits.length) bits.push('everything is committed and pushed');
|
||||
let where;
|
||||
if (sum.repos > 1) where = `${sum.repos} repositories`;
|
||||
@@ -196,6 +220,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (sum.conflicted) parts.push(`⚠ ${sum.conflicted}`);
|
||||
if (sum.uncommitted) parts.push(`● ${sum.uncommitted}`);
|
||||
if (sum.unpushed) parts.push(`↑ ${sum.unpushed}`);
|
||||
if (sum.unreadable) parts.push(`? ${sum.unreadable}`);
|
||||
if (!parts.length) parts.push('✓');
|
||||
if (label) label.textContent = parts.join(' ');
|
||||
const sentence = this._gitStatusSentence(data);
|
||||
@@ -343,7 +368,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
for (const r of repos) body.append(this._gitRepoSection(r));
|
||||
if (overview.reposTruncated) {
|
||||
body.append(
|
||||
el('div', 'git-status-more', `Showing the first ${repos.length} repositories found under this folder.`)
|
||||
el(
|
||||
'div',
|
||||
'git-status-more',
|
||||
`Showing the first ${overview.repoLimit || repos.length} of more than ${overview.repoLimit || repos.length} repositories under this folder. Raise “Git status: max repositories” in Settings → Bottom bar to see more.`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -364,6 +393,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
_gitRepoSection(r) {
|
||||
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||
const d = r.status;
|
||||
if (d.state === 'error') {
|
||||
// Kept in the list with the reason, rather than silently left out.
|
||||
const row = el('div', 'git-status-repo git-status-repo--error');
|
||||
row.append(el('span', 'git-status-repo-name', r.name));
|
||||
if (r.path !== r.name) row.append(el('span', 'git-status-repo-path', r.path));
|
||||
const why = el('span', 'git-status-repo-unreadable', `⚠ could not read: ${d.error || 'git failed'}`);
|
||||
why.title = 'If this is a timeout, raise “Git status: git timeout” in Settings → Bottom bar.';
|
||||
row.append(why);
|
||||
return row;
|
||||
}
|
||||
const section = el('details', 'git-status-repo');
|
||||
const outstanding = d.counts.uncommitted > 0 || d.unpushedCount > 0;
|
||||
const openRepos = (this._gitTreeOpen = this._gitTreeOpen || new Set());
|
||||
@@ -582,7 +621,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
const view = { sessionId, repoRoot, file, letter, state: 'loading' };
|
||||
this._gitDiffView = view;
|
||||
this._renderGitStatusPanel();
|
||||
const qs = new URLSearchParams({ repo: repoRoot, path: file.path, kind: file.kind });
|
||||
const qs = new URLSearchParams({
|
||||
repo: repoRoot,
|
||||
path: file.path,
|
||||
kind: file.kind,
|
||||
...this.gitStatusLimits(),
|
||||
});
|
||||
const res = await this._api(`/api/sessions/${encodeURIComponent(sessionId)}/git-diff?${qs}`);
|
||||
// Back, another file or another session while this was in flight: drop the answer.
|
||||
if (this._gitDiffView !== view) return;
|
||||
|
||||
@@ -2009,6 +2009,20 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsGitStatusTree" checked><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="git status maximum repositories folder many repos limit">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Git status: max repositories <span class="set-scope">device</span></span>
|
||||
<span class="set-row-desc">When the session's folder holds several projects instead of being one, the Git window lists up to this many (1 to 50, default 12). Each one costs a few git commands per refresh.</span>
|
||||
</div>
|
||||
<input type="number" id="appSettingsGitStatusMaxRepos" class="set-num" value="12" min="1" max="50" step="1">
|
||||
</div>
|
||||
<div class="set-row" data-search="git status timeout seconds slow share network">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Git status: git timeout <span class="set-scope">device</span></span>
|
||||
<span class="set-row-desc">Seconds one git command may run before that repository is reported as unreadable (5 to 120, default 30). Raise it for repositories on a slow network share.</span>
|
||||
</div>
|
||||
<input type="number" id="appSettingsGitStatusTimeout" class="set-num" value="30" min="5" max="120" step="5">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -455,6 +455,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
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;
|
||||
document.getElementById('appSettingsGitStatusMaxRepos').value = settings.gitStatusMaxRepos ?? defaults.gitStatusMaxRepos ?? 12;
|
||||
document.getElementById('appSettingsGitStatusTimeout').value = settings.gitStatusTimeoutSeconds ?? defaults.gitStatusTimeoutSeconds ?? 30;
|
||||
// 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
|
||||
@@ -2552,6 +2554,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
||||
showGitStatus: document.getElementById('appSettingsShowGitStatus').checked,
|
||||
gitStatusTree: document.getElementById('appSettingsGitStatusTree').checked,
|
||||
// Clamped here and again on the server; an empty or odd value falls back to the default.
|
||||
gitStatusMaxRepos: Math.min(50, Math.max(1, parseInt(document.getElementById('appSettingsGitStatusMaxRepos').value, 10) || 12)),
|
||||
gitStatusTimeoutSeconds: Math.min(120, Math.max(5, parseInt(document.getElementById('appSettingsGitStatusTimeout').value, 10) || 30)),
|
||||
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
|
||||
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
||||
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
||||
@@ -2823,6 +2828,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Per-device bottom-bar indicator, absent from SettingsUpdateSchema (.strict()): it must not reach the PUT.
|
||||
showGitStatus: _sgs,
|
||||
gitStatusTree: _gst,
|
||||
gitStatusMaxRepos: _gsm,
|
||||
gitStatusTimeoutSeconds: _gst2,
|
||||
showTabDetachButton: _tdb,
|
||||
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
|
||||
mobileOverviewEnabled: _mov,
|
||||
@@ -4228,7 +4235,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'language',
|
||||
'terminalWheelLocalScrollback',
|
||||
'autoCopySelection', 'copyStripMargin',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds',
|
||||
'showTabDetachButton',
|
||||
'mobileOverviewEnabled',
|
||||
'sessionLineageLines',
|
||||
|
||||
@@ -20586,6 +20586,22 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Git status panel: a repository git could not read stays in the list, with the reason */
|
||||
.git-status-repo--error {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: var(--bg-input);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.git-status-repo-unreadable {
|
||||
color: #e0a030;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Tab grouping by state (tabArrangement: 'state', opt-in;
|
||||
Discussion #426 option C)
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
emptyOverview,
|
||||
findWorkspaceRepo,
|
||||
getGitFileDiff,
|
||||
resolveOverviewLimits,
|
||||
getGitWorkspaceOverview,
|
||||
getGitWorkspaceStatus,
|
||||
type GitFileDiff,
|
||||
@@ -33,6 +34,18 @@ import type { SessionPort } from '../ports/index.js';
|
||||
const defaultDockerWorkspaces = async (): Promise<string[]> =>
|
||||
(await readDockerCases(getDataDir()).catch(() => [])).map((c) => c.hostWorkspacePath).filter(Boolean);
|
||||
|
||||
/**
|
||||
* The repository limit and git timeout a request asks for (Settings → Bottom bar, per device):
|
||||
* `maxRepos` and `timeout` (seconds) query parameters, each clamped to a safe range, so an odd value
|
||||
* can never cost more than the module's own ceiling.
|
||||
*/
|
||||
function limitsFrom(query: { maxRepos?: string; timeout?: string }) {
|
||||
return resolveOverviewLimits({
|
||||
maxRepos: query.maxRepos,
|
||||
timeoutMs: query.timeout?.trim() ? Number(query.timeout) * 1000 : undefined, // '' and absent mean "default"
|
||||
});
|
||||
}
|
||||
|
||||
export function registerGitStatusRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort,
|
||||
@@ -41,7 +54,9 @@ export function registerGitStatusRoutes(
|
||||
): void {
|
||||
app.get('/api/sessions/:id/git-status', async (req): Promise<ApiResponse<GitWorkspaceOverview>> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { fresh } = req.query as { fresh?: string };
|
||||
const query = req.query as { fresh?: string; maxRepos?: string; timeout?: string };
|
||||
const { fresh } = query;
|
||||
const limits = limitsFrom(query);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
if (session.remote) return { success: true, data: emptyOverview('unsupported', { reason: 'remote' }) };
|
||||
if (session.docker) return { success: true, data: emptyOverview('unsupported', { reason: 'docker' }) };
|
||||
@@ -50,6 +65,7 @@ export function registerGitStatusRoutes(
|
||||
data: await getGitWorkspaceOverview(session.workingDir, {
|
||||
git,
|
||||
fresh: fresh === '1',
|
||||
...limits,
|
||||
dockerWorkspaces: await dockerWorkspaces(),
|
||||
}),
|
||||
};
|
||||
@@ -62,16 +78,24 @@ export function registerGitStatusRoutes(
|
||||
// repository's status is refreshed: a click must not re-read every repository in the folder.
|
||||
app.get('/api/sessions/:id/git-diff', async (req, reply): Promise<ApiResponse<GitFileDiff>> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { repo, path, kind } = req.query as { repo?: string; path?: string; kind?: string };
|
||||
const query = req.query as { repo?: string; path?: string; kind?: string; maxRepos?: string; timeout?: string };
|
||||
const { repo, path, kind } = query;
|
||||
const limits = limitsFrom(query);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
if (session.remote || session.docker) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Git is not available for remote or Docker sessions');
|
||||
}
|
||||
const repoRoot = repo
|
||||
? await findWorkspaceRepo(session.workingDir, repo, { git, dockerWorkspaces: await dockerWorkspaces() })
|
||||
? await findWorkspaceRepo(session.workingDir, repo, {
|
||||
git,
|
||||
...limits,
|
||||
dockerWorkspaces: await dockerWorkspaces(),
|
||||
})
|
||||
: null;
|
||||
const status = repoRoot
|
||||
? await getGitWorkspaceStatus(repoRoot, { git, fresh: true, timeoutMs: limits.timeoutMs })
|
||||
: null;
|
||||
const status = repoRoot ? await getGitWorkspaceStatus(repoRoot, { git, fresh: true }) : null;
|
||||
const entry =
|
||||
status?.state === 'ok'
|
||||
? status.files.find((f) => f.path === path && f.kind === (kind as GitFileKind))
|
||||
@@ -81,7 +105,10 @@ export function registerGitStatusRoutes(
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'That file has no outstanding change any more');
|
||||
}
|
||||
try {
|
||||
return { success: true, data: await getGitFileDiff(status.repoRoot, entry, { git }) };
|
||||
return {
|
||||
success: true,
|
||||
data: await getGitFileDiff(status.repoRoot, entry, { git, timeoutMs: limits.timeoutMs }),
|
||||
};
|
||||
} catch (err) {
|
||||
reply.code(500);
|
||||
return createErrorResponse(
|
||||
|
||||
Reference in new issue
Block a user