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
tabs.test.ts8.7 KB
// Git opens diffs and files through the convergence/tabs service. Real
// activation with a canned tabs client: opening calls the service, and
// without a provider the repository actions still run.
import assert from "node:assert/strict";
import { test } from "node:test";
import * as z from "zod";
import type { Api, AppState, AppStatePatch, HostEvent, RenderResult, SlotOptions, ViewContext } from "convergence";
import { ui } from "convergence";
import { testLayer } from "convergence/effect/testing";
import { activate } from "./main.ts";
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
function fakeApi(withTabs: boolean) {
let state: AppState = { workspaceId: "w1", shared: { rightPanel: "git" } };
const appListeners = new Set<(state: AppState) => void>();
const views: { render: (ctx: ViewContext<object>) => RenderResult; active: boolean }[] = [];
const listeners = new Map<string, Set<(event: HostEvent) => unknown>>();
const calls: { method: string; params: unknown }[] = [];
const tabsCalls: { method: string; params: unknown }[] = [];
let dirty = true;
const status = {
branch: "main",
upstream: null,
ahead: 0,
behind: 0,
isRepo: true,
entries: [{ path: "src/a.ts", staged: " ", unstaged: "M" }],
};
const update = (patch: AppStatePatch) => {
state = {
...state,
...patch,
workspaceId: patch.workspaceId === null ? undefined : (patch.workspaceId ?? state.workspaceId),
chatId: patch.chatId === null ? undefined : (patch.chatId ?? state.chatId),
route: patch.route ?? state.route,
shared: { ...state.shared, ...patch.shared },
};
dirty = true;
for (const listener of appListeners) listener(state);
};
const call = async (method: string, params: unknown) => {
calls.push({ method, params });
switch (method) {
case "host/git.watch":
return {};
case "host/git.status":
return status;
case "host/git.diff":
return { diff: "--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1 @@\n-old\n+new" };
case "host/git.run":
return { stdout: "", stderr: "", code: 0 };
default:
throw new Error(`Unexpected host method ${method}`);
}
};
const tabsClient = {
open: Object.assign(async (params: unknown) => ({ id: "tab" }), {
// The Effect adapter calls through `result`, never the direct call.
result: async (params: unknown) => {
tabsCalls.push({ method: "open", params });
return { ok: true as const, value: { id: "tab" } };
},
}),
close: Object.assign(async () => null, {
result: async (params: unknown) => {
tabsCalls.push({ method: "close", params });
return { ok: true as const, value: null };
},
}),
active: Object.assign(async () => null, {
result: async () => ({ ok: true as const, value: null }),
}),
on: () => () => {},
};
const broker = testLayer();
// This assertion describes a local fake; unused API methods fail rather than impersonating external replies.
const api = new Proxy(
{
...broker.api,
plugin: "git",
id: "convergence/git",
ui,
services: {
provide: async () => ({ emit: () => {}, remove: () => {} }),
get: (name: string) => {
if (name === "convergence/tabs" && withTabs) return tabsClient;
throw new Error(`no provider for ${name}`);
},
available: (name: string) => name === "convergence/tabs" && withTabs,
when: (name: string, fn: (client: unknown) => void) => {
fn(name === "convergence/tabs" && withTabs ? tabsClient : undefined);
return () => {};
},
},
host: {
...broker.api.host,
call,
kernel: call,
git: Object.fromEntries(
["status", "watch", "run", "diff"].map((name) => [
name,
(params: unknown) => call(`host/git.${name}`, params),
]),
),
},
app: {
state: () => state,
update,
share: (key: string, value: unknown) => update({ shared: { [key]: value } }),
onChange: (fn: (state: AppState) => void) => {
appListeners.add(fn);
return () => {
appListeners.delete(fn);
};
},
},
slot: (slot: string, render: (ctx: ViewContext<object>) => RenderResult, _options?: SlotOptions) => {
const view = { render, active: true };
views.push(view);
dirty = true;
return {
id: views.length,
update: () => {
dirty = true;
},
remove: () => {
view.active = false;
},
};
},
on: (name: string, fn: (event: HostEvent) => unknown) => {
const subscribers = listeners.get(name) ?? new Set();
listeners.set(name, subscribers);
subscribers.add(fn);
return () => {
subscribers.delete(fn);
};
},
command: () => ({ remove() {} }),
notify: () => {},
focus: () => {},
},
{
get(target, key, receiver) {
if (Reflect.has(target, key)) return Reflect.get(target, key, receiver);
throw new Error(`Unexpected API ${String(key)}`);
},
},
) as unknown as Api;
const render = () =>
views
.filter((view) => view.active)
.map((view) =>
view.render({
state: {},
app: state,
plugin: "git",
update: () => {
dirty = true;
},
}),
);
let trees: RenderResult[] = [];
const settle = async () => {
for (let round = 0; round < 50; round++) {
await tick();
if (!dirty) {
await tick();
if (!dirty) return;
}
dirty = false;
trees = render();
}
throw new Error("git did not settle");
};
const nodeSchema = z.object({ id: z.string().optional(), children: z.array(z.unknown()).optional() }).passthrough();
function found(tree: unknown): Record<string, unknown>[] {
if (Array.isArray(tree)) return tree.flatMap(found);
const parsed = nodeSchema.safeParse(tree);
if (!parsed.success) return [];
return [
parsed.data,
...(parsed.data.children ?? []).flatMap(found),
...(Array.isArray(parsed.data.items) ? parsed.data.items.flatMap(found) : []),
];
}
const invoke = (id: string, event = "onClick", payload: unknown = {}) => {
const node = trees.flatMap((view) => found(view)).find((node) => node.id === id);
assert.ok(node, `Missing ${id}`);
const handler = z.custom<(payload: unknown) => unknown>((value) => typeof value === "function").parse(node[event]);
handler(payload);
};
// A file's hover button in a section's tree, by its icon.
const action = (side: string, path: string, icon: string) => {
const tree = trees.flatMap((view) => found(view)).find((node) => node.id === `git-tree-${side}`);
assert.ok(tree, `Missing git-tree-${side}`);
const file = z
.array(z.object({ path: z.string(), actions: z.array(z.object({ icon: z.string(), onClick: z.function() })) }))
.parse(tree["files"])
.find((f) => f.path === path);
const button = file?.actions.find((a) => a.icon === icon);
assert.ok(button, `Missing ${icon} of ${path}`);
button.onClick();
};
return { api, calls, tabsCalls, update, settle, invoke, action, state: () => state };
}
test("diffs and files open through the tabs service", async () => {
const fake = fakeApi(true);
const dispose = activate(fake.api);
try {
await fake.settle();
fake.action("changes", "src/a.ts", "file");
await fake.settle();
assert.deepEqual(fake.tabsCalls, [
{ method: "open", params: { kind: "file", workspaceId: "w1", path: "src/a.ts" } },
]);
fake.tabsCalls.length = 0;
fake.invoke("git-tree-changes", "onSelect", { id: "src/a.ts", folder: false });
await fake.settle();
assert.ok(fake.calls.some((call) => call.method === "host/git.diff"));
assert.deepEqual(fake.tabsCalls, [
{
method: "open",
params: {
kind: "diff",
workspaceId: "w1",
path: "src/a.ts",
diff: "--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1 @@\n-old\n+new",
},
},
]);
} finally {
await dispose();
}
});
test("without the tabs plugin diffs still load and nothing opens", async () => {
const fake = fakeApi(false);
const dispose = activate(fake.api);
try {
await fake.settle();
fake.invoke("git-tree-changes", "onSelect", { id: "src/a.ts", folder: false });
await fake.settle();
assert.ok(fake.calls.some((call) => call.method === "host/git.diff"));
assert.deepEqual(fake.tabsCalls, []);
assert.equal(fake.state().shared?.openTab, undefined);
} finally {
await dispose();
}
});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.