Official
files
Built-in files 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/files@0.2.0
Permissions in 0.2.0
Files
tabs.test.ts8.2 KB
// Files open through the convergence/tabs service and follow its visible
// tab. Real activation with a canned tabs client: opening calls the
// service, the active event selects the file, and without a provider
// opening does nothing.
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));
const stable = (value: unknown) =>
JSON.stringify(value, (_key, item: unknown) => (typeof item === "function" ? "@handler" : item));
function fakeApi(withTabs: boolean, activeTab: { kind: string; path: string } | null) {
let state: AppState = { workspaceId: "w1", shared: { rightPanel: "files" } };
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 }[] = [];
const tabsSubs = new Map<string, Set<(payload: unknown) => void>>();
let dirty = true;
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 });
if (method === "host/fs.search") return { paths: ["README.md", "src/a.ts", "src/z.ts"] };
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 () => activeTab, {
result: async () => ({ ok: true as const, value: activeTab }),
}),
on: (event: string, fn: (payload: unknown) => void) => {
const key = `convergence/tabs|${event}`;
const set = tabsSubs.get(key) ?? new Set<(payload: unknown) => void>();
tabsSubs.set(key, set);
set.add(fn);
return () => {
set.delete(fn);
};
},
};
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: "files",
id: "convergence/files",
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 },
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: "files",
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("files 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);
};
return {
api,
tabsCalls,
update,
settle,
invoke,
trees: () => stable(trees),
tabsActive: (tab: { kind: string; path: string } | null) => {
for (const fn of tabsSubs.get("convergence/tabs|activeChanged") ?? []) fn(tab);
},
};
}
test("selecting a file calls the tabs service, and the visible tab selects it", async () => {
const fake = fakeApi(true, { kind: "file", path: "README.md" });
const dispose = activate(fake.api);
try {
await fake.settle();
// The initial visible tab arrives through the service, not shared state.
assert.ok(fake.trees().includes('"selected":"README.md"'));
fake.invoke("files-w1", "onSelect", { id: "src/a.ts", folder: false });
await fake.settle();
assert.deepEqual(fake.tabsCalls, [
{ method: "open", params: { kind: "file", workspaceId: "w1", path: "src/a.ts" } },
]);
fake.tabsActive({ kind: "file", path: "src/a.ts" });
await fake.settle();
assert.ok(fake.trees().includes('"selected":"src/a.ts"'));
// A non-file tab clears the selection.
fake.tabsActive({ kind: "terminal", path: "t1" });
await fake.settle();
assert.ok(fake.trees().includes('"selected":null'));
} finally {
await dispose();
}
});
test("without the tabs plugin selecting a file does nothing", async () => {
const fake = fakeApi(false, null);
const dispose = activate(fake.api);
try {
await fake.settle();
fake.invoke("files-w1", "onSelect", { id: "src/a.ts", folder: false });
await fake.settle();
assert.deepEqual(fake.tabsCalls, []);
assert.ok(fake.trees().includes('"selected":null'));
} finally {
await dispose();
}
});Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 6.4 KB | 3 permissions | Listed |
No comments yet.