Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 126 additions & 6 deletions vscode/src/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ interface Disposable {
}

const settings = new Map<string, unknown>();
const executedCommands: unknown[][] = [];
const configurationChanges = new Emitter<{ affectsConfiguration(section: string): boolean }>();
let registeredProvider: unknown = null;

Expand Down Expand Up @@ -62,7 +63,11 @@ void mock.module("vscode", () => ({
},
commands: {
registerCommand: () => ({ dispose: () => undefined }),
executeCommand: () => Promise.resolve(undefined),
executeCommand: (...args: unknown[]) => {
executedCommands.push(args);
notify();
return Promise.resolve(undefined);
},
},
}));

Expand Down Expand Up @@ -100,10 +105,19 @@ async function until(predicate: () => boolean, label: string): Promise<void> {
});
}

function startServer(initialToken: string) {
function startServer(initialToken: string, workspaces: unknown[] = [WORKSPACE]) {
const hits = new Map<string, number>();
const state: { token: string; hold: Promise<void> | null; heldCalls: number } = {
const state: {
token: string;
hold: Promise<void> | null;
heldCalls: number;
metadata: unknown[];
metadataOpen: number;
} = {
token: initialToken,
// Queued workspace.onMetadata updates, delivered after the snapshot (#5109).
metadata: [],
metadataOpen: 0,
// getOutput waits on this while set, so a test can hold a call in flight.
hold: null,
heldCalls: 0,
Expand All @@ -117,7 +131,22 @@ function startServer(initialToken: string) {
const router = {
general: { ping: authed.handler(() => "pong") },
workspace: {
list: authed.handler(() => [WORKSPACE]),
list: authed.handler(() => workspaces),
onMetadata: authed.handler(async function* ({ signal }) {
// Open subscriptions; the client closing one aborts its request.
state.metadataOpen += 1;
notify();
signal?.addEventListener("abort", () => {
state.metadataOpen -= 1;
notify();
});
yield { type: "snapshot", workspaces };
for (;;) {
const update = state.metadata.shift();
if (update) yield update;
else await until(() => state.metadata.length > 0, "a metadata update").catch(() => undefined);
}
}),
activity: { list: authed.handler(() => ({})) },
backgroundBashes: {
getOutput: authed.handler(async () => {
Expand Down Expand Up @@ -204,8 +233,8 @@ afterEach(() => {
settings.clear();
});

async function setup() {
const server = startServer("token-a");
async function setup(workspaces?: unknown[]) {
const server = startServer("token-a", workspaces);
servers.push(server);
settings.set("mux.connectionMode", "server-only");
settings.set("mux.serverUrl", server.url);
Expand Down Expand Up @@ -301,6 +330,7 @@ async function setup() {
response,
call,
cancel: (requestId: string) => send({ type: "orpcCancel", requestId }),
send,
setSecret: (token: string) => secrets.set(SECRET_KEY, token),
fireSecretChange: () => secretChanges.fire({ key: SECRET_KEY }),
};
Expand Down Expand Up @@ -441,3 +471,93 @@ describe("chat view bridged oRPC calls reuse the validated API client (#5196)",
expect(server.validations()).toBe(validations);
});
});

describe("chat view live workspace list (#5109)", () => {
const child = (extra: Record<string, unknown> = {}) => ({
...WORKSPACE,
id: "ws-child",
name: "child",
parentWorkspaceId: WORKSPACE.id,
...extra,
});
type Posted = PostedMessage & { workspaces?: Array<{ id: string }> };
const lists = (posted: Posted[]) => posted.filter((message) => message.type === "workspaces");

test("a burst of metadata events re-posts the list only when the projection changes", async () => {
// Users have ~4.5k workspaces; these are older, so they sort after the others.
const synthetic = Array.from({ length: 4_500 }, (_, i) => ({
...WORKSPACE,
id: `ws-s${i}`,
name: `s${i}`,
createdAt: "2026-09-01T00:00:00.000Z",
}));
const harness = await setup([WORKSPACE, ...synthetic]);
const posted = harness.posted as Posted[];
const before = lists(posted).length;
const update = (metadata: unknown, workspaceId = "ws-child") =>
harness.server.state.metadata.push({ workspaceId, metadata });

// 21 events; only the creation, the rename, the archive and the last creation change what
// the webview is sent.
for (let i = 0; i < 5; i++) update({ ...synthetic[i], namedWorkspacePath: `/p${i}` }, `ws-s${i}`);
update(child()); // created
for (let i = 0; i < 5; i++) update(child({ namedWorkspacePath: `/tmp/c${i}` }));
update(child({ title: "Explorer" })); // renamed
for (let i = 0; i < 5; i++) update(child({ title: "Explorer", namedWorkspacePath: `/x${i}` }));
update(child({ archivedAt: "2026-10-04T00:00:00.000Z" })); // archived: drops out
update(null, "ws-gone"); // removal of an unknown workspace changes nothing
update(null);
// A last creation marks the end of the burst: every event before it has been handled.
update(child({ id: "ws-last", name: "last" }), "ws-last");
notify();
await until(() => lists(posted).length - before >= 4, "the end of the burst");

const ids = lists(posted)
.slice(before)
.map((message) => {
const listed = message.workspaces?.map((w) => w.id) ?? [];
expect(listed.filter((id) => id.startsWith("ws-s"))).toHaveLength(synthetic.length);
return listed.filter((id) => !id.startsWith("ws-s"));
});
// Sorted by recency, then name.
expect(ids).toEqual([
["ws-child", "ws-1"],
["ws-child", "ws-1"],
["ws-1"],
["ws-last", "ws-1"],
]);
});

test("a changed server URL or auth token closes the subscription until the next refresh", async () => {
for (const change of ["url", "secret"] as const) {
const harness = await setup();
const { state } = harness.server;
await until(() => state.metadataOpen === 1, "the metadata subscription");
if (change === "url") {
configurationChanges.fire({ affectsConfiguration: (section) => section === "mux.serverUrl" });
} else {
harness.fireSecretChange();
}
await until(() => state.metadataOpen === 0, `the ${change} change to close it`);
await harness.refresh("refreshWorkspaces");
await until(() => state.metadataOpen === 1, "a new subscription after the refresh");
}
});

test("an update the webview never sees still refreshes the host's copy", async () => {
const titled = { ...WORKSPACE, title: "Main", runtimeConfig: { type: "worktree", srcBaseDir: "/src" } };
const harness = await setup([titled]);
const posted = harness.posted as Posted[];
const before = lists(posted).length;
// With a display title set, a rename changes only `name`, which the webview is never sent.
harness.server.state.metadata.push({ workspaceId: WORKSPACE.id, metadata: { ...titled, name: "renamed" } });
// A creation marks the end; its re-sort must keep the renamed copy.
harness.server.state.metadata.push({ workspaceId: "ws-child", metadata: child() });
notify();
await until(() => lists(posted).length > before, "the creation post");
executedCommands.length = 0;
harness.send({ type: "openWorkspace", workspaceId: WORKSPACE.id });
await until(() => executedCommands.length > 0, "the workspace to open");
expect(String(executedCommands[0][1])).toBe("file:///src/xum/renamed");
});
});
82 changes: 80 additions & 2 deletions vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createHash, randomBytes } from "node:crypto";

import { formatRelativeTime } from "xum/browser/utils/ui/dateTime";
import {
enrichAndSort,
getAllWorkspacesFromFiles,
getAllWorkspacesFromApi,
getWorkspacePath,
Expand Down Expand Up @@ -31,6 +32,7 @@ import {
import { parseWebviewToExtensionMessage } from "./parseWebviewToExtensionMessage";
import { openWorkspace } from "./workspaceOpener";
import { pumpWorkspaceActivity } from "./workspaceActivityPump";
import { pumpWorkspaceMetadata } from "./workspaceMetadataPump";

let sessionPreferredMode: "api" | "file" | null = null;
let didShowFallbackPrompt = false;
Expand Down Expand Up @@ -770,7 +772,8 @@ async function debugConnectionCommand(context: vscode.ExtensionContext): Promise

async function getWorkspacesForSidebar(
context: vscode.ExtensionContext
): Promise<{ workspaces: WorkspaceWithContext[]; status: UiConnectionStatus }> {
// In api mode, `client` listed them; the live pumps reuse its validation.
): Promise<{ workspaces: WorkspaceWithContext[]; status: UiConnectionStatus; client?: ApiClient }> {
assert(context, "getWorkspacesForSidebar requires context");

const modeSetting: ConnectionMode = getConnectionModeSetting();
Expand Down Expand Up @@ -846,6 +849,7 @@ async function getWorkspacesForSidebar(
mode: "api",
baseUrl: api.baseUrl,
},
client: api.client,
};
} catch (error) {
const apiError = formatError(error);
Expand Down Expand Up @@ -980,6 +984,11 @@ class XumChatViewProvider implements vscode.WebviewViewProvider, vscode.Disposab
>();
private subscribedWorkspaceId: string | null = null;
private subscriptionAbort: AbortController | null = null;
// Live workspace list (#5109), on the client of the last successful refresh.
private metadataAbort: AbortController | null = null;
// What the webview was last sent: the whole list, and per workspace for metadata events.
private lastPostedWorkspacesKey: string | null = null;
private postedProjections = new Map<string, string>();

constructor(private readonly context: vscode.ExtensionContext) {
this.selectedWorkspaceId =
Expand All @@ -1000,6 +1009,9 @@ class XumChatViewProvider implements vscode.WebviewViewProvider, vscode.Disposab

dropValidatedApi(): void {
this.validatedApi = null;
// A new server URL or token (or a refresh) also ends the live list's subscription, so no event
// from the old endpoint lands; the next refresh starts one on the new client (#5109).
this.startMetadataPump(null);
}

private dropValidatedApiIfCurrent(acquired: Promise<ApiClientResult> | null): void {
Expand Down Expand Up @@ -1433,7 +1445,10 @@ class XumChatViewProvider implements vscode.WebviewViewProvider, vscode.Disposab
this.workspacesById = new Map(this.workspaces.map((w) => [w.id, w]));

this.postMessage({ type: "connectionStatus", status: this.connectionStatus });
this.postMessage({ type: "workspaces", workspaces: this.workspaces.map(toUiWorkspace) });
// Always posted: a refresh may come from a new webview that has no list yet.
this.lastPostedWorkspacesKey = null;
this.postWorkspacesIfChanged();
this.startMetadataPump(result.status.mode === "api" ? (result.client ?? null) : null);

if (this.selectedWorkspaceId && !this.workspacesById.has(this.selectedWorkspaceId)) {
await this.setSelectedWorkspaceId(null);
Expand Down Expand Up @@ -1461,6 +1476,7 @@ class XumChatViewProvider implements vscode.WebviewViewProvider, vscode.Disposab
this.setConnectionStatus({ mode: "file", error: message });
this.workspaces = [];
this.workspacesById = new Map();
this.startMetadataPump(null);

this.subscriptionAbort?.abort();
this.subscriptionAbort = null;
Expand Down Expand Up @@ -1727,6 +1743,68 @@ class XumChatViewProvider implements vscode.WebviewViewProvider, vscode.Disposab
}
}

private postWorkspacesIfChanged(): void {
const workspaces = this.workspaces.map(toUiWorkspace);
const projections = new Map(workspaces.map((w) => [w.id, JSON.stringify(w)]));
const key = [...projections.values()].join("\n");
if (key === this.lastPostedWorkspacesKey) {
return;
}
this.lastPostedWorkspacesKey = key;
this.postedProjections = projections;
this.postMessage({ type: "workspaces", workspaces });
}

/** Re-sorts and posts the list; a real change only (or the snapshot), never per event. */
private async relist(workspaces: WorkspaceWithContext[]): Promise<void> {
// Keep each workspace's recency and stream flag from the last refresh.
const extensionMeta = new Map(
this.workspaces.flatMap((w) => (w.extensionMetadata ? [[w.id, w.extensionMetadata]] : []))
);
this.workspaces = enrichAndSort(workspaces, extensionMeta);
this.workspacesById = new Map(this.workspaces.map((w) => [w.id, w]));
this.postWorkspacesIfChanged();
if (this.selectedWorkspaceId && !this.workspacesById.has(this.selectedWorkspaceId)) {
await this.setSelectedWorkspaceId(null);
}
}

/** One workspace.onMetadata subscription per refresh keeps the list live; null stops it. */
private startMetadataPump(client: ApiClient | null): void {
this.metadataAbort?.abort();
this.metadataAbort = null;
if (!client) {
return;
}
const controller = new AbortController();
this.metadataAbort = controller;
void pumpWorkspaceMetadata({
Comment thread
ThomasK33 marked this conversation as resolved.
client,
signal: controller.signal,
onSnapshot: (workspaces) => this.relist(workspaces),
onUpdate: async (workspaceId, metadata) => {
const previous = this.workspacesById.get(workspaceId);
const next = metadata && { ...metadata, extensionMetadata: previous?.extensionMetadata };
// Compare only this workspace with its last posted projection: most events change fields
// the webview never sees, and rebuilding a list of thousands per event is too slow. Such
// an event still refreshes the stored copy, e.g. the name that opening the workspace reads.
const posted = this.postedProjections.get(workspaceId);
if (posted === (next ? JSON.stringify(toUiWorkspace(next)) : undefined)) {
if (next) this.workspacesById.set(workspaceId, next);
return;
}
// From the map, which holds those refreshed copies.
const others = [...this.workspacesById.values()].filter((w) => w.id !== workspaceId);
await this.relist(next ? [...others, next] : others);
},
onError: (error) => {
xumLogDebug("mux.chatView: workspace metadata subscription failed", {
error: formatError(error),
});
},
});
}

private async updateChatSubscription(): Promise<void> {
if (!this.isWebviewReady || !this.view) {
return;
Expand Down
41 changes: 41 additions & 0 deletions vscode/src/workspaceMetadataPump.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, test } from "bun:test";

import type { FrontendWorkspaceMetadata } from "xum/common/types/workspace";
import { pumpWorkspaceMetadata, type WorkspaceMetadataPumpClient } from "./workspaceMetadataPump";

const workspace = (id: string) => ({ id, name: id }) as FrontendWorkspaceMetadata;

// The host replaces the subscription on every refresh (#5109); the old one must go quiet.
describe("pumpWorkspaceMetadata", () => {
test("an aborted subscription ignores its late events and errors", async () => {
const controller = new AbortController();
const client: WorkspaceMetadataPumpClient = {
workspace: {
onMetadata: () =>
Promise.resolve(
(async function* () {
yield { type: "snapshot" as const, workspaces: [workspace("parent")] };
controller.abort(); // replaced while the next event was in flight
yield { workspaceId: "child", metadata: workspace("child") };
throw new Error("stream closed");
})()
),
},
};
const seen: string[] = [];
const errors: unknown[] = [];
await pumpWorkspaceMetadata({
client,
signal: controller.signal,
onSnapshot: (workspaces) => {
seen.push(...workspaces.map((w) => w.id));
},
onUpdate: (workspaceId) => {
seen.push(workspaceId);
},
onError: (error) => errors.push(error),
});
expect(seen).toEqual(["parent"]);
expect(errors).toEqual([]);
});
});
Loading
Loading