Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
10 changes: 10 additions & 0 deletions resources/beads-icon-editor-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions resources/beads-icon-editor-light.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
91 changes: 58 additions & 33 deletions src/backend/BeadsCommandRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -152,41 +197,21 @@ export class BeadsCommandRunner implements BeadsBackend {
}

async update(args: UpdateIssueArgs): Promise<BeadsIssue> {
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");
Expand Down
80 changes: 79 additions & 1 deletion src/backend/__tests__/BeadsCommandRunner.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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<unknown> },
"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",
]);
});
});
36 changes: 26 additions & 10 deletions src/providers/BaseViewProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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<void>;
protected abstract loadData(
reason?: "initial" | "projectChange" | "manualRefresh" | "background",
target?: WebviewHost
): Promise<void>;

/**
* Handles messages from the webview. Override in subclasses for custom handling.
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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");
}

Expand Down
Loading
Loading