mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 17:29:41 +02:00
Merge pull request #537 from opticon454/feat/git-status-indicator
feat(ui): git status indicator in the bottom bar, with a panel of uncommitted and unpushed work # Conflicts: # config/test-suites.ts # docs/api-reference.md
This commit is contained in:
@@ -0,0 +1,672 @@
|
||||
/**
|
||||
* @fileoverview Git status indicator in the bottom bar, and the panel it opens.
|
||||
*
|
||||
* Agents leave work uncommitted and unpushed. This puts a small indicator at the right of the bottom
|
||||
* toolbar for the ACTIVE session's repository, or repositories when the session's folder holds several (`●3` uncommitted files, `↑2` commits not pushed,
|
||||
* `✓` when everything is committed and pushed) and, on click, a draggable panel in the style of the
|
||||
* Files window listing exactly which files are uncommitted and which commits are not pushed.
|
||||
*
|
||||
* OPTIONAL and per-device: `showGitStatus` (App Settings → Header & Panels → Bottom bar), default
|
||||
* OFF. While it is off nothing polls and the button never shows. While it is on, the page asks
|
||||
* `GET /api/sessions/:id/git-status` for the active session on a slow poll (and at once when the
|
||||
* session changes or the window regains focus). The route is read-only and offline: it never fetches
|
||||
* or changes the repository, so the "behind" number reflects the last `git fetch`, which the panel
|
||||
* footer says. Remote (SSH) and Docker sessions answer `unsupported` and show no indicator.
|
||||
*
|
||||
* Everything that comes from git (file names, commit subjects, author names) is untrusted text: it is
|
||||
* only ever written with `textContent`, never `innerHTML`.
|
||||
*
|
||||
* The bottom toolbar's right group is hidden on phones (mobile.css), so this surface is desktop and
|
||||
* tablet only by construction.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (this.activeSessionId, this.loadAppSettingsFromStorage, this.getDefaultSettings, this.$)
|
||||
* @dependency panels-ui.js (openFilePreview)
|
||||
* @loadorder 12.57 of 16, after home-sessions.js, before entrance-animations.js
|
||||
*/
|
||||
|
||||
/** How often the active session's repository is re-read while the indicator is on. */
|
||||
const GIT_STATUS_POLL_MS = 15000;
|
||||
/** The timer only decides whether a poll is due; it is cheap and runs while the indicator is on. */
|
||||
const GIT_STATUS_TICK_MS = 2000;
|
||||
/** A focus or visibility change refreshes at once unless the last read is younger than this. */
|
||||
const GIT_STATUS_MIN_REFRESH_MS = 3000;
|
||||
|
||||
const GIT_STATUS_BADGE_TITLE = {
|
||||
M: 'Modified',
|
||||
A: 'Added',
|
||||
D: 'Deleted',
|
||||
R: 'Renamed',
|
||||
C: 'Copied',
|
||||
T: 'Type changed',
|
||||
U: 'Unmerged',
|
||||
'?': 'Untracked',
|
||||
};
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/** Per-device setting, default OFF. */
|
||||
isGitStatusEnabled() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
return (settings.showGitStatus ?? defaults.showGitStatus ?? false) === true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Starts or stops the poll to match the setting. Called from applyHeaderVisibilitySettings(), which
|
||||
* runs on boot and after every settings save, so a live toggle needs no reload.
|
||||
*/
|
||||
applyGitStatusVisibility() {
|
||||
const on = this.isGitStatusEnabled();
|
||||
if (on && !this._gitStatusTimer) {
|
||||
this._gitStatusTimer = setInterval(() => this._gitStatusTick(), GIT_STATUS_TICK_MS);
|
||||
this._gitStatusOnVisible = () => {
|
||||
if (!document.hidden) this.refreshGitStatus({ minAgeMs: GIT_STATUS_MIN_REFRESH_MS });
|
||||
};
|
||||
document.addEventListener('visibilitychange', this._gitStatusOnVisible);
|
||||
window.addEventListener('focus', this._gitStatusOnVisible);
|
||||
this.refreshGitStatus();
|
||||
} else if (!on && this._gitStatusTimer) {
|
||||
clearInterval(this._gitStatusTimer);
|
||||
this._gitStatusTimer = null;
|
||||
document.removeEventListener('visibilitychange', this._gitStatusOnVisible);
|
||||
window.removeEventListener('focus', this._gitStatusOnVisible);
|
||||
this._gitStatusOnVisible = null;
|
||||
}
|
||||
if (!on) {
|
||||
this._gitStatus = null;
|
||||
this._gitStatusEpoch = (this._gitStatusEpoch || 0) + 1; // an in-flight read must not repaint
|
||||
// That read's `finally` no longer owns the flag (its epoch is stale), so release it here: left set,
|
||||
// turning the setting back on would skip every refresh for this session until a reload.
|
||||
this._gitStatusInFlight = false;
|
||||
this.closeGitStatusPanel();
|
||||
}
|
||||
this._renderGitStatusButton();
|
||||
},
|
||||
|
||||
_gitStatusTick() {
|
||||
if (document.hidden) return;
|
||||
const sid = this.activeSessionId || null;
|
||||
if (sid !== this._gitStatusSessionId) {
|
||||
// The active session changed (or the first one opened): show nothing stale, read now.
|
||||
this._gitStatus = null;
|
||||
this._renderGitStatusButton();
|
||||
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel(); // not the previous repo's files
|
||||
this.refreshGitStatus();
|
||||
return;
|
||||
}
|
||||
if (sid && Date.now() - (this._gitStatusFetchedAt || 0) >= GIT_STATUS_POLL_MS) this.refreshGitStatus();
|
||||
},
|
||||
|
||||
/** Reads the active session's git status and repaints. Stale answers (another session, setting off) are dropped. */
|
||||
async refreshGitStatus({ minAgeMs = 0, fresh = false } = {}) {
|
||||
if (!this.isGitStatusEnabled()) return;
|
||||
const sid = this.activeSessionId || null;
|
||||
this._gitStatusSessionId = sid;
|
||||
if (!sid) {
|
||||
this._gitStatus = null;
|
||||
this._renderGitStatusButton();
|
||||
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel();
|
||||
return;
|
||||
}
|
||||
if (minAgeMs && Date.now() - (this._gitStatusFetchedAt || 0) < minAgeMs) return;
|
||||
// A read for THIS session is already running: let it finish. One for another session is not worth
|
||||
// waiting for (its answer is dropped below), so a session switch is never left blank.
|
||||
if (this._gitStatusInFlight && this._gitStatusInFlightSid === sid) return;
|
||||
this._gitStatusInFlight = true;
|
||||
this._gitStatusInFlightSid = sid;
|
||||
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' : ''}`);
|
||||
if (epoch !== this._gitStatusEpoch || sid !== this.activeSessionId || !this.isGitStatusEnabled()) return;
|
||||
this._gitStatus = data ? { sessionId: sid, data } : null;
|
||||
} catch {
|
||||
if (epoch === this._gitStatusEpoch) this._gitStatus = null;
|
||||
} finally {
|
||||
// Only the newest request owns the flag: an older one finishing late must not clear it.
|
||||
if (epoch === this._gitStatusEpoch) this._gitStatusInFlight = false;
|
||||
}
|
||||
if (epoch !== this._gitStatusEpoch) return;
|
||||
this._renderGitStatusButton();
|
||||
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;
|
||||
return s && s.sessionId === this.activeSessionId && s.data ? s.data : null;
|
||||
},
|
||||
|
||||
/** `{ uncommitted, unpushed, conflicted, repos, tone }` summed over every repository, or null when there is nothing to show. */
|
||||
_gitStatusSummary(overview) {
|
||||
if (!overview || overview.state !== 'ok' || !overview.repos?.length) return null;
|
||||
let uncommitted = 0;
|
||||
let unpushed = 0;
|
||||
let conflicted = 0;
|
||||
for (const r of overview.repos) {
|
||||
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 };
|
||||
},
|
||||
|
||||
/** One sentence for the tooltip and the screen-reader label. */
|
||||
_gitStatusSentence(overview) {
|
||||
const sum = this._gitStatusSummary(overview);
|
||||
if (!sum) return '';
|
||||
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
||||
const bits = [];
|
||||
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 (!bits.length) bits.push('everything is committed and pushed');
|
||||
let where;
|
||||
if (sum.repos > 1) where = `${sum.repos} repositories`;
|
||||
else {
|
||||
const d = overview.repos[0].status;
|
||||
where = d.detached ? 'detached HEAD' : d.branch || 'no branch';
|
||||
}
|
||||
return `Git (${where}): ${bits.join(', ')}. Click for details.`;
|
||||
},
|
||||
|
||||
_renderGitStatusButton() {
|
||||
const btn = this.$('gitStatusBtn');
|
||||
if (!btn) return;
|
||||
const data = this.isGitStatusEnabled() ? this._currentGitStatus() : null;
|
||||
const sum = this._gitStatusSummary(data);
|
||||
btn.hidden = !sum;
|
||||
btn.classList.toggle('git-status--clean', sum?.tone === 'clean');
|
||||
btn.classList.toggle('git-status--dirty', sum?.tone === 'dirty');
|
||||
btn.classList.toggle('git-status--conflict', sum?.tone === 'conflict');
|
||||
const label = btn.querySelector('.git-status-label');
|
||||
if (!sum) {
|
||||
if (label) label.textContent = '';
|
||||
return;
|
||||
}
|
||||
const parts = [];
|
||||
if (sum.conflicted) parts.push(`⚠ ${sum.conflicted}`);
|
||||
if (sum.uncommitted) parts.push(`● ${sum.uncommitted}`);
|
||||
if (sum.unpushed) parts.push(`↑ ${sum.unpushed}`);
|
||||
if (!parts.length) parts.push('✓');
|
||||
if (label) label.textContent = parts.join(' ');
|
||||
const sentence = this._gitStatusSentence(data);
|
||||
btn.title = sentence;
|
||||
btn.setAttribute('aria-label', sentence);
|
||||
},
|
||||
|
||||
// ── Panel ───────────────────────────────────────────────────────────────
|
||||
|
||||
_isGitStatusPanelOpen() {
|
||||
return !!this.$('gitStatusPanel')?.classList.contains('visible');
|
||||
},
|
||||
|
||||
toggleGitStatusPanel() {
|
||||
if (this._isGitStatusPanelOpen()) {
|
||||
this.closeGitStatusPanel();
|
||||
return;
|
||||
}
|
||||
const panel = this.$('gitStatusPanel');
|
||||
if (!panel) return;
|
||||
panel.classList.add('visible');
|
||||
this.$('gitStatusBtn')?.setAttribute('aria-expanded', 'true');
|
||||
this._ensureGitStatusPanelDrag();
|
||||
this._renderGitStatusPanel();
|
||||
this.refreshGitStatus({ fresh: true }); // the click should show what is true now, not what was true 14s ago
|
||||
},
|
||||
|
||||
closeGitStatusPanel() {
|
||||
this._gitDiffView = null;
|
||||
const panel = this.$('gitStatusPanel');
|
||||
if (panel) {
|
||||
panel.classList.remove('visible');
|
||||
// Reset a dragged position so it reopens at the default spot.
|
||||
panel.style.left = panel.style.top = panel.style.right = panel.style.bottom = '';
|
||||
}
|
||||
this.$('gitStatusBtn')?.setAttribute('aria-expanded', 'false');
|
||||
},
|
||||
|
||||
refreshGitStatusNow() {
|
||||
this._gitStatusFetchedAt = 0;
|
||||
this._gitStatusInFlight = false;
|
||||
return this.refreshGitStatus({ fresh: true });
|
||||
},
|
||||
|
||||
/** Drag by the header. Pointer events cover mouse, pen and touch; one set of listeners lives as long as the page. */
|
||||
_ensureGitStatusPanelDrag() {
|
||||
const panel = this.$('gitStatusPanel');
|
||||
const handle = panel?.querySelector('.git-status-header');
|
||||
if (!panel || !handle || handle._dragReady) return;
|
||||
handle._dragReady = true;
|
||||
let drag = null;
|
||||
handle.addEventListener('pointerdown', (e) => {
|
||||
if (e.target.closest('button')) return;
|
||||
const rect = panel.getBoundingClientRect();
|
||||
drag = { dx: e.clientX - rect.left, dy: e.clientY - rect.top };
|
||||
// Switch from right/bottom anchoring to explicit left/top so the drag has one coordinate system.
|
||||
panel.style.left = `${rect.left}px`;
|
||||
panel.style.top = `${rect.top}px`;
|
||||
panel.style.right = 'auto';
|
||||
panel.style.bottom = 'auto';
|
||||
handle.setPointerCapture?.(e.pointerId);
|
||||
e.preventDefault();
|
||||
});
|
||||
handle.addEventListener('pointermove', (e) => {
|
||||
if (!drag) return;
|
||||
const maxX = window.innerWidth - panel.offsetWidth - 4;
|
||||
const maxY = window.innerHeight - panel.offsetHeight - 4;
|
||||
panel.style.left = `${Math.max(4, Math.min(e.clientX - drag.dx, maxX))}px`;
|
||||
panel.style.top = `${Math.max(4, Math.min(e.clientY - drag.dy, maxY))}px`;
|
||||
});
|
||||
const end = (e) => {
|
||||
drag = null;
|
||||
handle.releasePointerCapture?.(e.pointerId);
|
||||
};
|
||||
handle.addEventListener('pointerup', end);
|
||||
handle.addEventListener('pointercancel', end);
|
||||
},
|
||||
|
||||
_gitEl(tag, className, text) {
|
||||
const el = document.createElement(tag);
|
||||
if (className) el.className = className;
|
||||
if (text !== undefined) el.textContent = text;
|
||||
return el;
|
||||
},
|
||||
|
||||
_renderGitStatusPanel() {
|
||||
const body = this.$('gitStatusBody');
|
||||
const head = this.$('gitStatusBranch');
|
||||
const foot = this.$('gitStatusFooter');
|
||||
if (!body) return;
|
||||
const overview = this._currentGitStatus();
|
||||
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||
// The 15 s poll replaces every row: put keyboard focus back on the same file afterwards.
|
||||
const focusKey = body.contains(document.activeElement)
|
||||
? document.activeElement.closest?.('[data-git-key]')?.dataset.gitKey
|
||||
: null;
|
||||
const view = this._gitDiffView;
|
||||
if (view && view.sessionId === this.activeSessionId) {
|
||||
// A file's diff is on screen: the 15 s poll re-renders the panel, and must not throw it away.
|
||||
this._renderGitDiffView(body, view);
|
||||
if (head) head.textContent = '';
|
||||
if (foot) foot.textContent = '';
|
||||
return;
|
||||
}
|
||||
this._gitDiffView = null;
|
||||
body.replaceChildren();
|
||||
const clearChrome = () => {
|
||||
if (head) head.textContent = '';
|
||||
if (foot) foot.textContent = '';
|
||||
};
|
||||
|
||||
if (!this.activeSessionId) {
|
||||
body.append(el('div', 'git-status-empty', 'Open a session to see its repository.'));
|
||||
clearChrome();
|
||||
return;
|
||||
}
|
||||
if (!overview) {
|
||||
body.append(
|
||||
el('div', 'git-status-empty', this._gitStatus === null ? 'Reading the repository…' : 'No status available.')
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (overview.state !== 'ok') {
|
||||
const why =
|
||||
overview.state === 'not-a-repo'
|
||||
? 'No git repository here: this session’s folder is not one, and none was found inside it (up to two levels down).'
|
||||
: overview.state === 'unsupported'
|
||||
? overview.reason === 'docker'
|
||||
? 'Git status is not available for Docker sessions, or for folders inside a Docker case workspace.'
|
||||
: 'Git status is not available for remote (SSH) sessions.'
|
||||
: `Could not read the repository: ${overview.error || 'git failed'}`;
|
||||
body.append(el('div', 'git-status-empty', why));
|
||||
clearChrome();
|
||||
return;
|
||||
}
|
||||
|
||||
const repos = overview.repos;
|
||||
if (repos.length === 1) {
|
||||
// One repository: the panel is that repository, as it always was.
|
||||
const d = repos[0].status;
|
||||
if (head) head.textContent = d.detached ? 'detached HEAD' : d.branch || '';
|
||||
this._renderGitRepoInto(body, d);
|
||||
} else {
|
||||
if (head) head.textContent = `${repos.length} repositories`;
|
||||
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.`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (foot) {
|
||||
foot.textContent = `Checked ${new Date(overview.checkedAt).toLocaleTimeString()}. Read-only: Codeman never fetches or changes the repository, so “behind” is as of your last fetch.`;
|
||||
}
|
||||
if (focusKey) {
|
||||
const again = [...body.querySelectorAll('[data-git-key]')].find((n) => n.dataset.gitKey === focusKey);
|
||||
again?.focus({ preventScroll: true });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* One repository of several: a collapsible section, collapsed by default (the summary line already
|
||||
* shows what is outstanding). Which ones the user opened stay open across the 15 s re-render.
|
||||
*/
|
||||
_gitRepoSection(r) {
|
||||
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||
const d = r.status;
|
||||
const section = el('details', 'git-status-repo');
|
||||
const outstanding = d.counts.uncommitted > 0 || d.unpushedCount > 0;
|
||||
const openRepos = (this._gitTreeOpen = this._gitTreeOpen || new Set());
|
||||
const repoKey = `repo|${d.repoRoot || r.path}`;
|
||||
section.open = openRepos.has(repoKey);
|
||||
section.addEventListener('toggle', () => (section.open ? openRepos.add(repoKey) : openRepos.delete(repoKey)));
|
||||
const summary = el('summary', 'git-status-repo-summary');
|
||||
summary.append(el('span', 'git-status-repo-name', r.name));
|
||||
if (r.path !== r.name) summary.append(el('span', 'git-status-repo-path', r.path));
|
||||
summary.append(el('span', 'git-status-repo-branch', d.detached ? 'detached HEAD' : d.branch || ''));
|
||||
const bits = [];
|
||||
if (d.counts.conflicted) bits.push(`⚠ ${d.counts.conflicted}`);
|
||||
if (d.counts.uncommitted) bits.push(`● ${d.counts.uncommitted}`);
|
||||
if (d.unpushedCount) bits.push(`↑ ${d.unpushedCount}`);
|
||||
const state = el(
|
||||
'span',
|
||||
`git-status-repo-state${outstanding ? ' git-status-repo-state--dirty' : ''}`,
|
||||
bits.join(' ') || '✓'
|
||||
);
|
||||
summary.append(state);
|
||||
section.append(summary);
|
||||
const inner = el('div', 'git-status-repo-body');
|
||||
this._renderGitRepoInto(inner, d);
|
||||
section.append(inner);
|
||||
return section;
|
||||
},
|
||||
|
||||
/** The branch line, uncommitted files and unpushed commits of ONE repository into `body`. */
|
||||
_renderGitRepoInto(body, data) {
|
||||
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||
|
||||
// Branch / upstream line.
|
||||
const line = el('div', 'git-status-branchline');
|
||||
if (data.upstream && data.upstreamGone) {
|
||||
line.append(el('span', 'git-status-chip', `${data.branch || 'HEAD'} → ${data.upstream}`));
|
||||
const gone = el('span', 'git-status-chip git-status-chip--warn', 'Upstream is gone');
|
||||
gone.title = 'The remote branch was deleted (and pruned), so the commits below are on no remote.';
|
||||
line.append(gone);
|
||||
} else if (data.upstream) {
|
||||
line.append(el('span', 'git-status-chip', `${data.branch || 'HEAD'} → ${data.upstream}`));
|
||||
if (data.ahead) line.append(el('span', 'git-status-chip git-status-chip--warn', `↑ ${data.ahead} ahead`));
|
||||
if (data.behind) {
|
||||
const behind = el('span', 'git-status-chip', `↓ ${data.behind} behind`);
|
||||
behind.title = 'As of the last git fetch: Codeman never fetches.';
|
||||
line.append(behind);
|
||||
}
|
||||
} else if (data.hasRemote) {
|
||||
line.append(el('span', 'git-status-chip git-status-chip--warn', 'No upstream branch'));
|
||||
} else {
|
||||
line.append(el('span', 'git-status-chip', 'No remote configured'));
|
||||
}
|
||||
if (data.counts.stashes) {
|
||||
line.append(
|
||||
el('span', 'git-status-chip', `${data.counts.stashes} stash${data.counts.stashes === 1 ? '' : 'es'}`)
|
||||
);
|
||||
}
|
||||
body.append(line);
|
||||
|
||||
// Uncommitted changes.
|
||||
const filesSection = el('section', 'git-status-section');
|
||||
filesSection.append(el('h4', 'git-status-section-title', `Uncommitted changes (${data.counts.uncommitted})`));
|
||||
if (!data.files.length) {
|
||||
filesSection.append(el('div', 'git-status-ok', 'Nothing uncommitted.'));
|
||||
} else {
|
||||
const groups = [
|
||||
['conflicted', 'Merge conflicts'],
|
||||
['staged', 'Staged'],
|
||||
['unstaged', 'Not staged'],
|
||||
['untracked', 'Untracked'],
|
||||
];
|
||||
for (const [kind, label] of groups) {
|
||||
const rows = data.files.filter((f) => f.kind === kind);
|
||||
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]})`));
|
||||
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) {
|
||||
filesSection.append(
|
||||
el(
|
||||
'div',
|
||||
'git-status-more',
|
||||
`Showing the first ${data.files.length} entries; the counts above include every file.`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
body.append(filesSection);
|
||||
|
||||
// Commits not pushed.
|
||||
const pushSection = el('section', 'git-status-section');
|
||||
pushSection.append(el('h4', 'git-status-section-title', `Not pushed (${data.unpushedCount})`));
|
||||
if (!data.unpushedCount) {
|
||||
pushSection.append(
|
||||
el(
|
||||
'div',
|
||||
'git-status-ok',
|
||||
data.hasRemote ? 'Every commit on this branch is on a remote.' : 'There is no remote to push to.'
|
||||
)
|
||||
);
|
||||
} else {
|
||||
if (!data.upstream || data.upstreamGone) {
|
||||
pushSection.append(
|
||||
el(
|
||||
'div',
|
||||
'git-status-note',
|
||||
data.upstreamGone
|
||||
? 'The upstream branch is gone from the remote, so these commits are on no remote.'
|
||||
: 'This branch has no upstream, so these commits are on no remote yet.'
|
||||
)
|
||||
);
|
||||
}
|
||||
for (const c of data.unpushed) pushSection.append(this._gitCommitRow(c));
|
||||
if (data.unpushedCount > data.unpushed.length) {
|
||||
pushSection.append(
|
||||
el('div', 'git-status-more', `…and ${data.unpushedCount - data.unpushed.length} older commits.`)
|
||||
);
|
||||
}
|
||||
}
|
||||
body.append(pushSection);
|
||||
},
|
||||
|
||||
/**
|
||||
* `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.
|
||||
const letter =
|
||||
f.kind === 'untracked' ? '?' : f.kind === 'conflicted' ? 'U' : f.kind === 'staged' ? f.index : f.worktree;
|
||||
const badge = el('span', `git-status-badge git-status-badge--${letter === '?' ? 'new' : letter}`, letter);
|
||||
badge.title = GIT_STATUS_BADGE_TITLE[letter] || letter;
|
||||
row.dataset.gitKey = `${f.kind}|${f.path}`;
|
||||
row.append(badge);
|
||||
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}`));
|
||||
|
||||
// An untracked folder has no single diff; every other row opens its changes.
|
||||
if (!f.path.endsWith('/') && data.repoRoot) {
|
||||
row.classList.add('git-status-file--clickable');
|
||||
row.tabIndex = 0;
|
||||
row.setAttribute('role', 'button');
|
||||
row.title = 'Show what changed';
|
||||
const open = () => this.openGitDiff(data.repoRoot, f, letter);
|
||||
row.addEventListener('click', open);
|
||||
row.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
open();
|
||||
}
|
||||
});
|
||||
}
|
||||
return row;
|
||||
},
|
||||
|
||||
// ── Diff view ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Show `file`'s changes in the panel (a Back button returns to the list). */
|
||||
async openGitDiff(repoRoot, file, letter) {
|
||||
const sessionId = this.activeSessionId;
|
||||
if (!sessionId) return;
|
||||
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 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;
|
||||
let body = null;
|
||||
try {
|
||||
body = res ? await res.json() : null;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
if (this._gitDiffView !== view) return;
|
||||
if (res && res.ok && body?.success) {
|
||||
view.state = 'ok';
|
||||
view.result = body.data;
|
||||
} else {
|
||||
view.state = 'error';
|
||||
view.error = body?.error || 'Could not read the diff.';
|
||||
}
|
||||
this._renderGitStatusPanel();
|
||||
},
|
||||
|
||||
closeGitDiff() {
|
||||
this._gitDiffView = null;
|
||||
this._renderGitStatusPanel();
|
||||
},
|
||||
|
||||
_renderGitDiffView(body, view) {
|
||||
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||
body.replaceChildren();
|
||||
const bar = el('div', 'git-diff-bar');
|
||||
const back = el('button', 'btn-toolbar btn-sm', '← Back');
|
||||
back.type = 'button';
|
||||
back.addEventListener('click', () => this.closeGitDiff());
|
||||
bar.append(back);
|
||||
bar.append(el('span', 'git-diff-path', view.file.path));
|
||||
const kindLabel = { staged: 'staged', unstaged: 'not staged', untracked: 'new file', conflicted: 'conflict' };
|
||||
bar.append(el('span', 'git-diff-kind', kindLabel[view.file.kind] || ''));
|
||||
if (view.letter !== 'D') {
|
||||
const open = el('button', 'btn-toolbar btn-sm', 'Open file');
|
||||
open.type = 'button';
|
||||
open.addEventListener('click', () =>
|
||||
this.openFilePreview?.(`${view.repoRoot}/${view.file.path}`, this.activeSessionId)
|
||||
);
|
||||
bar.append(open);
|
||||
}
|
||||
body.append(bar);
|
||||
|
||||
if (view.state === 'loading') {
|
||||
body.append(el('div', 'git-status-empty', 'Reading the diff…'));
|
||||
return;
|
||||
}
|
||||
if (view.state === 'error') {
|
||||
body.append(el('div', 'git-status-empty', view.error));
|
||||
return;
|
||||
}
|
||||
const { diff, truncated, binary } = view.result;
|
||||
if (binary) body.append(el('div', 'git-status-note', 'This is a binary file; there is no text diff to show.'));
|
||||
if (!diff.trim()) {
|
||||
if (!binary) body.append(el('div', 'git-status-empty', 'No textual changes (the file may differ only in mode).'));
|
||||
return;
|
||||
}
|
||||
const pre = el('pre', 'git-diff');
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const line of diff.split('\n')) {
|
||||
let cls = 'git-diff-line';
|
||||
if (line.startsWith('@@')) cls += ' git-diff-line--hunk';
|
||||
else if (
|
||||
/^(diff --git|index |--- |\+\+\+ |new file|deleted file|similarity|rename |old mode|new mode)/.test(line)
|
||||
)
|
||||
cls += ' git-diff-line--meta';
|
||||
else if (line.startsWith('+')) cls += ' git-diff-line--add';
|
||||
else if (line.startsWith('-')) cls += ' git-diff-line--del';
|
||||
frag.append(el('span', cls, line + '\n'));
|
||||
}
|
||||
pre.append(frag);
|
||||
body.append(pre);
|
||||
if (truncated) body.append(el('div', 'git-status-more', 'Diff cut short: it is larger than the viewer shows.'));
|
||||
},
|
||||
|
||||
_gitCommitRow(c) {
|
||||
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||
const row = el('div', 'git-status-commit');
|
||||
row.append(el('span', 'git-status-hash', c.hash));
|
||||
row.append(el('span', 'git-status-subject', c.subject));
|
||||
const meta = c.time ? `${c.author} · ${this.formatRelativeTime?.(c.time * 1000) ?? ''}` : c.author;
|
||||
row.append(el('span', 'git-status-commit-meta', meta));
|
||||
return row;
|
||||
},
|
||||
});
|
||||
@@ -567,6 +567,19 @@
|
||||
<div class="file-browser-status" id="fileBrowserStatus"></div>
|
||||
</div>
|
||||
|
||||
<!-- Git status panel (git-status-ui.js): what the active session's repo has not committed or pushed. -->
|
||||
<div class="git-status-panel" id="gitStatusPanel" role="dialog" aria-label="Git status">
|
||||
<div class="git-status-header">
|
||||
<span class="git-status-title">Git <span class="git-status-branch" id="gitStatusBranch" data-i18n-skip></span></span>
|
||||
<div class="git-status-actions">
|
||||
<button class="btn-icon-sm" onclick="app.refreshGitStatusNow()" title="Refresh" aria-label="Refresh git status">↻</button>
|
||||
<button class="btn-icon-sm" onclick="app.closeGitStatusPanel()" title="Close" aria-label="Close git status">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="git-status-body" id="gitStatusBody" data-i18n-skip></div>
|
||||
<div class="git-status-footer" id="gitStatusFooter" data-i18n-skip></div>
|
||||
</div>
|
||||
|
||||
<!-- File Preview Overlay -->
|
||||
<div class="file-preview-overlay" id="filePreviewOverlay">
|
||||
<div class="file-preview-window">
|
||||
@@ -771,6 +784,13 @@
|
||||
<!-- Orchestrator button hidden until feature is ready -->
|
||||
<!-- <button class="btn-toolbar btn-sm" onclick="app.toggleOrchestratorPanel()" title="Orchestrator Loop">⚙ Orchestrator</button> -->
|
||||
<button class="btn-toolbar btn-sm btn-cron btn-cron--hidden" onclick="app.openCron()" title="Cron Jobs">⏰ Cron</button>
|
||||
<!-- Git status of the active session's repository (git-status-ui.js). Optional and per-device
|
||||
(App Settings → Header & Panels → Bottom bar, default OFF), so it is hidden until that
|
||||
setting is on AND the session is a local git repository. -->
|
||||
<button type="button" class="btn-toolbar btn-sm btn-git-status" id="gitStatusBtn" hidden aria-expanded="false" aria-controls="gitStatusPanel" onclick="app.toggleGitStatusPanel()">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 4-6 3-11 6"/></svg>
|
||||
<span class="git-status-label" data-i18n-skip></span>
|
||||
</button>
|
||||
<span class="version-display" id="versionDisplay" title="Codeman version">v0.0.0</span>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -1935,6 +1955,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Bottom bar</h4></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="git status uncommitted unpushed commit push indicator bottom bar toolbar">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Git status <span class="set-scope">device</span></span>
|
||||
<span class="set-row-desc">Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only: Codeman never fetches or changes the repository. Not shown for Docker or remote sessions. Off by default.</span>
|
||||
</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>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Subagent windows</h4></div>
|
||||
<div class="set-group-body">
|
||||
@@ -3896,6 +3936,7 @@
|
||||
<script defer src="webview-tabs.js"></script>
|
||||
<script defer src="mobile-overview.js"></script>
|
||||
<script defer src="home-sessions.js"></script>
|
||||
<script defer src="git-status-ui.js"></script>
|
||||
<script defer src="entrance-animations.js"></script>
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
|
||||
@@ -451,6 +451,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsShowSessionButton').checked = settings.showSessionButton ?? defaults.showSessionButton ?? false;
|
||||
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
|
||||
@@ -2496,6 +2498,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
|
||||
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,
|
||||
@@ -2748,6 +2752,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
showSessionButton: _ssb,
|
||||
showAwayDigestButton: _adb,
|
||||
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,
|
||||
@@ -3687,6 +3694,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
cronBtn.classList.toggle('btn-cron--hidden', !showCronButton);
|
||||
}
|
||||
|
||||
// Bottom-bar Git indicator (git-status-ui.js): opt-in, per-device. Starts or stops its poll to
|
||||
// match the setting, so a live toggle needs no reload.
|
||||
this.applyGitStatusVisibility?.();
|
||||
|
||||
// Notification bell is retired (notifications live in Settings → Notifications
|
||||
// + the drawer); keep it hidden regardless of the notification-enabled state.
|
||||
const notifBtn = document.querySelector('.btn-notifications');
|
||||
@@ -4035,7 +4046,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'language',
|
||||
'terminalWheelLocalScrollback',
|
||||
'autoCopySelection', 'copyStripMargin',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree',
|
||||
'showTabDetachButton',
|
||||
'mobileOverviewEnabled',
|
||||
'sessionLineageLines',
|
||||
|
||||
@@ -19390,3 +19390,401 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
|
||||
/* ── Git status: bottom-bar indicator + panel (git-status-ui.js) ─────────────── */
|
||||
|
||||
/* .btn-toolbar sets display:flex, which beats the [hidden] attribute's UA display:none. */
|
||||
.btn-git-status[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn-git-status {
|
||||
gap: 0.4rem;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.68rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-git-status .git-status-label {
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* The skin block in styles.css nests every .btn-toolbar rule under html:not([data-skin="og"]), which
|
||||
out-ranks a bare .btn-git-status.<tone> rule, so the tones carry the same `html .toolbar` prefix
|
||||
(specificity (0,3,1) against the skin's (0,2,1)). Without it the amber never shows. */
|
||||
html .toolbar .btn-git-status.git-status--clean {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
html .toolbar .btn-git-status.git-status--clean svg {
|
||||
color: rgb(34, 197, 94);
|
||||
}
|
||||
|
||||
html .toolbar .btn-git-status.git-status--dirty {
|
||||
color: #e0a030;
|
||||
border-color: rgba(224, 160, 48, 0.4);
|
||||
}
|
||||
|
||||
html .toolbar .btn-git-status.git-status--conflict {
|
||||
color: #e5534b;
|
||||
border-color: rgba(229, 83, 75, 0.5);
|
||||
}
|
||||
|
||||
html .toolbar .btn-git-status[aria-expanded='true'] {
|
||||
background: color-mix(in srgb, currentColor 16%, transparent);
|
||||
}
|
||||
|
||||
/* Same look as the Files window. Default spot is just left of it so both can be open. */
|
||||
.git-status-panel {
|
||||
position: fixed;
|
||||
top: calc(var(--header-height) + 10px);
|
||||
/* Just left of the Files window; on a narrow viewport slide right so the left edge never leaves the screen. */
|
||||
right: clamp(8px, calc(100vw - 388px), 320px);
|
||||
width: 380px;
|
||||
max-width: calc(100vw - 16px);
|
||||
height: calc(100vh - var(--header-height) - var(--toolbar-height) - 40px);
|
||||
height: calc(100dvh - var(--header-height) - var(--toolbar-height) - 40px);
|
||||
max-height: 600px;
|
||||
min-width: 260px;
|
||||
min-height: 220px;
|
||||
background: var(--floating-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
z-index: 100;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
}
|
||||
|
||||
.git-status-panel.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.git-status-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--bg-input);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.git-status-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.git-status-branch {
|
||||
margin-left: 0.4rem;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.git-status-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.git-status-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0.6rem;
|
||||
font-size: 0.74rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.git-status-footer {
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.64rem;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.git-status-empty,
|
||||
.git-status-ok,
|
||||
.git-status-note,
|
||||
.git-status-more {
|
||||
color: var(--text-muted);
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.git-status-ok {
|
||||
color: rgb(34, 197, 94);
|
||||
}
|
||||
|
||||
.git-status-branchline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.git-status-chip {
|
||||
padding: 0.1rem 0.45rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.66rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.git-status-chip--warn {
|
||||
color: #e0a030;
|
||||
border-color: rgba(224, 160, 48, 0.4);
|
||||
}
|
||||
|
||||
.git-status-section {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.git-status-section-title {
|
||||
margin: 0 0 0.3rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.git-status-group {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.git-status-group-title {
|
||||
font-size: 0.64rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
|
||||
.git-status-file,
|
||||
.git-status-commit {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.45rem;
|
||||
padding: 0.15rem 0.25rem;
|
||||
border-radius: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.git-status-file--clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.git-status-file--clickable:hover,
|
||||
.git-status-file--clickable:focus-visible {
|
||||
background: var(--bg-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.git-status-badge {
|
||||
flex: 0 0 1.1rem;
|
||||
text-align: center;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
color: #e0a030;
|
||||
}
|
||||
|
||||
.git-status-badge--A,
|
||||
.git-status-badge--new {
|
||||
color: rgb(34, 197, 94);
|
||||
}
|
||||
|
||||
.git-status-badge--D,
|
||||
.git-status-badge--U {
|
||||
color: #e5534b;
|
||||
}
|
||||
|
||||
.git-status-path,
|
||||
.git-status-subject {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.git-status-subject {
|
||||
font-family: inherit;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.git-status-orig,
|
||||
.git-status-commit-meta {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.64rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.git-status-hash {
|
||||
flex: 0 0 auto;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.66rem;
|
||||
color: #e0a030;
|
||||
}
|
||||
|
||||
/* One section per repository when a session's folder holds several (git-status-ui.js). */
|
||||
.git-status-repo {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.git-status-repo-summary {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
cursor: pointer;
|
||||
background: var(--bg-input);
|
||||
list-style: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.git-status-repo-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.git-status-repo-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.74rem;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.git-status-repo-path,
|
||||
.git-status-repo-branch {
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.64rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.git-status-repo-state {
|
||||
margin-left: auto;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.git-status-repo-state--dirty {
|
||||
color: #e0a030;
|
||||
}
|
||||
|
||||
.git-status-repo-body {
|
||||
padding: 0.5rem 0.6rem;
|
||||
}
|
||||
|
||||
/* Git status panel: per-file diff view */
|
||||
.git-diff-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.git-diff-path {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.git-diff-kind {
|
||||
font-size: 0.64rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.git-diff {
|
||||
margin: 0;
|
||||
padding: 0.3rem 0;
|
||||
overflow-x: auto;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.45;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.git-diff-line {
|
||||
display: block;
|
||||
padding: 0 0.5rem;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.git-diff-line--add {
|
||||
background: color-mix(in srgb, var(--green) 18%, transparent);
|
||||
}
|
||||
|
||||
.git-diff-line--del {
|
||||
background: color-mix(in srgb, var(--red) 18%, transparent);
|
||||
}
|
||||
|
||||
.git-diff-line--hunk {
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @fileoverview `GET /api/sessions/:id/git-status`: what the session's workspace has not committed or
|
||||
* pushed (src/git-workspace-status.ts), for the bottom-bar Git indicator and its panel. The answer is
|
||||
* an overview: the enclosing repository, or each repository found below a folder that holds several
|
||||
* projects (see `getGitWorkspaceOverview` for exactly which).
|
||||
*
|
||||
* Read-only and offline: it never fetches and never runs a git write command. A remote (SSH) or
|
||||
* Docker session is not inspected and answers `state: 'unsupported'`, and neither is any repository at or
|
||||
* inside a Docker case workspace (a container can write there, and git here would run on the host). Ownership goes through
|
||||
* `findSessionOrFail`, like every session-scoped route.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
|
||||
import { redactGitCredentials } from '../../git-clone.js';
|
||||
import { readDockerCases } from '../../docker-hosts.js';
|
||||
import { getDataDir } from '../../config/instance.js';
|
||||
import { findSessionOrFail } from '../route-helpers.js';
|
||||
import {
|
||||
emptyOverview,
|
||||
getGitFileDiff,
|
||||
getGitWorkspaceOverview,
|
||||
type GitFileDiff,
|
||||
type GitFileKind,
|
||||
type GitRunner,
|
||||
type GitWorkspaceOverview,
|
||||
} from '../../git-workspace-status.js';
|
||||
import type { SessionPort } from '../ports/index.js';
|
||||
|
||||
/** Host paths of every Docker case workspace: repositories at or inside these are never inspected. */
|
||||
const defaultDockerWorkspaces = async (): Promise<string[]> =>
|
||||
(await readDockerCases(getDataDir()).catch(() => [])).map((c) => c.hostWorkspacePath).filter(Boolean);
|
||||
|
||||
export function registerGitStatusRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort,
|
||||
git?: GitRunner,
|
||||
dockerWorkspaces: () => Promise<string[]> = defaultDockerWorkspaces
|
||||
): 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 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' }) };
|
||||
return {
|
||||
success: true,
|
||||
data: await getGitWorkspaceOverview(session.workingDir, {
|
||||
git,
|
||||
fresh: fresh === '1',
|
||||
dockerWorkspaces: await dockerWorkspaces(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// The diff of one file the panel lists. `repo` and `path` are matched against the CURRENT status
|
||||
// (a repository this session's folder holds, a path git reported in it) rather than trusted, so
|
||||
// the route cannot be pointed at an arbitrary directory or file.
|
||||
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 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 overview = await getGitWorkspaceOverview(session.workingDir, {
|
||||
git,
|
||||
fresh: true,
|
||||
dockerWorkspaces: await dockerWorkspaces(),
|
||||
});
|
||||
const status = overview.repos.find((r) => r.status.repoRoot === repo)?.status;
|
||||
const entry = status?.files.find((f) => f.path === path && f.kind === (kind as GitFileKind));
|
||||
if (!status?.repoRoot || !entry) {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'That file has no outstanding change any more');
|
||||
}
|
||||
try {
|
||||
return { success: true, data: await getGitFileDiff(status.repoRoot, entry, { git }) };
|
||||
} catch (err) {
|
||||
reply.code(500);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INTERNAL_ERROR,
|
||||
`git diff failed: ${redactGitCredentials(getErrorMessage(err))}`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export { registerHookEventRoutes } from './hook-event-routes.js';
|
||||
export { registerApprovalRoutes } from './approval-routes.js';
|
||||
export { registerRebootRestoreRoutes } from './reboot-restore-routes.js';
|
||||
export { registerReadMyMindRoutes } from './readmymind-routes.js';
|
||||
export { registerGitStatusRoutes } from './git-status-routes.js';
|
||||
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
|
||||
export { registerCaseRoutes } from './case-routes.js';
|
||||
export { registerSessionRoutes } from './session-routes.js';
|
||||
|
||||
@@ -183,6 +183,7 @@ import {
|
||||
registerApprovalRoutes,
|
||||
registerRebootRestoreRoutes,
|
||||
registerReadMyMindRoutes,
|
||||
registerGitStatusRoutes,
|
||||
registerStatusTelemetryRoutes,
|
||||
registerSystemRoutes,
|
||||
registerCaseRoutes,
|
||||
@@ -1122,6 +1123,7 @@ export class WebServer extends EventEmitter {
|
||||
registerApprovalRoutes(this.app, ctx);
|
||||
registerRebootRestoreRoutes(this.app, ctx);
|
||||
registerReadMyMindRoutes(this.app, ctx);
|
||||
registerGitStatusRoutes(this.app, ctx);
|
||||
registerStatusTelemetryRoutes(this.app, ctx);
|
||||
registerSystemRoutes(this.app, ctx);
|
||||
registerCaseRoutes(this.app, ctx);
|
||||
|
||||
Reference in New Issue
Block a user