Official
git
Built-in git UI (JavaScript UI plugin).
The app opens the listing; nothing installs until an agent in your Plugins workspace has read the files and you enable the plugin. In a terminal: cvg install convergence/git@0.2.0
Permissions in 0.2.0
Files
views/panel.ts5.1 KB
// Source-control rail and panel; rendering never starts a host request.
// HTM whitespace is significant; keep these templates out of Prettier's HTML formatter.
import { html as htm } from "convergence";
import type { Element } from "convergence";
import type { EffectViewContext } from "convergence/effect";
import type { Git } from "../controller.ts";
import { groups, isOpen } from "../status.ts";
import { controls } from "./controls.ts";
import { entriesView } from "./entries.ts";
import { column, row, fill, full, INSET } from "./layout.ts";
export function renderRail(ctx: EffectViewContext<object>, actions: Git["Service"]) {
const { state } = actions;
const count = state.status?.entries?.length ?? 0;
const pinned = ctx.app.shared?.rightPanel === "git";
return htm`<button id="rail-git" icon="git-branch" variant="ghost" active=${pinned} badge=${count ? String(count) : undefined}
tooltip=${count ? `Source Control · ${count} changed` : "Source Control"} onClick=${() => ctx.run(actions.toggle)} />`;
}
// The sections that have files, with a line between two of them so each
// group's end is plain.
function divided(sections: readonly (Element | null)[]) {
return sections.filter((s) => s !== null).map((s, i) => [i ? htm`<divider />` : null, s]);
}
export function renderPanel(ctx: EffectViewContext<object>, actions: Git["Service"]) {
const { state } = actions;
const { primaryButton, branchPicker, menu } = controls(ctx, actions);
const { action, section, confirmed } = entriesView(ctx, actions);
if (!isOpen(ctx.app)) return null;
const workspaceId = ctx.app.workspaceId;
if (!workspaceId)
return htm`<div ...${column} gap="small" padding="medium"><text style="muted">Select a workspace to see its changes.</text></div>`;
const status = state.status;
const g = status?.isRepo ? groups(status) : { merge: [], staged: [], changes: [] };
const total = g.merge.length + g.staged.length + g.changes.length;
const header = htm`
<div ...${row} gap="small" padding="small" paddingY="small">
<div ...${row} ...${fill} gap="small" padding="small" paddingX=${INSET}>
<text style="body" size=${13.125} weight=${600} truncate>Source Control</text>
</div>
${state.busy ? htm`<div ...${row} gap="small" padding="small"><spinner /><text style="body" size="xs" tone="muted">${state.busy}</text></div>` : null}
${
status?.isRepo
? htm`<button id="git-header-commit" icon="check" variant="ghost" small tooltip="Commit"
disabled=${!!state.busy || !total || !state.message.trim()} onClick=${() => ctx.run(actions.commit())} />`
: null
}
<button id="git-refresh" icon="rotate-cw" variant="ghost" small tooltip="Refresh" onClick=${() => ctx.run(actions.refresh())} />
${status?.isRepo ? menu(status, g) : null}
<button id="git-close" icon="x" variant="ghost" small tooltip="Close" onClick=${() => ctx.run(actions.close)} />
</div>`;
const body = (...children: (Element | null)[]) =>
htm`<div ...${column} ...${fill} ...${full} gap="small" padding="small" paddingX="small">${header}${children}</div>`;
if (state.error) return body(htm`<text style="body" size="xs" tone="danger">${state.error}</text>`);
if (!status) return body(htm`<text style="muted">Loading…</text>`);
if (!status.isRepo) return body(htm`<text style="muted">This workspace is not in a git repository.</text>`);
const branch = status.branch ? `"${status.branch}"` : "this branch";
return body(
branchPicker(status),
htm`
<div ...${column} gap="small" padding="small">
<input id="git-message" value=${state.message} placeholder=${`Message (⏎ to commit on ${branch})`}
onChange=${(e: { value: string }) => {
state.message = e.value;
ctx.update();
}}
onSubmit=${() => ctx.run(actions.commit())} />
${primaryButton(status, total)}
</div>`,
htm`
<div ...${column} ...${fill} gap="small" padding="small" paddingY="small" overflowY="scroll">
${divided([section("merge", "Merge Changes", g.merge, [
action("git-merge-stage-all", "plus", "Stage All Merge Changes", () => ctx.run(actions.stage(g.merge))),
]),
section("staged", "Staged Changes", g.staged, [
action("git-staged-unstage-all", "minus", "Unstage All Changes", () => ctx.run(actions.unstage(g.staged))),
]),
section("changes", "Changes", g.changes, [
action(
"git-changes-discard-all",
"undo-2",
state.confirm === "discard:all" ? "Click again to discard all changes" : "Discard All Changes",
() => confirmed("discard:all", actions.discard(g.changes)),
state.confirm === "discard:all",
),
action("git-changes-stage-all", "plus", "Stage All Changes", () => ctx.run(actions.stage(g.changes))),
])])}
${total ? null : htm`<div ...${column} gap="small" padding="small" paddingX=${INSET} paddingY="medium"><text style="muted">No changes. The working tree is clean.</text></div>`}
</div>`,
);
}Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 13.4 KB | 4 permissions | Listed |
No comments yet.