Official

tabs

The tabbed detail pane beside the chat: opened files and diffs (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/tabs@0.2.0

Permissions in 0.2.0

  • Read files fs.readMediumReads files in the listed places.Open workspace files in tabsPlaces: the open workspace
  • Read chats chats.readMediumReads your transcripts and chat lists.Reload the open file when an agent finishes a turn
  • Show panels ui.slotsLowShows views in the listed parts of the window.Draw the tabbed pane beside the chatSlots: detail

Files

equivalence.test.ts37.9 KB
// Window-free equivalence scenarios for the three reference UI conversions.
// Real module imports exercise activation, trees, shared state and host-call order.
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { test } from "node:test";
import * as z from "zod";
import type {
  Api,
  AppState,
  AppStatePatch,
  GitStatus,
  HostEvent,
  RenderResult,
  SlotOptions,
  ViewContext,
} from "convergence";
import { ui } from "convergence";
import { testLayer } from "convergence/effect/testing";

type Activate = (api: Api) => void | (() => Promise<void>);
const moduleSchema = z.object({ activate: z.custom<Activate>((value) => typeof value === "function") });
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
const stable = (value: unknown) =>
  JSON.stringify(value, (_key, item: unknown) => (typeof item === "function" ? "@handler" : item));
const entry = (path: string, staged = " ", unstaged = "M", origPath?: string) => ({
  path,
  staged,
  unstaged,
  ...(origPath ? { origPath } : {}),
});
// A service call or event both sides record in the same shape: the stub bus
// below logs the new code's real ones, and each fake's update() wrapper logs
// the baseline's shared writes as their replacements.
type ServiceEntry = {
  op: string;
  name: string;
  method?: string;
  event?: string;
  payload?: unknown;
  params?: unknown;
};
const repository = {
  branch: "main",
  upstream: "origin/main",
  ahead: 1,
  behind: 1,
  isRepo: true,
  entries: [
    entry("src/a.ts", "M", "M"),
    entry("new.ts", "?", "?"),
    entry("gone.ts", " ", "D"),
    entry("conflict.ts", "U", "U"),
    entry("renamed.ts", "R", " ", "old.ts"),
  ],
};

function fakeUi(plugin: string, initial: AppState = {}) {
  let state = initial;
  const views: {
    slot: string;
    options?: SlotOptions;
    render: (ctx: ViewContext<object>) => RenderResult;
    active: boolean;
  }[] = [];
  const listeners = new Map<string, Set<(event: HostEvent) => unknown>>();
  const appListeners = new Set<(state: AppState) => void>();
  const commands = new Map<string, () => unknown>();
  const calls: { method: string; params: unknown }[] = [];
  const notices: string[] = [];
  const focus: string[] = [];
  const active = new Set<string>();
  let dirty = true;
  let failure: string | undefined;
  let runCode = 0;
  let status: GitStatus = repository;
  let remotes = "backup\norigin\n";
  let fileReply: unknown = { text: "# Fixture\nconst value = 1;" };
  const held = new Map<string, ReturnType<typeof Promise.withResolvers<void>>>();
  const cancelled: string[] = [];
  const response = (method: string, params: unknown): unknown => {
    if (failure === method) throw new Error("fixture failure");
    switch (method) {
      case "host/fs.read":
        return fileReply;
      case "host/fs.search":
        return { paths: ["README.md", "src/a.ts", "src/z.ts", "package.json"] };
      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": {
        const args = z.object({ args: z.array(z.string()) }).parse(params).args;
        const stdout = args[0] === "branch" ? "main\nfeature\n" : args[0] === "remote" ? remotes : "";
        return { stdout, stderr: runCode ? "fixture command failed" : "", code: runCode };
      }
      default:
        throw new Error(`Unexpected host method ${method}`);
    }
  };
  const call = async (method: string, params: unknown, options?: { signal?: AbortSignal }) => {
    calls.push({ method, params });
    const gate = held.get(method);
    if (gate)
      await new Promise<void>((resolve, reject) => {
        const abort = () => {
          cancelled.push(method);
          reject(options?.signal?.reason);
        };
        options?.signal?.addEventListener("abort", abort, { once: true });
        gate.promise.then(() => {
          options?.signal?.removeEventListener("abort", abort);
          resolve();
        });
      });
    return response(method, params);
  };
  const update = (patch: AppStatePatch) => {
    recordShared(patch);
    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 broker = testLayer();
  // Comparison only: the baseline's shared writes are recorded here as the
  // service calls and events that replaced them, in the same shape the stub
  // service bus below records the real ones in. Each rule mirrors a row of
  // the harness mapping table: openTab/closeTab requests become tabs
  // open/close calls (without the nonce), an activeTab write becomes the
  // activeChanged event (activeTabPath rides along with it), and the
  // null writes that only consumed a request are dropped with the keys.
  const serviceLog: ServiceEntry[] = [];
  const withoutNonce = (value: unknown) => {
    if (typeof value === "object" && value !== null && "nonce" in value) {
      const { nonce: _nonce, ...rest } = value as Record<string, unknown>;
      return rest;
    }
    return value;
  };
  const recordShared = (patch: AppStatePatch) => {
    const shared = patch.shared as Record<string, unknown> | undefined;
    if (!shared) return;
    const prev = (state.shared ?? {}) as Record<string, unknown>;
    if (shared.openTab != null)
      serviceLog.push({ op: "call", name: "convergence/tabs", method: "open", params: withoutNonce(shared.openTab) });
    if (shared.closeTab != null)
      serviceLog.push({ op: "call", name: "convergence/tabs", method: "close", params: withoutNonce(shared.closeTab) });
    if (shared.activeTab !== undefined && (shared.activeTab != null || prev.activeTab !== undefined))
      serviceLog.push({ op: "emit", name: "convergence/tabs", event: "activeChanged", payload: shared.activeTab });
  };
  // This assertion describes the locally constructed fake, not unchecked external data.
  // Unused APIs fail on access; only the exercised UI/broker surface is implemented.
  const serviceSubs = new Map<string, Set<(payload: unknown) => void>>();
  const tabsClient = {
    open: Object.assign(
      async (params: { kind: string; workspaceId: string; path: string }) => ({
        id: `${params.kind}:${params.workspaceId}:${params.path}`,
      }),
      {
        // The Effect adapter calls through `result`, never the direct call.
        result: async (params: { kind: string; workspaceId: string; path: string }) => {
          serviceLog.push({ op: "call", name: "convergence/tabs", method: "open", params });
          return { ok: true as const, value: { id: `${params.kind}:${params.workspaceId}:${params.path}` } };
        },
      },
    ),
    close: Object.assign(async () => null, {
      result: async (params: unknown) => {
        serviceLog.push({ op: "call", name: "convergence/tabs", method: "close", params });
        return { ok: true as const, value: null };
      },
    }),
    active: Object.assign(async () => null, {
      result: async () => {
        serviceLog.push({ op: "call", name: "convergence/tabs", method: "active", params: {} });
        return { ok: true as const, value: null };
      },
    }),
    on: (event: string, fn: (payload: unknown) => void) => {
      const key = `convergence/tabs|${event}`;
      const set = serviceSubs.get(key) ?? new Set<(payload: unknown) => void>();
      serviceSubs.set(key, set);
      set.add(fn);
      return () => {
        set.delete(fn);
      };
    },
  };
  const servicesStub = {
    provide: async (def: { name: string }) => {
      serviceLog.push({ op: "provide", name: def.name });
      return {
        emit: (event: string, payload: unknown) => {
          serviceLog.push({ op: "emit", name: def.name, event, payload });
          for (const fn of serviceSubs.get(`${def.name}|${event}`) ?? []) fn(payload);
        },
        remove: () => {},
      };
    },
    get: (name: string) => {
      if (name === "convergence/tabs") return tabsClient;
      throw new Error(`no provider for ${name}`);
    },
    available: (name: string) => name === "convergence/tabs",
    when: (name: string, fn: (client: unknown) => void) => {
      fn(name === "convergence/tabs" ? tabsClient : undefined);
      return () => {};
    },
  };
  const api = new Proxy(
    {
      ...broker.api,
      plugin,
      id: `convergence/${plugin}`,
      ui,
      services: servicesStub,
      host: {
        ...broker.api.host,
        call,
        kernel: call,
        fs: {
          read: (params: unknown) => call("host/fs.read", params),
          search: (params: unknown) => call("host/fs.search", params),
        },
        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: (listener: (state: AppState) => void) => {
          appListeners.add(listener);
          return () => {
            appListeners.delete(listener);
          };
        },
      },
      slot: (slot: string, render: (ctx: ViewContext<object>) => RenderResult, options?: SlotOptions) => {
        const view = { slot, render, options, active: true };
        views.push(view);
        active.add(`view:${views.length}`);
        const id = views.length;
        return {
          id,
          update: () => {
            dirty = true;
          },
          remove: () => {
            view.active = false;
            active.delete(`view:${id}`);
          },
        };
      },
      on: (name: string, listener: (event: HostEvent) => unknown) => {
        const subscribers = listeners.get(name) ?? new Set();
        listeners.set(name, subscribers);
        subscribers.add(listener);
        return () => {
          subscribers.delete(listener);
        };
      },
      command: ({ id, run }: { id: string; run: () => unknown }) => {
        commands.set(id, run);
        active.add(id);
        return {
          remove: () => {
            commands.delete(id);
            active.delete(id);
          },
        };
      },
      notify: (message: string) => {
        notices.push(message);
      },
      focus: (id: string) => {
        focus.push(id);
      },
    },
    {
      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) => ({
        slot: view.slot,
        options: view.options,
        tree: view.render({
          state: {},
          app: state,
          plugin,
          update: () => {
            dirty = true;
          },
        }),
      }));
  let trees: ReturnType<typeof render> = [];
  const settle = async () => {
    for (let round = 0; round < 20; round++) {
      await tick();
      if (!dirty) {
        await tick();
        if (!dirty) return;
      }
      dirty = false;
      trees = render();
    }
    throw new Error("UI did not settle");
  };
  const nodeSchema = z.object({ id: z.string().optional(), children: z.array(z.unknown()).optional() }).passthrough();
  function nodes(tree: unknown): Record<string, unknown>[] {
    if (Array.isArray(tree)) return tree.flatMap(nodes);
    const parsed = nodeSchema.safeParse(tree);
    if (!parsed.success) return [];
    return [
      parsed.data,
      ...(parsed.data.children ?? []).flatMap(nodes),
      ...(Array.isArray(parsed.data.items) ? parsed.data.items.flatMap(nodes) : []),
    ];
  }
  const invoke = (id: string, event = "onClick", payload: unknown = {}) => {
    const node = trees.flatMap((view) => nodes(view.tree)).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,
    views,
    calls,
    notices,
    focus,
    active,
    appListeners,
    listeners,
    commands,
    update,
    settle,
    invoke,
    nodes: () => trees.flatMap((view) => nodes(view.tree)),
    snapshot: () => stable({ trees, state, calls, notices, focus, serviceLog }),
    tabsActive: (tab: { kind: string; path: string } | null) => {
      // The new code follows the service event; the baseline reads the
      // shared keys. Only one side can see each half.
      const subs = serviceSubs.get("convergence/tabs|activeChanged");
      if (subs?.size) {
        // The stub bus stands in for the tabs provider here: its emit is
        // recorded like a real one.
        serviceLog.push({ op: "emit", name: "convergence/tabs", event: "activeChanged", payload: tab });
        for (const fn of [...subs]) fn(tab);
      } else {
        update({
          shared: tab
            ? { activeTab: tab, activeTabPath: tab.kind === "file" ? tab.path : null }
            : { activeTab: null, activeTabPath: null },
        });
      }
    },
    serviceLog,
    trees: () => trees,
    state: () => state,
    fail: (method?: string) => {
      failure = method;
    },
    code: (code: number) => {
      runCode = code;
    },
    status: (next: GitStatus) => {
      status = next;
    },
    remotes: (value: string) => {
      remotes = value;
    },
    fileReply: (value: unknown) => {
      fileReply = value;
    },
    hold: (method: string) => {
      held.set(method, Promise.withResolvers<void>());
    },
    release: (method: string) => {
      const gate = held.get(method);
      held.delete(method);
      gate?.resolve();
    },
    cancelled,
    emit: (event: HostEvent) => {
      for (const name of [event.event, "*"]) for (const fn of listeners.get(name) ?? []) fn(event);
    },
  };
}

async function load(plugin: string, baseline: boolean): Promise<Activate> {
  const path = baseline ? `../../target/js-baseline/${plugin}/main.js` : `../${plugin}/main.ts`;
  // Both sides are actual modules; the baseline is never copied or rewritten.
  const module: unknown = await import(new URL(path, import.meta.url).href);
  return moduleSchema.parse(module).activate;
}
const hasBaseline = (plugin: string) =>
  existsSync(new URL(`../../target/js-baseline/${plugin}/main.js`, import.meta.url));
// Deliberate changes since the JavaScript baseline, applied to its trees so
// everything else still has to match. The git message was a frameless
// input in a padded `muted` box beside a small primary button; it is now a
// plain input the kernel draws as a field (`crates/kernel/src/field.rs`),
// with a field-height button the same width under it.
const GIT_PRIMARY = new Set(["git-commit", "git-publish", "git-sync"]);
// A git section's rows, as the kernel's file tree the panel now draws: the
// path, the rename label, the status and its letter, and the row's buttons
// (none while a command runs, where the baseline disabled them).
const GIT_STATUS: Record<string, string> = {
  M: "modified",
  T: "modified",
  A: "added",
  D: "deleted",
  R: "renamed",
  C: "renamed",
  U: "untracked",
  "!": "conflict",
};
function gitTree(rows: Record<string, unknown>[]) {
  const side = String(rows[0]?.["id"]).slice("git-row-".length).split(":")[0];
  const files = rows.map((row) => {
    const path = String(row["id"]).slice(`git-row-${side}:`.length);
    const kids = (row["children"] ?? []) as Record<string, unknown>[];
    const texts = kids.filter((kid) => kid["type"] === "text").map((kid) => String(kid["text"]));
    const letter = texts[texts.length - 1] ?? "";
    const label = texts[0] ?? "";
    const strip = kids.find((kid) => kid["type"] === "div");
    const buttons = ((strip?.["children"] ?? []) as Record<string, unknown>[]).filter(
      (kid) => kid["type"] === "button",
    );
    return {
      path,
      ...(label.includes(" → ") ? { label } : {}),
      status: GIT_STATUS[letter] ?? "modified",
      badge: letter,
      actions: buttons.map((button) => ({
        icon: button["icon"],
        tooltip: button["tooltip"],
        onClick: "@handler",
        disabled: button["disabled"] === true,
      })),
    };
  });
  return { type: "tree", id: `git-tree-${side}`, fit: true, openFolders: true, files, onSelect: "@handler" };
}
// The files panel's tree, given flat by path now (the kernel makes the
// folders).
function leaves(items: Record<string, unknown>[]): { path: string }[] {
  return items.flatMap((item) => {
    const kids = (item["children"] ?? []) as Record<string, unknown>[];
    return String(item["id"]).endsWith("/") ? leaves(kids) : [{ path: String(item["id"]) }];
  });
}
function redesigned(node: unknown): void {
  if (Array.isArray(node)) {
    node.forEach((child, index) => {
      const record = child as Record<string, unknown> | null;
      const rows = record?.["children"];
      if (
        record?.["type"] === "div" &&
        Array.isArray(rows) &&
        rows.length > 0 &&
        rows.every((row) => String((row as Record<string, unknown>)["id"]).startsWith("git-row-"))
      ) {
        node[index] = gitTree(rows as Record<string, unknown>[]);
        return;
      }
      const kids = record?.["children"];
      const only = Array.isArray(kids) && kids.length === 1 ? (kids[0] as Record<string, unknown>) : null;
      if (record?.["type"] === "div" && record["background"] === "muted" && only?.["id"] === "git-message") {
        delete only["ghost"];
        // A single-line field now: Return commits.
        delete only["multiline"];
        if (typeof only["placeholder"] === "string") only["placeholder"] = only["placeholder"].replace("⌘⏎", "⏎");
        node[index] = only;
      }
      redesigned(node[index]);
    });
    return;
  }
  if (typeof node !== "object" || node === null) return;
  const record = node as Record<string, unknown>;
  if (record["type"] === "tree" && String(record["id"]).startsWith("files-") && Array.isArray(record["items"])) {
    // In the new order of its props: files, then the selection.
    const { selected, onSelect } = record;
    const files = leaves(record["items"] as Record<string, unknown>[]);
    for (const key of ["items", "selected", "onSelect"]) delete record[key];
    Object.assign(record, { files, selected, onSelect });
  }
  // A git section's header is a taller muted heading on the tree's grid
  // with the tree's chevron at its end, its hover buttons in the count's
  // place; a line now separates two sections.
  if (record["type"] === "div" && String(record["id"]).startsWith("git-section-")) {
    record["gap"] = 6;
    record["height"] = 30;
    const kids = (record["children"] ?? []) as Record<string, unknown>[];
    const icon = kids.findIndex((kid) => kid["type"] === "icon");
    if (icon >= 0) kids.push(...kids.splice(icon, 1));
    // The buttons and the count share a place: the count hides on hover.
    const buttons = kids.findIndex((kid) => kid["type"] === "div" && kid["position"] === "absolute");
    if (buttons >= 0 && kids[buttons + 1]?.["type"] === "text") {
      const [strip, count] = kids.splice(buttons, 2) as Record<string, unknown>[];
      strip!["right"] = 0;
      kids.splice(buttons, 0, {
        type: "div",
        display: "flex",
        flexDirection: "row",
        alignItems: "center",
        position: "relative",
        height: "100%",
        children: [strip, { type: "div", groupHover: { group: "hover", visibility: "hidden" }, children: [count] }],
      });
    }
    for (const kid of kids) {
      if (kid["type"] === "icon") {
        kid["name"] = `tree-${String(kid["name"])}`;
        delete kid["small"];
      }
      if (kid["type"] === "text" && kid["style"] === "section") {
        const { text, truncate } = kid;
        delete kid["text"];
        delete kid["truncate"];
        Object.assign(kid, { size: 11, tone: "muted", truncate, text });
      }
    }
  }
  if (Array.isArray(record["children"])) {
    const isSection = (kid: unknown) =>
      String(((kid as Record<string, unknown> | null)?.["children"] as Record<string, unknown>[] | undefined)?.[0]?.["id"]).startsWith(
        "git-section-",
      );
    const kids = record["children"] as unknown[];
    for (let i = kids.length - 1; i > 0; i--)
      if (isSection(kids[i]) && isSection(kids[i - 1])) kids.splice(i, 0, { type: "divider" });
  }
  if (record["type"] === "button" && GIT_PRIMARY.has(String(record["id"]))) {
    delete record["small"];
    // An accent button now: the active selection colour.
    record["variant"] = "accent";
  }
  for (const value of Object.values(record)) redesigned(value);
}

// A step on a git file row: the baseline's row or button by id, the new
// panel's tree (`onSelect`) or the file's action by its icon.
const ACTION_ICON: Record<string, string> = { open: "file", stage: "plus", unstage: "minus", discard: "undo-2" };
function gitRow(fake: ReturnType<typeof fakeUi>, key: string) {
  if (fake.nodes().some((node) => node["id"] === `git-row-${key}`)) return fake.invoke(`git-row-${key}`);
  const [side, ...rest] = key.split(":");
  fake.invoke(`git-tree-${side}`, "onSelect", { id: rest.join(":"), folder: false });
}
function gitAct(fake: ReturnType<typeof fakeUi>, id: string) {
  if (fake.nodes().some((node) => node["id"] === id)) return fake.invoke(id);
  const [, verb, rest] = /^git-(open|stage|unstage|discard)-(.*)$/.exec(id) ?? [];
  const [side, ...path] = String(rest).split(":");
  const tree = fake.nodes().find((node) => node["id"] === `git-tree-${side}`);
  assert.ok(tree, `Missing git-tree-${side}`);
  const file = (tree["files"] as { path: string; actions: { icon: string; onClick: () => void }[] }[]).find(
    (f) => f.path === path.join(":"),
  );
  const action = file?.actions.find((a) => a.icon === ACTION_ICON[String(verb)]);
  assert.ok(action, `Missing ${id}`);
  action.onClick();
}

async function compare(plugin: string, steps: ((fake: ReturnType<typeof fakeUi>) => void)[], initial: AppState = {}) {
  const current = fakeUi(plugin, initial),
    baseline = fakeUi(plugin, initial);
  const dispose = (await load(plugin, false))(current.api);
  (await load(plugin, true))(baseline.api);
  assert.equal(current.views.length, plugin === "tabs" ? 1 : 2, "all views register before activate returns");
  // The shared request keys are gone on the new side and the service log
  // holds calls the baseline never makes: compare both sides without them.
  // Reads (`active`) leave no trace on the baseline, so they go too.
  const normalized = (fake: ReturnType<typeof fakeUi>) => {
    const snap = JSON.parse(fake.snapshot()) as { state: AppState; serviceLog: ServiceEntry[] };
    if (fake === baseline) redesigned(snap);
    // A tree's files are sorted by the kernel: their order is not compared.
    (function sortFiles(node: unknown): void {
      if (Array.isArray(node)) return node.forEach(sortFiles);
      if (typeof node !== "object" || node === null) return;
      const record = node as Record<string, unknown>;
      if (record["type"] === "tree" && Array.isArray(record["files"]))
        (record["files"] as { path: string }[]).sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
      Object.values(record).forEach(sortFiles);
    })(snap);
    const sharedState = snap.state as { shared?: Record<string, unknown> };
    for (const key of ["openTab", "closeTab", "activeTab", "activeTabPath"]) {
      if (sharedState.shared) delete sharedState.shared[key];
    }
    if (sharedState.shared && !Object.keys(sharedState.shared).length) delete sharedState.shared;
    type Entry = { op: string; method?: string };
    snap.serviceLog = snap.serviceLog.filter(
      (entry: Entry) => entry.op !== "provide" && !(entry.op === "call" && entry.method === "active"),
    ) as ServiceEntry[];
    return JSON.stringify(snap);
  };
  try {
    await current.settle();
    await baseline.settle();
    assert.equal(normalized(current), normalized(baseline), "initial state");
    for (const [index, step] of steps.entries()) {
      current.calls.length = baseline.calls.length = 0;
      current.serviceLog.length = baseline.serviceLog.length = 0;
      step(current);
      step(baseline);
      await current.settle();
      await baseline.settle();
      assert.equal(normalized(current), normalized(baseline), `step ${index}`);
    }
  } finally {
    if (dispose) await dispose();
  }
}

const open =
  (kind: string, path: string, nonce: number, extra: object = {}) =>
  (fake: ReturnType<typeof fakeUi>) =>
    fake.update({ shared: { openTab: { kind, path, workspaceId: "w1", nonce, ...extra } } });

test(
  "tabs render and host calls equal the JS baseline across every content kind",
  { skip: !hasBaseline("tabs") },
  async (ctx) => {
    ctx.mock.method(Date, "now", () => 123);
    await compare("tabs", [
      open("file", "README.md", 1),
      (fake) => fake.invoke("preview-file:w1:README.md"),
      (fake) => fake.invoke("editor-file:w1:README.md"),
      open("diff", "src/a.ts", 2, { diff: "" }),
      open("diff", "src/a.ts", 3, { diff: "--- a\n+++ b\n" }),
      open("terminal", "t1", 4),
      open("view", "v1", 5, { title: "First", view: 10 }),
      open("view", "v1", 6, { title: "Updated", icon: "file", view: 11 }),
      (fake) => fake.update({ shared: { closeTab: { kind: "view", path: "v1", nonce: 7 } } }),
      (fake) => fake.invoke("close-terminal:w1:t1"),
      (fake) => fake.invoke("close-diff:w1:src/a.ts"),
      (fake) =>
        fake.emit({ event: "agent", chatId: "c1", kind: { event: "run_finished", outcome: { status: "completed" } } }),
      (fake) => {
        fake.fail("host/fs.read");
      },
      open("file", "error.txt", 8),
      (fake) => fake.invoke("close-file:w1:error.txt"),
      (fake) => fake.invoke("close-file:w1:README.md"),
    ]);
  },
);

test(
  "files render and host calls equal the JS baseline through panel and workspace changes",
  { skip: !hasBaseline("files") },
  async (ctx) => {
    ctx.mock.method(Date, "now", () => 123);
    await compare("files", [
      (fake) => fake.update({ shared: { rightPanelPreview: "files" } }),
      (fake) => fake.update({ workspaceId: "w1" }),
      (fake) => fake.invoke("files-search", "onChange", { value: " TS " }),
      (fake) => fake.invoke("files-w1", "onSelect", { id: "src/", folder: true }),
      (fake) => fake.invoke("files-w1", "onSelect", { id: "src/a.ts", folder: false }),
      (fake) => fake.tabsActive({ kind: "file", path: "src/a.ts" }),
      (fake) => fake.invoke("files-refresh"),
      (fake) =>
        fake.emit({ event: "agent", chatId: "c1", kind: { event: "run_finished", outcome: { status: "completed" } } }),
      (fake) => fake.invoke("rail-files"),
      (fake) => fake.update({ workspaceId: "w2" }),
      (fake) => {
        fake.fail("host/fs.search");
        fake.invoke("files-refresh");
      },
      (fake) => fake.invoke("files-close"),
      (fake) => fake.update({ shared: { rightPanelPreview: null } }),
    ]);
  },
);

test(
  "git render, menu, sections and command order equal the JS baseline",
  { skip: !hasBaseline("git") },
  async (ctx) => {
    ctx.mock.method(Date, "now", () => 123);
    await compare("git", [
      (fake) => fake.update({ workspaceId: "w1", shared: { rightPanel: "git" } }),
      (fake) => fake.invoke("git-section-staged"),
      (fake) => fake.invoke("git-section-staged"),
      (fake) => gitRow(fake, "staged:renamed.ts"),
      (fake) => gitRow(fake, "changes:src/a.ts"),
      (fake) => gitAct(fake, "git-open-changes:src/a.ts"),
      (fake) => gitAct(fake, "git-stage-changes:new.ts"),
      (fake) => gitAct(fake, "git-unstage-staged:renamed.ts"),
      (fake) => gitAct(fake, "git-discard-changes:new.ts"),
      (fake) => gitAct(fake, "git-discard-changes:new.ts"),
      (fake) => fake.invoke("git-message", "onChange", { value: "  message  " }),
      (fake) => fake.invoke("git-commit"),
      (fake) => fake.invoke("git-more"),
      (fake) => fake.invoke("git-menu-fetch"),
      (fake) => fake.invoke("git-branch", "onPress"),
      (fake) => fake.invoke("git-branch-query", "onChange", { value: "new-branch" }),
      (fake) => fake.invoke("git-branch-query", "onSubmit"),
      (fake) => fake.invoke("git-branch", "onPress"),
      (fake) => fake.invoke("git-branch-feature"),
      (fake) => fake.emit({ event: "git_changed", workspaceId: "w1" }),
      (fake) => fake.emit({ event: "workspaces_changed" }),
      (fake) => {
        fake.fail("host/git.status");
        fake.invoke("git-refresh");
      },
      (fake) => {
        fake.fail();
        fake.invoke("git-refresh");
      },
      (fake) => fake.invoke("git-close"),
    ]);
  },
);

for (const plugin of ["tabs", "files", "git"])
  test(`${plugin} acquires and releases its views and subscriptions on unload`, async () => {
    const fake = fakeUi(plugin);
    const dispose = (await load(plugin, false))(fake.api);
    assert.equal(fake.views.length, plugin === "tabs" ? 1 : 2);
    const appSubscriptions = plugin === "git" ? 0 : 1;
    assert.equal(fake.appListeners.size, appSubscriptions, "app subscriptions follow the baseline lifecycle");
    assert.ok(
      [...fake.listeners.values()].some((set) => set.size > 0),
      "event subscriptions are ready before activate returns",
    );
    await fake.settle();
    assert.equal(fake.appListeners.size, appSubscriptions);
    assert.ok([...fake.listeners.values()].some((set) => set.size > 0));
    assert.ok(dispose);
    await dispose();
    assert.equal(fake.active.size, 0);
    assert.equal(fake.appListeners.size, 0);
    assert.ok([...fake.listeners.values()].every((set) => set.size === 0));
  });

test(
  "git command fallbacks, smart commit and every menu action match the baseline",
  { skip: !hasBaseline("git") },
  async (ctx) => {
    ctx.mock.method(Date, "now", () => 123);
    const changes = { ...repository, entries: [entry("tracked.ts"), entry("new.ts", "?", "?")] };
    await compare(
      "git",
      [
        (fake) => {
          fake.status(changes);
          fake.invoke("git-refresh");
        },
        (fake) => fake.invoke("git-message", "onChange", { value: "smart commit" }),
        (fake) => fake.invoke("git-commit"),
        (fake) => {
          fake.code(1);
          fake.invoke("git-message", "onChange", { value: "keep on failure" });
        },
        (fake) => fake.invoke("git-commit"),
        (fake) => {
          fake.code(0);
          fake.invoke("git-message", "onChange", { value: "" });
        },
        ...["pull", "push", "fetch", "amend", "undo", "stage-all", "unstage-all", "stash", "pop"].flatMap((action) => [
          (fake: ReturnType<typeof fakeUi>) => fake.invoke("git-more"),
          (fake: ReturnType<typeof fakeUi>) => fake.invoke(`git-menu-${action}`),
        ]),
        (fake) => {
          fake.status(repository);
          fake.invoke("git-refresh");
        },
        (fake) => {
          fake.code(1);
          gitAct(fake, "git-unstage-staged:renamed.ts");
        },
        (fake) => {
          fake.code(0);
          fake.invoke("git-changes-discard-all");
        },
        (fake) => fake.invoke("git-changes-discard-all"),
        (fake) => fake.invoke("git-merge-stage-all"),
        (fake) => {
          fake.status({ ...repository, entries: [] });
          fake.invoke("git-refresh");
        },
        (fake) => fake.invoke("git-sync"),
        (fake) => {
          fake.status({ ...repository, entries: [], upstream: null });
          fake.invoke("git-refresh");
        },
        (fake) => fake.invoke("git-publish"),
        (fake) => {
          fake.remotes("backup\n");
          fake.invoke("git-publish");
        },
        (fake) => {
          fake.remotes("");
          fake.invoke("git-publish");
        },
        (fake) => {
          fake.fail("host/git.diff");
          fake.status(repository);
          fake.invoke("git-refresh");
        },
        (fake) => gitRow(fake, "changes:src/a.ts"),
      ],
      { workspaceId: "w1", shared: { rightPanel: "git" } },
    );
  },
);

test("tabs accept close requests while a file is loading and cancel reads on unload", async () => {
  const fake = fakeUi("tabs");
  fake.hold("host/fs.read");
  const dispose = (await load("tabs", false))(fake.api);
  try {
    await fake.settle();
    open("file", "slow.ts", 1)(fake);
    await fake.settle();
    assert.equal(fake.calls.filter((call) => call.method === "host/fs.read").length, 1);
    assert.ok(stable(fake.trees()).includes("Loading…"));
    fake.update({ shared: { closeTab: { kind: "file", path: "slow.ts", nonce: 2 } } });
    await fake.settle();
    assert.equal(fake.trees()[0].tree, null);
    assert.equal(fake.state().shared?.openTab, null);
    assert.equal(fake.state().shared?.closeTab, null);
    assert.ok(dispose);
    await dispose();
    assert.deepEqual(fake.cancelled, ["host/fs.read"]);
  } finally {
    fake.release("host/fs.read");
    if (dispose) await dispose();
  }
});

test("git coalesces refreshes and ignores a late status from the previous workspace", async () => {
  const fake = fakeUi("git", { workspaceId: "w1", shared: { rightPanel: "git" } });
  fake.hold("host/git.status");
  const dispose = (await load("git", false))(fake.api);
  try {
    await fake.settle();
    fake.emit({ event: "git_changed", workspaceId: "w1" });
    fake.emit({ event: "workspaces_changed" });
    fake.update({ workspaceId: "w2" });
    await fake.settle();
    assert.equal(fake.calls.filter((call) => call.method === "host/git.status").length, 1);
    assert.ok(stable(fake.trees()).includes("Loading…"));
    fake.release("host/git.status");
    await fake.settle();
    assert.deepEqual(
      fake.calls.filter((call) => call.method === "host/git.status").map((call) => call.params),
      [{ workspaceId: "w1" }, { workspaceId: "w2" }],
    );
  } finally {
    fake.release("host/git.status");
    if (dispose) await dispose();
  }
});

test("files show loading immediately and unload cancels the outstanding search", async () => {
  const fake = fakeUi("files", { workspaceId: "w1", shared: { rightPanel: "files" } });
  fake.hold("host/fs.search");
  const dispose = (await load("files", false))(fake.api);
  try {
    await fake.settle();
    assert.ok(stable(fake.trees()).includes("Loading files…"));
    assert.equal(fake.calls.length, 1);
    assert.ok(dispose);
    await dispose();
    assert.deepEqual(fake.cancelled, ["host/fs.search"]);
  } finally {
    fake.release("host/fs.search");
    if (dispose) await dispose();
  }
});

test("git unload cancels a command without starting another status read", async () => {
  const fake = fakeUi("git", { workspaceId: "w1", shared: { rightPanel: "git" } });
  const dispose = (await load("git", false))(fake.api);
  try {
    await fake.settle();
    fake.calls.length = 0;
    fake.hold("host/git.run");
    gitAct(fake, "git-stage-changes:new.ts");
    await fake.settle();
    assert.ok(stable(fake.trees()).includes("Staging"));
    assert.ok(dispose);
    await dispose();
    assert.deepEqual(fake.cancelled, ["host/git.run"]);
    assert.deepEqual(
      fake.calls.map((call) => call.method),
      ["host/git.run"],
    );
  } finally {
    fake.release("host/git.run");
    if (dispose) await dispose();
  }
});

test("malformed tab requests surface an error and the subscription accepts the next request", async () => {
  const fake = fakeUi("tabs");
  const dispose = (await load("tabs", false))(fake.api);
  try {
    await fake.settle();
    fake.update({ shared: { openTab: { kind: "file", workspaceId: "w1", path: "a.ts", nonce: "invalid" } } });
    await fake.settle();
    assert.ok(fake.notices.some((notice) => notice.includes("openTab")));
    assert.equal(fake.calls.length, 0);
    open("file", "a.ts", 1)(fake);
    await fake.settle();
    assert.ok(stable(fake.trees()).includes("file-file:w1:a.ts"));
  } finally {
    if (dispose) await dispose();
  }
});

test("tabs consume app requests delivered immediately after activate returns", async () => {
  const fake = fakeUi("tabs");
  const dispose = (await load("tabs", false))(fake.api);
  try {
    open("file", "immediate.ts", 1)(fake);
    await fake.settle();
    assert.equal(fake.state().shared?.openTab, null);
    assert.ok(stable(fake.trees()).includes("file-file:w1:immediate.ts"));
  } finally {
    if (dispose) await dispose();
  }
});

test("tabs render missing-file errors using the original QuickJS message", async () => {
  const fake = fakeUi("tabs");
  fake.fileReply(null);
  const dispose = (await load("tabs", false))(fake.api);
  try {
    open("file", "missing.ts", 1)(fake);
    await fake.settle();
    assert.deepEqual(fake.calls, [{ method: "host/fs.read", params: { workspaceId: "w1", path: "missing.ts" } }]);
    assert.ok(stable(fake.trees()).includes("cannot read property 'text' of null"));
    assert.equal(fake.notices.length, 0);
    fake.fileReply({ text: "recovered" });
    fake.emit({ event: "agent", chatId: "c1", kind: { event: "run_finished", outcome: { status: "completed" } } });
    await fake.settle();
    assert.ok(stable(fake.trees()).includes("recovered"));
    assert.ok(!stable(fake.trees()).includes("cannot read property"));
  } finally {
    if (dispose) await dispose();
  }
});

test("git starts watching at the first rail render and reads status without waiting for watch", async () => {
  const fake = fakeUi("git", { workspaceId: "w1", shared: { rightPanel: "git" } });
  fake.hold("host/git.watch");
  const dispose = (await load("git", false))(fake.api);
  try {
    assert.equal(fake.calls.length, 0, "activation only registers views and commands");
    await fake.settle();
    assert.deepEqual(fake.calls, [
      { method: "host/git.watch", params: { workspaceId: "w1" } },
      { method: "host/git.status", params: { workspaceId: "w1" } },
    ]);
    assert.ok(stable(fake.trees()).includes('"path":"src/a.ts"'));
    fake.update({ workspaceId: "w2" });
    await fake.settle();
    assert.deepEqual(fake.calls.slice(2), [
      { method: "host/git.watch", params: { workspaceId: "w2" } },
      { method: "host/git.status", params: { workspaceId: "w2" } },
    ]);
  } finally {
    fake.release("host/git.watch");
    if (dispose) await dispose();
  }
});

Versions

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <318.8 KB3 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.