diff --git a/resources/beads-icon-editor-dark.svg b/resources/beads-icon-editor-dark.svg new file mode 100644 index 0000000..271c0c5 --- /dev/null +++ b/resources/beads-icon-editor-dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/resources/beads-icon-editor-light.svg b/resources/beads-icon-editor-light.svg new file mode 100644 index 0000000..3581f9a --- /dev/null +++ b/resources/beads-icon-editor-light.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/backend/BeadsCommandRunner.ts b/src/backend/BeadsCommandRunner.ts index ff0d015..007b3c7 100644 --- a/src/backend/BeadsCommandRunner.ts +++ b/src/backend/BeadsCommandRunner.ts @@ -54,6 +54,51 @@ export function createShowCommandArgs(id: string): string[] { return ["show", id, "--json", "--include-dependents"]; } +export function createUpdateCommandArgs( + args: UpdateIssueArgs, + currentLabels: string[] = [] +): string[] { + const cmdArgs = ["update", args.id, "--json"]; + + if (args.title !== undefined) cmdArgs.push("--title", args.title); + if (args.description !== undefined) cmdArgs.push("--description", args.description); + if (args.design !== undefined) cmdArgs.push("--design", args.design); + if (args.acceptance_criteria !== undefined) cmdArgs.push("--acceptance", args.acceptance_criteria); + if (args.notes !== undefined) cmdArgs.push("--notes", args.notes); + if (args.status !== undefined) cmdArgs.push("--status", args.status); + if (args.priority !== undefined) cmdArgs.push("--priority", String(args.priority)); + if (args.assignee !== undefined) cmdArgs.push("--assignee", args.assignee); + if (args.external_ref !== undefined) cmdArgs.push("--external-ref", args.external_ref); + if ( + args.estimated_minutes !== undefined && + args.estimate !== undefined && + args.estimated_minutes !== args.estimate + ) { + throw new Error("Conflicting estimate values: estimated_minutes and estimate differ"); + } + const estimate = args.estimated_minutes ?? args.estimate; + if (estimate !== undefined) cmdArgs.push("--estimate", String(estimate)); + if ( + args.type !== undefined && + args.issue_type !== undefined && + args.type !== args.issue_type + ) { + throw new Error("Conflicting issue type values"); + } + const issueType = args.issue_type ?? args.type; + if (issueType !== undefined) cmdArgs.push("--type", issueType); + + for (const label of args.add_labels ?? []) cmdArgs.push("--add-label", label); + for (const label of args.remove_labels ?? []) cmdArgs.push("--remove-label", label); + if (args.set_labels?.length === 0) { + for (const label of currentLabels) cmdArgs.push("--remove-label", label); + } else { + for (const label of args.set_labels ?? []) cmdArgs.push("--set-labels", label); + } + + return cmdArgs; +} + export class BeadsCommandRunner implements BeadsBackend { private readonly bdPath: string; private readonly cwd: string; @@ -152,41 +197,21 @@ export class BeadsCommandRunner implements BeadsBackend { } async update(args: UpdateIssueArgs): Promise { - const cmdArgs = ["update", args.id, "--json"]; - - if (args.title !== undefined) cmdArgs.push("--title", args.title); - if (args.description !== undefined) cmdArgs.push("--description", args.description); - if (args.design !== undefined) cmdArgs.push("--design", args.design); - if (args.acceptance_criteria !== undefined) { - cmdArgs.push("--acceptance", args.acceptance_criteria); - } - if (args.notes !== undefined) cmdArgs.push("--notes", args.notes); - if (args.status !== undefined) cmdArgs.push("--status", args.status); - if (args.priority !== undefined) cmdArgs.push("--priority", String(args.priority)); - if (args.assignee !== undefined) cmdArgs.push("--assignee", args.assignee); - if (args.external_ref !== undefined) cmdArgs.push("--external-ref", args.external_ref); - if ( - args.estimated_minutes !== undefined && - args.estimate !== undefined && - args.estimated_minutes !== args.estimate - ) { - throw new Error("Conflicting estimate values: estimated_minutes and estimate differ"); - } - const estimate = args.estimated_minutes ?? args.estimate; - if (estimate !== undefined) cmdArgs.push("--estimate", String(estimate)); - if ( - args.type !== undefined && - args.issue_type !== undefined && - args.type !== args.issue_type - ) { - throw new Error("Conflicting issue type values"); + let currentIssue: BeadsIssue | null = null; + if (args.set_labels?.length === 0) { + const result = await this.runJson(createShowCommandArgs(args.id)); + currentIssue = Array.isArray(result) + ? (result[0] as BeadsIssue | undefined) ?? null + : (result as BeadsIssue) ?? null; + if (!currentIssue) { + throw new Error(`Issue not found: ${args.id}`); + } } - const issueType = args.issue_type ?? args.type; - if (issueType !== undefined) cmdArgs.push("--type", issueType); - for (const label of args.add_labels ?? []) cmdArgs.push("--add-label", label); - for (const label of args.remove_labels ?? []) cmdArgs.push("--remove-label", label); - for (const label of args.set_labels ?? []) cmdArgs.push("--set-labels", label); + const cmdArgs = createUpdateCommandArgs(args, currentIssue?.labels); + if (cmdArgs.length === 3 && currentIssue) { + return currentIssue; + } const result = await this.runJson(cmdArgs); return this.pickSingleIssue(result, "update"); diff --git a/src/backend/__tests__/BeadsCommandRunner.test.ts b/src/backend/__tests__/BeadsCommandRunner.test.ts index 70d880e..63a7e44 100644 --- a/src/backend/__tests__/BeadsCommandRunner.test.ts +++ b/src/backend/__tests__/BeadsCommandRunner.test.ts @@ -1,5 +1,11 @@ -import { createListCommandArgs, createShowCommandArgs } from "../BeadsCommandRunner"; +import { + BeadsCommandRunner, + createListCommandArgs, + createShowCommandArgs, + createUpdateCommandArgs, +} from "../BeadsCommandRunner"; import { MIN_SUPPORTED_BD_VERSION } from "../BeadsBackend"; +import { Logger } from "../../utils/logger"; describe("createListCommandArgs", () => { it("requests all issues without the CLI default limit", () => { @@ -23,3 +29,75 @@ describe("createShowCommandArgs", () => { expect(major * 10000 + minor * 100 + patch).toBeGreaterThanOrEqual(10005); }); }); + +describe("createUpdateCommandArgs", () => { + it("serializes empty strings and zero values used to clear optional fields", () => { + expect(createUpdateCommandArgs({ + id: "bd-1", + external_ref: "", + estimated_minutes: 0, + })).toEqual([ + "update", + "bd-1", + "--json", + "--external-ref", + "", + "--estimate", + "0", + ]); + }); + + it("removes current labels when the replacement set is empty", () => { + expect(createUpdateCommandArgs( + { id: "bd-1", set_labels: [] }, + ["bug", "ui"] + )).toEqual([ + "update", + "bd-1", + "--json", + "--remove-label", + "bug", + "--remove-label", + "ui", + ]); + }); + + it("reads labels without the show cache before removing the final labels", async () => { + const log = { child: () => log } as unknown as Logger; + const runner = new BeadsCommandRunner({ + bdPath: "bd", + cwd: "/tmp", + beadsDir: "/tmp/.beads", + log, + }); + const issue = { + id: "bd-1", + title: "Issue", + status: "open", + priority: 2, + issue_type: "task", + labels: ["bug", "ui"], + created_at: "2026-08-29T00:00:00Z", + updated_at: "2026-08-29T00:00:00Z", + }; + const runJson = jest.spyOn( + runner as unknown as { runJson: (args: string[]) => Promise }, + "runJson" + ) + .mockResolvedValueOnce([issue]) + .mockResolvedValueOnce([{ ...issue, labels: [] }]); + + await runner.update({ id: "bd-1", set_labels: [] }); + + expect(runJson).toHaveBeenNthCalledWith(1, createShowCommandArgs("bd-1")); + expect(runJson).toHaveBeenNthCalledWith(2, [ + "update", + "bd-1", + "--json", + "--remove-label", + "bug", + "--remove-label", + "ui", + ]); + }); +}); diff --git a/src/providers/BaseViewProvider.ts b/src/providers/BaseViewProvider.ts index a940118..791b9c9 100644 --- a/src/providers/BaseViewProvider.ts +++ b/src/providers/BaseViewProvider.ts @@ -160,7 +160,10 @@ export abstract class BaseViewProvider implements vscode.WebviewViewProvider { // Options and HTML are not preserved across a reload, so always re-apply. panel.webview.options = this.getWebviewOptions(); panel.webview.html = this.getHtmlForWebview(panel.webview); - panel.iconPath = vscode.Uri.joinPath(this.extensionUri, "resources", "beads-icon.svg"); + panel.iconPath = { + light: vscode.Uri.joinPath(this.extensionUri, "resources", "beads-icon-editor-light.svg"), + dark: vscode.Uri.joinPath(this.extensionUri, "resources", "beads-icon-editor-dark.svg"), + }; this.editorPanel = panel; const host: WebviewHost = { @@ -316,16 +319,26 @@ export abstract class BaseViewProvider implements vscode.WebviewViewProvider { }, }, target); - // Load view-specific data only while some surface is visible. - if (this.isVisible) { - await this.loadData("initial"); + this.seedView(target); + + // Load view-specific data only while the initialized surface is visible. + if (target?.visible ?? this.isVisible) { + await this.loadData("initial", target); } } + /** Seeds provider-specific state that is not part of a backend load. */ + protected seedView(_target?: WebviewHost): void { + // Default: nothing to seed + } + /** * Loads view-specific data. Override in subclasses. */ - protected abstract loadData(reason?: "initial" | "projectChange" | "manualRefresh" | "background"): Promise; + protected abstract loadData( + reason?: "initial" | "projectChange" | "manualRefresh" | "background", + target?: WebviewHost + ): Promise; /** * Handles messages from the webview. Override in subclasses for custom handling. @@ -488,15 +501,15 @@ export abstract class BaseViewProvider implements vscode.WebviewViewProvider { /** * Sets the loading state in the webview */ - protected setLoading(loading: boolean): void { - this.postMessage({ type: "setLoading", loading }); + protected setLoading(loading: boolean, target?: WebviewHost): void { + this.postMessage({ type: "setLoading", loading }, target); } /** * Sets an error message in the webview */ - protected setError(error: string | null): void { - this.postMessage({ type: "setError", error }); + protected setError(error: string | null, target?: WebviewHost): void { + this.postMessage({ type: "setError", error }, target); } /** @@ -535,11 +548,14 @@ export abstract class BaseViewProvider implements vscode.WebviewViewProvider { * Triggers a refresh intended for active project switches. */ public refreshForProjectChange(): void { + // Retained hidden webviews also need the new project identity so their + // persisted state cannot remain bound to the previous project. + this.postProjectState(); + if (!this.isVisible) { return; } - this.postProjectState(); this.loadData("projectChange"); } diff --git a/src/providers/BeadDetailsViewProvider.ts b/src/providers/BeadDetailsViewProvider.ts index f9e1e1e..2016a3e 100644 --- a/src/providers/BeadDetailsViewProvider.ts +++ b/src/providers/BeadDetailsViewProvider.ts @@ -9,9 +9,9 @@ */ import * as vscode from "vscode"; -import { BaseViewProvider, NavigationOrigin } from "./BaseViewProvider"; +import { BaseViewProvider, NavigationOrigin, WebviewHost } from "./BaseViewProvider"; import { BeadsProjectManager } from "../backend/BeadsProjectManager"; -import { WebviewToExtensionMessage, issueToWebviewBead } from "../backend/types"; +import { Bead, WebviewToExtensionMessage, issueToWebviewBead } from "../backend/types"; import { Logger } from "../utils/logger"; import { buildUpdateArgs } from "./bead-updates"; @@ -19,9 +19,17 @@ export class BeadDetailsViewProvider extends BaseViewProvider { protected readonly viewType = "beadsDetails"; protected readonly panelViewType = "beads.detailsEditor"; protected readonly panelTitle = "Beads Details"; + private static readonly SNAPSHOT_TTL_MS = 1000; private currentBeadId: string | null = null; private currentProjectId: string | null = null; private loadSequence = 0; // Tracks request order to prevent stale responses + private readonly targetLoadTokens = new WeakMap(); + private snapshot: { + projectId: string; + beadId: string; + bead: Bead; + loadedAt: number; + } | null = null; constructor( extensionUri: vscode.Uri, @@ -40,6 +48,7 @@ export class BeadDetailsViewProvider extends BaseViewProvider { // Update context for conditional menu items vscode.commands.executeCommand("setContext", "beads.hasSelectedBead", true); + this.postMessage({ type: "setSelectedBeadId", beadId }); // Auto-expand the details view on the surface the request came from, // creating the editor tab if that is where the request originated @@ -53,13 +62,27 @@ export class BeadDetailsViewProvider extends BaseViewProvider { * Restores the bead an editor tab was showing before a window reload. */ protected restoreEditorState(state: unknown): void { - const beadId = (state as { beadId?: unknown } | null | undefined)?.beadId; - if (typeof beadId !== "string" || beadId.length === 0) { + const restored = state as { + version?: unknown; + projectId?: unknown; + beadId?: unknown; + } | null | undefined; + const activeProjectId = this.projectManager.getActiveProject()?.id ?? null; + if ( + restored?.version !== 1 || + typeof restored.projectId !== "string" || + restored.projectId !== activeProjectId || + typeof restored.beadId !== "string" || + restored.beadId.length === 0 + ) { + this.currentBeadId = null; + this.currentProjectId = activeProjectId; + vscode.commands.executeCommand("setContext", "beads.hasSelectedBead", false); return; } - this.currentBeadId = beadId; - this.currentProjectId = this.projectManager.getActiveProject()?.id || null; + this.currentBeadId = restored.beadId; + this.currentProjectId = restored.projectId; vscode.commands.executeCommand("setContext", "beads.hasSelectedBead", true); } @@ -85,49 +108,99 @@ export class BeadDetailsViewProvider extends BaseViewProvider { * Clear the current bead (e.g., when switching projects) */ public clearBead(): void { + this.loadSequence++; + this.snapshot = null; this.currentBeadId = null; vscode.commands.executeCommand("setContext", "beads.hasSelectedBead", false); this.setEditorPanelTitle(this.panelTitle); + this.postMessage({ type: "setSelectedBeadId", beadId: null }); this.postMessage({ type: "setBead", bead: null }); this.setLoading(false); } - protected async loadData(_reason: "initial" | "projectChange" | "manualRefresh" | "background" = "background"): Promise { + public refreshForProjectChange(): void { + const activeProjectId = this.projectManager.getActiveProject()?.id ?? null; + if (this.currentBeadId && activeProjectId !== this.currentProjectId) { + this.currentProjectId = activeProjectId; + this.clearBead(); + } else { + this.currentProjectId = activeProjectId; + } + super.refreshForProjectChange(); + } + + protected seedView(target?: WebviewHost): void { + this.postMessage({ type: "setSelectedBeadId", beadId: this.currentBeadId }, target); + } + + protected async loadData( + reason: "initial" | "projectChange" | "manualRefresh" | "background" = "background", + target?: WebviewHost + ): Promise { + const activeProjectId = this.projectManager.getActiveProject()?.id ?? null; + const beadId = this.currentBeadId; + if ( + reason === "initial" && + target && + activeProjectId && + beadId && + this.snapshot?.projectId === activeProjectId && + this.snapshot.beadId === beadId + ) { + this.postMessage({ type: "setBead", bead: this.snapshot.bead }, target); + this.setError(null, target); + this.setLoading(false, target); + if (Date.now() - this.snapshot.loadedAt > BeadDetailsViewProvider.SNAPSHOT_TTL_MS) { + await this.loadData("background"); + } + return; + } + // Increment sequence to track this request - prevents stale responses from // overwriting newer data when multiple refreshes occur in rapid succession - const thisRequest = ++this.loadSequence; + const targetLoadToken = target ? Symbol() : null; + if (target && targetLoadToken) { + this.targetLoadTokens.set(target, targetLoadToken); + } + const thisRequest = target ? this.loadSequence : ++this.loadSequence; + const isCurrentRequest = () => + thisRequest === this.loadSequence && + (!target || this.targetLoadTokens.get(target) === targetLoadToken); const client = this.projectManager.getClient(); - const activeProjectId = this.projectManager.getActiveProject()?.id; // Clear selection if project changed. Goes through clearBead so the menu // context and the editor tab title are reset too, not just the id. - if (this.currentProjectId && activeProjectId !== this.currentProjectId) { - this.currentProjectId = activeProjectId || null; + if (this.currentBeadId && activeProjectId !== this.currentProjectId) { + this.currentProjectId = activeProjectId; this.clearBead(); } if (!client || !this.currentBeadId) { - this.postMessage({ type: "setBead", bead: null }); - this.setLoading(false); + this.postMessage({ type: "setBead", bead: null }, target); + this.setLoading(false, target); return; } - this.setLoading(true); - this.setError(null); + this.setLoading(true, target); + this.setError(null, target); try { // Fetch issue and comments in parallel const [issue, comments] = await Promise.all([ - client.show(this.currentBeadId), - client.listComments(this.currentBeadId).catch((err) => { + client.show(beadId!), + client.listComments(beadId!).catch((err) => { this.log.trace(`Failed to fetch comments: ${err}`); return []; }), ]); // Check if a newer request has started - if so, discard this stale response - if (thisRequest !== this.loadSequence) { + if ( + !isCurrentRequest() || + (this.projectManager.getActiveProject()?.id ?? null) !== activeProjectId || + this.currentBeadId !== beadId + ) { this.log.debug(`Discarding stale response (request ${thisRequest}, current ${this.loadSequence})`); return; } @@ -142,27 +215,33 @@ export class BeadDetailsViewProvider extends BaseViewProvider { }; const bead = issueToWebviewBead(issueWithComments); if (bead) { - this.postMessage({ type: "setBead", bead }); + this.snapshot = { + projectId: activeProjectId!, + beadId: beadId!, + bead, + loadedAt: Date.now(), + }; + this.postMessage({ type: "setBead", bead }, target); } else { - this.setError("Invalid bead status"); - this.postMessage({ type: "setBead", bead: null }); + this.setError("Invalid bead status", target); + this.postMessage({ type: "setBead", bead: null }, target); } } else { - this.setError("Bead not found"); - this.postMessage({ type: "setBead", bead: null }); + this.setError("Bead not found", target); + this.postMessage({ type: "setBead", bead: null }, target); } } catch (err) { // Only handle error if this is still the current request - if (thisRequest !== this.loadSequence) { + if (!isCurrentRequest() || this.currentBeadId !== beadId) { return; } - this.setError(String(err)); - this.postMessage({ type: "setBead", bead: null }); + this.setError(String(err), target); + this.postMessage({ type: "setBead", bead: null }, target); this.handleBackendError("Failed to load bead details", err); } finally { // Only update loading state if this is still the current request - if (thisRequest === this.loadSequence) { - this.setLoading(false); + if (isCurrentRequest()) { + this.setLoading(false, target); } } } diff --git a/src/providers/BeadsPanelViewProvider.ts b/src/providers/BeadsPanelViewProvider.ts index 93036c5..c3acdbb 100644 --- a/src/providers/BeadsPanelViewProvider.ts +++ b/src/providers/BeadsPanelViewProvider.ts @@ -10,7 +10,7 @@ */ import * as vscode from "vscode"; -import { BaseViewProvider } from "./BaseViewProvider"; +import { BaseViewProvider, WebviewHost } from "./BaseViewProvider"; import { BeadsProjectManager } from "../backend/BeadsProjectManager"; import { WebviewToExtensionMessage, Bead, issueToWebviewBead } from "../backend/types"; import { Logger } from "../utils/logger"; @@ -21,8 +21,11 @@ export class BeadsPanelViewProvider extends BaseViewProvider { protected readonly panelViewType = "beads.issuesEditor"; protected readonly panelTitle = "Beads Issues"; private static readonly MIN_LOADING_MS = 500; + private static readonly SNAPSHOT_TTL_MS = 1000; private selectedBeadId: string | null = null; private loadSequence = 0; + private readonly targetLoadTokens = new WeakMap(); + private snapshot: { projectId: string | null; beads: Bead[]; loadedAt: number } | null = null; constructor( extensionUri: vscode.Uri, @@ -40,51 +43,80 @@ export class BeadsPanelViewProvider extends BaseViewProvider { this.postMessage({ type: "setSelectedBeadId", beadId }); } - protected async loadData(reason: "initial" | "projectChange" | "manualRefresh" | "background" = "background"): Promise { - const thisRequest = ++this.loadSequence; + protected seedView(target?: WebviewHost): void { + this.postMessage({ type: "setSelectedBeadId", beadId: this.selectedBeadId }, target); + } + + protected async loadData( + reason: "initial" | "projectChange" | "manualRefresh" | "background" = "background", + target?: WebviewHost + ): Promise { + const projectId = this.projectManager.getActiveProject()?.id ?? null; + if (reason === "initial" && target && this.snapshot?.projectId === projectId) { + this.postMessage({ type: "setBeads", beads: this.snapshot.beads }, target); + this.setError(null, target); + this.setLoading(false, target); + if (Date.now() - this.snapshot.loadedAt > BeadsPanelViewProvider.SNAPSHOT_TTL_MS) { + await this.loadData("background"); + } + return; + } + + const targetLoadToken = target ? Symbol() : null; + if (target && targetLoadToken) { + this.targetLoadTokens.set(target, targetLoadToken); + } + const thisRequest = target ? this.loadSequence : ++this.loadSequence; + const isCurrentRequest = () => + thisRequest === this.loadSequence && + (!target || this.targetLoadTokens.get(target) === targetLoadToken); const client = this.projectManager.getClient(); if (!client) { + this.snapshot = { projectId, beads: [], loadedAt: Date.now() }; // No project/backend: clear loading so the webview shows the empty state // instead of spinning forever (#76) - this.postMessage({ type: "setBeads", beads: [] }); - this.setLoading(false); + this.postMessage({ type: "setBeads", beads: [] }, target); + this.setLoading(false, target); return; } const showLoading = reason === "initial" || reason === "projectChange" || reason === "manualRefresh"; const loadingStartedAt = showLoading ? Date.now() : 0; if (showLoading) { - this.postMessage({ type: "setBeads", beads: [] }); - this.setLoading(true); + this.postMessage({ type: "setBeads", beads: [] }, target); + this.setLoading(true, target); } - this.setError(null); + this.setError(null, target); try { const issues = await client.list(); if (showLoading) { await this.waitForMinimumLoading(loadingStartedAt); } - if (thisRequest !== this.loadSequence) { + if (!isCurrentRequest() || + (this.projectManager.getActiveProject()?.id ?? null) !== projectId) { return; } const beads = issues.map(issueToWebviewBead).filter((b): b is Bead => b !== null); - this.postMessage({ type: "setBeads", beads }); - this.setLoading(false); + this.snapshot = { projectId, beads, loadedAt: Date.now() }; + this.postMessage({ type: "setBeads", beads }, target); + this.setLoading(false, target); } catch (err) { if (showLoading) { await this.waitForMinimumLoading(loadingStartedAt); } - if (thisRequest !== this.loadSequence) { + if (!isCurrentRequest() || + (this.projectManager.getActiveProject()?.id ?? null) !== projectId) { return; } - this.setError(String(err)); + this.setError(String(err), target); if (showLoading) { - this.postMessage({ type: "setBeads", beads: [] }); + this.postMessage({ type: "setBeads", beads: [] }, target); } this.handleBackendError("Failed to load beads", err); } finally { - if (thisRequest === this.loadSequence) { - this.setLoading(false); + if (isCurrentRequest()) { + this.setLoading(false, target); } } } diff --git a/src/providers/DashboardViewProvider.ts b/src/providers/DashboardViewProvider.ts index 69eb2fe..a2330bc 100644 --- a/src/providers/DashboardViewProvider.ts +++ b/src/providers/DashboardViewProvider.ts @@ -9,7 +9,7 @@ */ import * as vscode from "vscode"; -import { BaseViewProvider } from "./BaseViewProvider"; +import { BaseViewProvider, WebviewHost } from "./BaseViewProvider"; import { BeadsProjectManager } from "../backend/BeadsProjectManager"; import { Bead, BeadsSummary, issueToWebviewBead, BeadPriority, BUILT_IN_STATUSES } from "../backend/types"; import { Logger } from "../utils/logger"; @@ -19,7 +19,15 @@ export class DashboardViewProvider extends BaseViewProvider { protected readonly panelViewType = "beads.dashboardEditor"; protected readonly panelTitle = "Beads Dashboard"; private static readonly MIN_LOADING_MS = 500; + private static readonly SNAPSHOT_TTL_MS = 1000; private loadSequence = 0; + private readonly targetLoadTokens = new WeakMap(); + private snapshot: { + projectId: string | null; + summary: BeadsSummary; + beads: Bead[]; + loadedAt: number; + } | null = null; constructor( extensionUri: vscode.Uri, @@ -29,43 +37,68 @@ export class DashboardViewProvider extends BaseViewProvider { super(extensionUri, projectManager, logger.child("Dashboard")); } - protected async loadData(reason: "initial" | "projectChange" | "manualRefresh" | "background" = "background"): Promise { - const thisRequest = ++this.loadSequence; + protected async loadData( + reason: "initial" | "projectChange" | "manualRefresh" | "background" = "background", + target?: WebviewHost + ): Promise { + const projectId = this.projectManager.getActiveProject()?.id ?? null; + if (reason === "initial" && target && this.snapshot?.projectId === projectId) { + this.postMessage({ type: "setSummary", summary: this.snapshot.summary }, target); + this.postMessage({ type: "setBeads", beads: this.snapshot.beads }, target); + this.setError(null, target); + this.setLoading(false, target); + if (Date.now() - this.snapshot.loadedAt > DashboardViewProvider.SNAPSHOT_TTL_MS) { + await this.loadData("background"); + } + return; + } + + const targetLoadToken = target ? Symbol() : null; + if (target && targetLoadToken) { + this.targetLoadTokens.set(target, targetLoadToken); + } + const thisRequest = target ? this.loadSequence : ++this.loadSequence; + const isCurrentRequest = () => + thisRequest === this.loadSequence && + (!target || this.targetLoadTokens.get(target) === targetLoadToken); const client = this.projectManager.getClient(); if (!client) { + const summary: BeadsSummary = { + total: 0, + byStatus: Object.fromEntries(BUILT_IN_STATUSES.map((s) => [s, 0])), + byPriority: { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0 }, + readyCount: 0, + blockedCount: 0, + inProgressCount: 0, + }; + this.snapshot = { projectId, summary, beads: [], loadedAt: Date.now() }; this.postMessage({ type: "setSummary", - summary: { - total: 0, - byStatus: Object.fromEntries(BUILT_IN_STATUSES.map((s) => [s, 0])), - byPriority: { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0 }, - readyCount: 0, - blockedCount: 0, - inProgressCount: 0, - }, - }); + summary, + }, target); // No project/backend: clear loading so the webview shows the empty state // instead of spinning forever (#76) - this.postMessage({ type: "setBeads", beads: [] }); - this.setLoading(false); + this.postMessage({ type: "setBeads", beads: [] }, target); + this.setLoading(false, target); return; } const showLoading = reason === "initial" || reason === "projectChange" || reason === "manualRefresh"; const loadingStartedAt = showLoading ? Date.now() : 0; if (showLoading) { - this.postMessage({ type: "setSummary", summary: null }); - this.postMessage({ type: "setBeads", beads: [] }); - this.setLoading(true); + this.postMessage({ type: "setSummary", summary: null }, target); + this.postMessage({ type: "setBeads", beads: [] }, target); + this.setLoading(true, target); } - this.setError(null); + this.setError(null, target); try { const issues = await client.list(); if (showLoading) { await this.waitForMinimumLoading(loadingStartedAt); } - if (thisRequest !== this.loadSequence) { + if (!isCurrentRequest() || + (this.projectManager.getActiveProject()?.id ?? null) !== projectId) { return; } @@ -91,25 +124,27 @@ export class DashboardViewProvider extends BaseViewProvider { inProgressCount: byStatus.in_progress, }; - this.postMessage({ type: "setSummary", summary }); - const openBeads = beads.filter((b) => b.status === "open").slice(0, 5); const blockedBeads = beads.filter((b) => b.status === "blocked").slice(0, 5); const inProgressBeads = beads.filter((b) => b.status === "in_progress").slice(0, 5); - this.postMessage({ type: "setBeads", beads: [...openBeads, ...blockedBeads, ...inProgressBeads] }); - this.setLoading(false); + const dashboardBeads = [...openBeads, ...blockedBeads, ...inProgressBeads]; + this.snapshot = { projectId, summary, beads: dashboardBeads, loadedAt: Date.now() }; + this.postMessage({ type: "setSummary", summary }, target); + this.postMessage({ type: "setBeads", beads: dashboardBeads }, target); + this.setLoading(false, target); } catch (err) { if (showLoading) { await this.waitForMinimumLoading(loadingStartedAt); } - if (thisRequest !== this.loadSequence) { + if (!isCurrentRequest() || + (this.projectManager.getActiveProject()?.id ?? null) !== projectId) { return; } - this.setError(String(err)); + this.setError(String(err), target); this.handleBackendError("Failed to load dashboard", err); } finally { - if (thisRequest === this.loadSequence) { - this.setLoading(false); + if (isCurrentRequest()) { + this.setLoading(false, target); } } } diff --git a/src/providers/__tests__/bead-updates.test.ts b/src/providers/__tests__/bead-updates.test.ts index 3f0e4e6..bc28dcb 100644 --- a/src/providers/__tests__/bead-updates.test.ts +++ b/src/providers/__tests__/bead-updates.test.ts @@ -76,6 +76,23 @@ describe("buildUpdateArgs", () => { expect(buildUpdateArgs("bd-1", { priority: 1.5 })).toHaveProperty("error"); }); + it("accepts the supported clear-value representations", () => { + const { args } = expectArgs( + buildUpdateArgs("bd-1", { + externalRef: "", + estimatedMinutes: 0, + labels: [], + }) + ); + + expect(args).toEqual({ + id: "bd-1", + external_ref: "", + estimated_minutes: 0, + set_labels: [], + }); + }); + it("rejects a message with no usable target or payload", () => { expect(buildUpdateArgs("", { status: "open" })).toHaveProperty("error"); expect(buildUpdateArgs(42, { status: "open" })).toHaveProperty("error"); diff --git a/src/providers/__tests__/editor-panels.test.ts b/src/providers/__tests__/editor-panels.test.ts index bfe7060..82204c8 100644 --- a/src/providers/__tests__/editor-panels.test.ts +++ b/src/providers/__tests__/editor-panels.test.ts @@ -12,9 +12,11 @@ import { FakeWebview, } from "../../__mocks__/vscode"; import { BeadDetailsViewProvider } from "../BeadDetailsViewProvider"; +import { BeadsPanelViewProvider } from "../BeadsPanelViewProvider"; import { DashboardViewProvider } from "../DashboardViewProvider"; import { BeadsProjectManager } from "../../backend/BeadsProjectManager"; import { ExtensionToWebviewMessage } from "../../backend/types"; +import { BeadsBackend, BeadsIssue } from "../../backend/BeadsBackend"; import { Logger } from "../../utils/logger"; interface Harness { @@ -38,13 +40,14 @@ function makeLogger(): Logger { */ function harness( Provider: new (uri: vscode.Uri, pm: BeadsProjectManager, log: Logger) => T, - activeProjectId: string | null = "project-a" + activeProjectId: string | null = "project-a", + client: Partial | null = null ): Harness { const posted: ExtensionToWebviewMessage[] = []; let projectId = activeProjectId; const projectManager = { - getClient: () => null, + getClient: () => client, getActiveProject: () => (projectId ? { id: projectId, name: projectId } : null), getProjects: () => [], } as unknown as BeadsProjectManager; @@ -94,6 +97,19 @@ describe("editor tab lifecycle", () => { expect(createdPanels[0].revealCalls).toEqual([{ column: undefined, preserveFocus: false }]); }); + it("uses theme-specific editor tab icons", () => { + const { provider } = harness(DashboardViewProvider); + + provider.showInEditor(); + + const iconPath = createdPanels[0].iconPath as { + light: { fsPath: string }; + dark: { fsPath: string }; + }; + expect(iconPath.light.fsPath).toMatch(/beads-icon-editor-light\.svg$/); + expect(iconPath.dark.fsPath).toMatch(/beads-icon-editor-dark\.svg$/); + }); + it("stops posting to a panel once it is disposed", async () => { const { provider } = harness(DashboardViewProvider); provider.showInEditor(); @@ -208,36 +224,204 @@ describe("serializer restoration", () => { provider.adoptEditorPanel( vscode.window.createWebviewPanel("beads.detailsEditor", "Beads Details", 1, {}) as unknown as vscode.WebviewPanel, - { beadId: "bd-42" } + { version: 1, projectId: "project-a", beadId: "bd-42" } ); expect(provider.getCurrentBeadId()).toBe("bd-42"); }); - it("ignores malformed persisted state", () => { + it("ignores persisted state for another project", () => { const { provider } = harness(BeadDetailsViewProvider); provider.adoptEditorPanel( vscode.window.createWebviewPanel("beads.detailsEditor", "Beads Details", 1, {}) as unknown as vscode.WebviewPanel, - { beadId: 42 } + { version: 1, projectId: "project-b", beadId: "bd-42" } ); expect(provider.getCurrentBeadId()).toBeNull(); }); + + it("ignores legacy and malformed persisted state", () => { + const { provider } = harness(BeadDetailsViewProvider); + + provider.adoptEditorPanel( + vscode.window.createWebviewPanel("beads.detailsEditor", "Beads Details", 1, {}) as unknown as vscode.WebviewPanel, + { beadId: "bd-42" } + ); + + expect(provider.getCurrentBeadId()).toBeNull(); + }); + + it("seeds a hidden restored tab with its bead id before loading", async () => { + const { provider } = harness(BeadDetailsViewProvider); + const panel = vscode.window.createWebviewPanel( + "beads.detailsEditor", + "Beads Details", + 1, + {} + ) as unknown as vscode.WebviewPanel; + provider.adoptEditorPanel(panel, { + version: 1, + projectId: "project-a", + beadId: "bd-42", + }); + const fakePanel = createdPanels[0]; + fakePanel.setVisible(false); + const seen: ExtensionToWebviewMessage[] = []; + fakePanel.webview.postMessage = (message) => seen.push(message as ExtensionToWebviewMessage); + + await fakePanel.webview.emit({ type: "ready" }); + + expect(seen).toContainEqual({ type: "setSelectedBeadId", beadId: "bd-42" }); + }); }); describe("project switching", () => { - it("clears the Details selection and its menu context", async () => { + it("clears a hidden Details selection and its menu context immediately", async () => { const spy = jest.spyOn(vscode.commands, "executeCommand"); const { provider, setActiveProjectId } = harness(BeadDetailsViewProvider); - await provider.showBead("bd-1"); + await provider.showBead("bd-1", { surface: "editor" }); expect(provider.getCurrentBeadId()).toBe("bd-1"); + createdPanels[0].setVisible(false); + const seen: ExtensionToWebviewMessage[] = []; + createdPanels[0].webview.postMessage = (message) => seen.push(message as ExtensionToWebviewMessage); setActiveProjectId("project-b"); - await (provider as unknown as { loadData: () => Promise }).loadData(); + provider.refreshForProjectChange(); expect(provider.getCurrentBeadId()).toBeNull(); + expect(createdPanels[0].title).toBe("Beads Details"); expect(spy).toHaveBeenCalledWith("setContext", "beads.hasSelectedBead", false); + expect(seen).toContainEqual({ type: "setSelectedBeadId", beadId: null }); + }); +}); + +describe("host seeding", () => { + it("seeds a newly opened Issues host with the current selection", async () => { + const { provider } = harness(BeadsPanelViewProvider); + provider.setSelectedBead("bd-1"); + provider.showInEditor(); + + const seen: ExtensionToWebviewMessage[] = []; + createdPanels[0].webview.postMessage = (message) => seen.push(message as ExtensionToWebviewMessage); + await createdPanels[0].webview.emit({ type: "ready" }); + + expect(seen).toContainEqual({ type: "setSelectedBeadId", beadId: "bd-1" }); + }); + + it("replays cached Issues data only to the newly opened host", async () => { + const issue: BeadsIssue = { + id: "bd-1", + title: "Cached issue", + status: "open", + priority: 2, + issue_type: "task", + created_at: "2026-08-29T00:00:00Z", + updated_at: "2026-08-29T00:00:00Z", + }; + const list = jest.fn().mockResolvedValue([issue]); + const { provider, posted, attachSidebar } = harness( + BeadsPanelViewProvider, + "project-a", + { list } + ); + attachSidebar(); + await (provider as unknown as { + loadData: (reason: "background") => Promise; + }).loadData("background"); + posted.length = 0; + + provider.showInEditor(); + const editorMessages: ExtensionToWebviewMessage[] = []; + createdPanels[0].webview.postMessage = (message) => + editorMessages.push(message as ExtensionToWebviewMessage); + await createdPanels[0].webview.emit({ type: "ready" }); + + expect(list).toHaveBeenCalledTimes(1); + expect(editorMessages).toContainEqual(expect.objectContaining({ type: "setBeads" })); + expect(posted).toEqual([]); + }); + + it("does not let an older targeted load replace a newer refresh", async () => { + let resolveInitial!: (issues: BeadsIssue[]) => void; + const initial = new Promise((resolve) => { + resolveInitial = resolve; + }); + const oldIssue: BeadsIssue = { + id: "bd-old", + title: "Old issue", + status: "open", + priority: 2, + issue_type: "task", + created_at: "2026-08-29T00:00:00Z", + updated_at: "2026-08-29T00:00:00Z", + }; + const newIssue = { ...oldIssue, id: "bd-new", title: "New issue" }; + const list = jest.fn() + .mockReturnValueOnce(initial) + .mockResolvedValueOnce([newIssue]); + const { provider } = harness(BeadsPanelViewProvider, "project-a", { list }); + provider.showInEditor(); + const seen: ExtensionToWebviewMessage[] = []; + createdPanels[0].webview.postMessage = (message) => seen.push(message as ExtensionToWebviewMessage); + + const ready = createdPanels[0].webview.emit({ type: "ready" }); + await (provider as unknown as { + loadData: (reason: "background") => Promise; + }).loadData("background"); + resolveInitial([oldIssue]); + await ready; + + const beadMessages = seen.filter((message) => message.type === "setBeads"); + expect(beadMessages).toContainEqual({ + type: "setBeads", + beads: [expect.objectContaining({ id: "bd-new" })], + }); + expect(beadMessages).not.toContainEqual({ + type: "setBeads", + beads: [expect.objectContaining({ id: "bd-old" })], + }); + }); + + it("does not let an older load replace a newer load for the same host", async () => { + let resolveInitial!: (issues: BeadsIssue[]) => void; + const initial = new Promise((resolve) => { + resolveInitial = resolve; + }); + const oldIssue: BeadsIssue = { + id: "bd-old", + title: "Old issue", + status: "open", + priority: 2, + issue_type: "task", + created_at: "2026-08-29T00:00:00Z", + updated_at: "2026-08-29T00:00:00Z", + }; + const newIssue = { ...oldIssue, id: "bd-new", title: "New issue" }; + const list = jest.fn() + .mockReturnValueOnce(initial) + .mockResolvedValueOnce([newIssue]); + const { provider } = harness(BeadsPanelViewProvider, "project-a", { list }); + provider.showInEditor(); + const seen: ExtensionToWebviewMessage[] = []; + const panel = createdPanels[0]; + panel.webview.postMessage = (message) => seen.push(message as ExtensionToWebviewMessage); + + const firstReady = panel.webview.emit({ type: "ready" }); + const secondReady = panel.webview.emit({ type: "ready" }); + await secondReady; + resolveInitial([oldIssue]); + await firstReady; + + const beadMessages = seen.filter((message) => message.type === "setBeads"); + expect(beadMessages).toContainEqual({ + type: "setBeads", + beads: [expect.objectContaining({ id: "bd-new" })], + }); + expect(beadMessages).not.toContainEqual({ + type: "setBeads", + beads: [expect.objectContaining({ id: "bd-old" })], + }); }); }); diff --git a/src/webview/App.tsx b/src/webview/App.tsx index 0ea41ff..ccb1f9e 100644 --- a/src/webview/App.tsx +++ b/src/webview/App.tsx @@ -33,6 +33,7 @@ interface AppState { loading: boolean; error: string | null; settings: WebviewSettings; + projectInitialized: boolean; } const initialState: AppState = { @@ -46,6 +47,7 @@ const initialState: AppState = { loading: true, error: null, settings: { renderMarkdown: true, userId: "", tooltipHoverDelay: 1000 }, + projectInitialized: false, }; export function App(): React.ReactElement { @@ -60,7 +62,7 @@ export function App(): React.ReactElement { setState((prev) => ({ ...prev, viewType: message.viewType })); break; case "setProject": - setState((prev) => ({ ...prev, project: message.project })); + setState((prev) => ({ ...prev, project: message.project, projectInitialized: true })); break; case "setProjects": setState((prev) => ({ ...prev, projects: message.projects })); @@ -100,15 +102,22 @@ export function App(): React.ReactElement { useEffect(() => { // Skip the pre-initialization render: writing placeholders here would // clear a selection a restored tab still needs. - if (!state.viewType) { + if (!state.viewType || !state.projectInitialized) { return; } patchState({ + version: 1, viewType: state.viewType, - beadId: state.selectedBead?.id ?? state.selectedBeadId ?? null, + projectId: state.project?.id ?? null, + beadId: state.selectedBeadId, }); - }, [state.viewType, state.selectedBead?.id, state.selectedBeadId]); + }, [ + state.viewType, + state.projectInitialized, + state.project?.id, + state.selectedBeadId, + ]); useEffect(() => { // Listen for messages from the extension diff --git a/src/webview/views/DetailsView.tsx b/src/webview/views/DetailsView.tsx index 4e1571c..2cfc523 100644 --- a/src/webview/views/DetailsView.tsx +++ b/src/webview/views/DetailsView.tsx @@ -569,7 +569,7 @@ export function DetailsView({ handleFieldChange("externalRef", e.target.value || null)} + onChange={(e) => handleFieldChange("externalRef", e.target.value)} className="text-input" placeholder="URL or reference ID" /> @@ -586,8 +586,8 @@ export function DetailsView({ {editMode ? ( handleFieldChange("estimatedMinutes", e.target.value ? parseInt(e.target.value, 10) : null)} + value={displayBead.estimatedMinutes ?? ""} + onChange={(e) => handleFieldChange("estimatedMinutes", e.target.value ? parseInt(e.target.value, 10) : 0)} className="text-input estimate-input" placeholder="Minutes" min="0"