Pin selection rewrite editor-state behavior

This commit is contained in:
murashit 2026-06-11 23:41:35 +09:00
parent 84c1282f06
commit 4f428e20bd
3 changed files with 142 additions and 2 deletions

View file

@ -137,9 +137,11 @@ export class SelectionRewritePopover {
prompt: buildSelectionRewritePrompt(this.options.state),
runtimeSettings: this.options.runtimeSettings,
onActivity: (activity) => {
if (generationRun.abortController.signal.aborted || !this.isActiveGenerationRun(generationRun)) return;
this.updateActivity(activity);
},
onPreview: (text) => {
if (generationRun.abortController.signal.aborted || !this.isActiveGenerationRun(generationRun)) return;
this.updatePreview(text);
},
signal: generationRun.abortController.signal,

View file

@ -0,0 +1,83 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import { MarkdownView, TFile } from "obsidian";
import { registerSelectionRewriteCommand } from "../../../src/features/selection-rewrite/command";
import type { SelectionRewritePopoverOptions } from "../../../src/features/selection-rewrite/popover";
const popoverMock = vi.hoisted(() => {
const instances: { options: SelectionRewritePopoverOptions; open: ReturnType<typeof vi.fn>; close: ReturnType<typeof vi.fn> }[] = [];
return {
instances,
reset(): void {
instances.length = 0;
},
};
});
vi.mock("../../../src/features/selection-rewrite/popover", () => {
class SelectionRewritePopover {
readonly open = vi.fn();
readonly close = vi.fn();
constructor(readonly options: SelectionRewritePopoverOptions) {
popoverMock.instances.push({ options, open: this.open, close: this.close });
}
}
return { SelectionRewritePopover };
});
describe("selection rewrite command", () => {
it("captures the unsaved editor buffer and clones the target range", () => {
popoverMock.reset();
const addedCommand = { current: null as null | { editorCallback: (editor: unknown, view: unknown) => void } };
const cleanup = { current: null as null | (() => void) };
const plugin = {
settings: {
codexPath: "/usr/local/bin/codex",
sendShortcut: "enter",
rewriteSelectionModel: null,
rewriteSelectionEffort: null,
},
vaultPath: "/vault",
register: vi.fn((callback: () => void) => {
cleanup.current = callback;
}),
addCommand: vi.fn((command: { editorCallback: (editor: unknown, view: unknown) => void }) => {
addedCommand.current = command;
}),
};
const from = { line: 1, ch: 0 };
const to = { line: 1, ch: 22 };
const editor = {
getSelection: vi.fn(() => "Revise this sentence."),
getValue: vi.fn(() => "# Draft heading\n\nRevise this sentence.\n\nUnsaved paragraph."),
getCursor: vi.fn((which: "from" | "to") => (which === "from" ? from : to)),
};
const view = new MarkdownView({} as never);
view.file = Object.assign(new TFile(), { path: "Draft.md", basename: "Draft" });
registerSelectionRewriteCommand(plugin as never);
expect(addedCommand.current).not.toBeNull();
addedCommand.current?.editorCallback(editor, view);
from.line = 99;
to.ch = 99;
const captured = popoverMock.instances[0]?.options.state;
expect(captured).toMatchObject({
filePath: "Draft.md",
originalText: "Revise this sentence.",
noteText: "# Draft heading\n\nRevise this sentence.\n\nUnsaved paragraph.",
targetRange: {
from: { line: 1, ch: 0 },
to: { line: 1, ch: 22 },
},
});
expect(popoverMock.instances[0]?.open).toHaveBeenCalledOnce();
cleanup.current?.();
expect(popoverMock.instances[0]?.close).toHaveBeenCalledOnce();
});
});

View file

@ -283,6 +283,29 @@ describe("selection rewrite popover", () => {
expect(onClose).toHaveBeenCalledOnce();
});
it("refuses to apply when the current editor range no longer matches the original selection", async () => {
const editor = editorFixture({ currentText: "Changed sentence." });
const popover = new SelectionRewritePopover(
popoverOptions({
editor: editor.editor,
state: rewriteState({ status: "preview", replacementText: "Rewritten sentence." }),
}),
);
openPopover(popover);
await act(async () => {
expectPresent(document.querySelector<HTMLButtonElement>('button[aria-label="Apply"]')).click();
await Promise.resolve();
});
expect(editor.getRange).toHaveBeenCalledWith({ line: 1, ch: 0 }, { line: 1, ch: 22 });
expect(editor.replaceRange).not.toHaveBeenCalled();
expect(document.querySelector(".codex-panel-selection-rewrite")).not.toBeNull();
expect(document.body.textContent).toContain("Selection changed. Generate the rewrite again before applying.");
closePopover(popover);
});
it("unmounts and removes the Preact popover when closed", () => {
const onClose = vi.fn();
const popover = new SelectionRewritePopover(popoverOptions({ onClose }));
@ -345,6 +368,35 @@ describe("selection rewrite popover", () => {
expect(document.body.textContent).not.toContain("Late rewrite");
});
it("ignores late generation preview callbacks after close", async () => {
const rewrite = deferred<{ replacementText: string }>();
const options = popoverOptions();
const preview: { current: ((text: string) => void) | null } = { current: null };
vi.spyOn(selectionRewriteRunner, "runSelectionRewrite").mockImplementation((runOptions) => {
preview.current = runOptions.onPreview ?? null;
return rewrite.promise;
});
const popover = new SelectionRewritePopover(options);
openPopover(popover);
await act(async () => {
expectPresent(document.querySelector<HTMLButtonElement>('button[aria-label="Generate"]')).click();
await Promise.resolve();
});
closePopover(popover);
const latePreview = preview.current;
if (!latePreview) throw new Error("Expected preview callback to be captured");
latePreview("Late partial");
expect(options.state.streamText).toBe("");
rewrite.resolve({ replacementText: "Late rewrite" });
await act(async () => {
await flushPromises();
});
});
it("keeps only one generation run active while a rewrite is pending", async () => {
const rewrite = deferred<{ replacementText: string }>();
vi.spyOn(selectionRewriteRunner, "runSelectionRewrite").mockReturnValue(rewrite.promise);
@ -579,18 +631,21 @@ function popoverOptions(
};
}
function editorFixture(): {
function editorFixture(options: { currentText?: string } = {}): {
editor: ConstructorParameters<typeof SelectionRewritePopover>[0]["editor"];
getRange: ReturnType<typeof vi.fn>;
replaceRange: ReturnType<typeof vi.fn>;
} {
const getRange = vi.fn(() => options.currentText ?? "Revise this sentence.");
const replaceRange = vi.fn();
return {
editor: {
getCursor: () => ({ line: 1, ch: 22 }),
getRange: vi.fn(() => "Revise this sentence."),
getRange,
posToOffset: () => 0,
replaceRange,
} as unknown as ConstructorParameters<typeof SelectionRewritePopover>[0]["editor"],
getRange,
replaceRange,
};
}