mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
fix(git-status): address #537 review (docker workspaces, gone upstream, in-flight reset, docs)
- never inspect a repository at or inside a Docker case workspace (walk-up, scan, diff route): git would run its clean filters on the host - a branch whose upstream was deleted and pruned reports upstreamGone and falls back to commits on no remote, instead of green - turning the setting off during a poll releases the in-flight flag - log.showSignature=false; reword the docs: clean filters still run - CLAUDE.md frontend load order, changeset names git-diff - discovery reads a bounded, sorted directory listing; leading-dash paths allowed; diff 500 redacts credentials - keyboard focus survives the poll re-render; panel stays on screen on narrow viewports; aria-expanded visible on light skins 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
b2423c90ce
commit
0c4bb5169f
@@ -75,6 +75,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
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();
|
||||
@@ -285,6 +288,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
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.
|
||||
@@ -316,7 +323,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
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'
|
||||
? `Git status is not available for ${overview.reason === 'docker' ? 'Docker' : 'remote (SSH)'} sessions.`
|
||||
? 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();
|
||||
@@ -342,6 +351,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
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 });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -384,7 +397,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Branch / upstream line.
|
||||
const line = el('div', 'git-status-branchline');
|
||||
if (data.upstream) {
|
||||
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) {
|
||||
@@ -449,9 +467,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
)
|
||||
);
|
||||
} else {
|
||||
if (!data.upstream) {
|
||||
if (!data.upstream || data.upstreamGone) {
|
||||
pushSection.append(
|
||||
el('div', 'git-status-note', 'This branch has no upstream, so these commits are on no remote yet.')
|
||||
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));
|
||||
@@ -523,6 +547,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
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;
|
||||
|
||||
@@ -19316,16 +19316,18 @@ html .toolbar .btn-git-status.git-status--conflict {
|
||||
border-color: rgba(229, 83, 75, 0.5);
|
||||
}
|
||||
|
||||
.btn-git-status[aria-expanded='true'] {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
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);
|
||||
right: 320px;
|
||||
/* 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;
|
||||
|
||||
@@ -5,13 +5,16 @@
|
||||
* 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'`: a Docker workspace is writable
|
||||
* from inside the sandbox, and git here would run on the host. Ownership goes through
|
||||
* 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,
|
||||
@@ -24,14 +27,30 @@ import {
|
||||
} from '../../git-workspace-status.js';
|
||||
import type { SessionPort } from '../ports/index.js';
|
||||
|
||||
export function registerGitStatusRoutes(app: FastifyInstance, ctx: SessionPort, git?: GitRunner): void {
|
||||
/** 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' }) };
|
||||
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
|
||||
@@ -45,7 +64,11 @@ export function registerGitStatusRoutes(app: FastifyInstance, ctx: SessionPort,
|
||||
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 });
|
||||
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) {
|
||||
@@ -56,7 +79,10 @@ export function registerGitStatusRoutes(app: FastifyInstance, ctx: SessionPort,
|
||||
return { success: true, data: await getGitFileDiff(status.repoRoot, entry, { git }) };
|
||||
} catch (err) {
|
||||
reply.code(500);
|
||||
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, `git diff failed: ${getErrorMessage(err)}`);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INTERNAL_ERROR,
|
||||
`git diff failed: ${redactGitCredentials(getErrorMessage(err))}`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user