Prepare the 1.0 compatibility review flow

This commit is contained in:
vorotamoroz
2026-07-19 04:34:08 +00:00
parent 52138bf7a5
commit ed3f81e9f9
42 changed files with 5524 additions and 4119 deletions
@@ -54,6 +54,14 @@
max-height: 100%;
}
:global(body.is-mobile .livesync-svelte-dialog-container .dialog-host > .button-group) {
background: var(--modal-background, var(--background-primary));
bottom: 0;
padding-bottom: 1px;
position: sticky;
z-index: 1;
}
.dialog-host {
padding: 20px;
gap: 0.5em;
+143 -25
View File
@@ -1,18 +1,19 @@
import { type App, type Plugin, Notice } from "@/deps";
import { scheduleTask, memoIfNotExist, memoObject, retrieveMemoObject, disposeMemoObject } from "@/common/utils";
import { EVENT_PLUGIN_UNLOADED } from "@/common/events";
import { $msg } from "@vrtmrz/livesync-commonlib/compat/common/i18n";
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import type { Confirm, ConfirmActionLayout } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import { confirmAction, pickOne, promptPassword, promptText } from "@vrtmrz/obsidian-plugin-kit";
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
import {
askYesNo,
askString,
confirmWithMessageWithWideButton,
askSelectString,
confirmWithMessage,
confirmWithMessage as confirmWithLegacyMessage,
} from "@/modules/coreObsidian/UILib/dialogs";
export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceContext> implements Confirm {
private _context: T;
private readonly dialogueController = new AbortController();
private readonly popupKeys = new Set<string>();
get _app(): App {
return this._context.app;
}
@@ -21,12 +22,58 @@ export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceC
}
constructor(context: T) {
this._context = context;
context.events.onceEvent(EVENT_PLUGIN_UNLOADED, () => {
this.dialogueController.abort();
for (const popupKey of this.popupKeys) {
this.closePopup(popupKey);
}
});
}
askYesNo(message: string): Promise<"yes" | "no"> {
return askYesNo(this._app, message);
private get dialogueLifecycle() {
return { signal: this.dialogueController.signal };
}
askString(title: string, key: string, placeholder: string, isPassword: boolean = false): Promise<string | false> {
return askString(this._app, title, key, placeholder, isPassword);
private hasCountdown(timeout: number | undefined): timeout is number {
return timeout !== undefined && timeout > 0;
}
async askYesNo(message: string): Promise<"yes" | "no"> {
const result = await confirmAction(
this._app,
{
title: $msg("moduleInputUIObsidian.defaultTitleConfirmation"),
message,
actions: ["yes", "no"] as const,
labels: {
yes: $msg("moduleInputUIObsidian.optionYes"),
no: $msg("moduleInputUIObsidian.optionNo"),
},
defaultAction: "no",
sourcePath: "/",
},
this.dialogueLifecycle
);
return result === "yes" ? "yes" : "no";
}
async askString(
title: string,
key: string,
placeholder: string,
isPassword: boolean = false
): Promise<string | false> {
const prompt = isPassword ? promptPassword : promptText;
const result = await prompt(
this._app,
{
title,
label: key,
placeholder,
},
this.dialogueLifecycle
);
return result ?? false;
}
async askYesNoDialog(
@@ -37,6 +84,21 @@ export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceC
const yesLabel = $msg("moduleInputUIObsidian.optionYes");
const noLabel = $msg("moduleInputUIObsidian.optionNo");
const defaultOption = opt.defaultOption === "Yes" ? yesLabel : noLabel;
if (!this.hasCountdown(opt.timeout)) {
const result = await confirmAction(
this._app,
{
title: opt.title || defaultTitle,
message,
actions: ["Yes", "No"] as const,
labels: { Yes: yesLabel, No: noLabel },
defaultAction: opt.defaultOption === "Yes" ? "Yes" : "No",
sourcePath: "/",
},
this.dialogueLifecycle
);
return result === "Yes" ? "yes" : "no";
}
const ret = await confirmWithMessageWithWideButton(
this._plugin,
opt.title || defaultTitle,
@@ -48,16 +110,39 @@ export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceC
return ret === yesLabel ? "yes" : "no";
}
askSelectString(message: string, items: string[]): Promise<string> {
return askSelectString(this._app, message, items);
async askSelectString(message: string, items: string[]): Promise<string> {
const result = await pickOne(
this._app,
{
items,
getText: (item) => item,
placeholder: message,
},
this.dialogueLifecycle
);
return result ?? "";
}
askSelectStringDialogue<T extends readonly string[]>(
async askSelectStringDialogue<T extends readonly string[]>(
message: string,
buttons: T,
opt: { title?: string; defaultAction: T[number]; timeout?: number }
): Promise<T[number] | false> {
const defaultTitle = $msg("moduleInputUIObsidian.defaultTitleSelect");
if (!this.hasCountdown(opt.timeout)) {
const result = await confirmAction(
this._app,
{
title: opt.title || defaultTitle,
message,
actions: buttons,
defaultAction: opt.defaultAction,
sourcePath: "/",
},
this.dialogueLifecycle
);
return result ?? false;
}
return confirmWithMessageWithWideButton(
this._plugin,
opt.title || defaultTitle,
@@ -68,7 +153,14 @@ export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceC
);
}
askInPopup(key: string, dialogText: string, anchorCallback: (anchor: HTMLAnchorElement) => void) {
askInPopup(
key: string,
dialogText: string,
anchorCallback: (anchor: HTMLAnchorElement) => void,
durationMs: number = 20000
) {
const popupKey = "popup-" + key;
this.popupKeys.add(popupKey);
const fragment = createFragment((doc) => {
const [beforeText, afterText] = dialogText.split("{HERE}", 2);
doc.createSpan(undefined, (a) => {
@@ -76,36 +168,62 @@ export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceC
a.appendChild(
a.createEl("a", undefined, (anchor) => {
anchorCallback(anchor);
anchor.addEventListener("click", () => this.closePopup(popupKey));
})
);
a.appendText(afterText);
});
});
const popupKey = "popup-" + key;
scheduleTask(popupKey, 1000, async () => {
if (this.dialogueController.signal.aborted) {
this.popupKeys.delete(popupKey);
return;
}
const popup = await memoIfNotExist(popupKey, () => new Notice(fragment, 0));
const isShown = popup?.noticeEl?.isShown();
if (!isShown) {
memoObject(popupKey, new Notice(fragment, 0));
}
scheduleTask(popupKey + "-close", 20000, () => {
const popup = retrieveMemoObject<Notice>(popupKey);
if (!popup) return;
if (popup?.noticeEl?.isShown()) {
popup.hide();
}
disposeMemoObject(popupKey);
});
scheduleTask(popupKey + "-close", durationMs, () => this.closePopup(popupKey));
});
}
confirmWithMessage(
private closePopup(popupKey: string) {
const popup = retrieveMemoObject<Notice>(popupKey);
if (!popup) {
this.popupKeys.delete(popupKey);
return;
}
if (popup.noticeEl?.isShown()) {
popup.hide();
}
disposeMemoObject(popupKey);
this.popupKeys.delete(popupKey);
}
async confirmWithMessage(
title: string,
contentMd: string,
buttons: string[],
defaultAction: (typeof buttons)[number],
timeout?: number
timeout?: number,
actionLayout?: ConfirmActionLayout
): Promise<(typeof buttons)[number] | false> {
return confirmWithMessage(this._plugin, title, contentMd, buttons, defaultAction, timeout);
if (this.hasCountdown(timeout)) {
return confirmWithLegacyMessage(this._plugin, title, contentMd, buttons, defaultAction, timeout);
}
const result = await confirmAction(
this._app,
{
title,
message: contentMd,
actions: buttons,
defaultAction,
sourcePath: "/",
...(actionLayout === undefined ? {} : { actionLayout }),
},
this.dialogueLifecycle
);
return result ?? false;
}
}
@@ -0,0 +1,229 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
confirmAction: vi.fn(),
pickOne: vi.fn(),
promptPassword: vi.fn(),
promptText: vi.fn(),
legacyAskSelectString: vi.fn(),
legacyAskString: vi.fn(),
legacyAskYesNo: vi.fn(),
legacyConfirm: vi.fn(),
legacyWideConfirm: vi.fn(),
}));
vi.mock("@vrtmrz/obsidian-plugin-kit", () => ({
confirmAction: mocks.confirmAction,
pickOne: mocks.pickOne,
promptPassword: mocks.promptPassword,
promptText: mocks.promptText,
}));
vi.mock("@/modules/coreObsidian/UILib/dialogs", () => ({
askSelectString: mocks.legacyAskSelectString,
askString: mocks.legacyAskString,
askYesNo: mocks.legacyAskYesNo,
confirmWithMessage: mocks.legacyConfirm,
confirmWithMessageWithWideButton: mocks.legacyWideConfirm,
}));
vi.mock("@/deps", () => ({
Notice: class {},
}));
import { EVENT_PLUGIN_UNLOADED } from "@/common/events";
import { memoObject, retrieveMemoObject } from "@/common/utils";
import { createLiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
import { ObsidianConfirm } from "./ObsidianConfirm";
import type { ObsidianServiceContext } from "./ObsidianServiceContext";
function createConfirm() {
const app = { id: "app" };
const plugin = { app };
const events = createLiveSyncEventHub();
const context = { app, plugin, events } as unknown as ObsidianServiceContext;
return { confirm: new ObsidianConfirm(context), events, app, plugin };
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("ObsidianConfirm Fancy Kit adapter", () => {
it("uses owner-bound Kit prompts and preserves cancellation and empty input", async () => {
const { confirm, app } = createConfirm();
mocks.promptText.mockResolvedValueOnce(null);
mocks.promptPassword.mockResolvedValueOnce("");
await expect(confirm.askString("Name", "Device name", "New Remote")).resolves.toBe(false);
await expect(confirm.askString("Secret", "Passphrase", "Enter it", true)).resolves.toBe("");
expect(mocks.promptText).toHaveBeenCalledWith(
app,
{
title: "Name",
label: "Device name",
placeholder: "New Remote",
},
{ signal: expect.any(AbortSignal) }
);
expect(mocks.promptPassword).toHaveBeenCalledWith(
app,
{
title: "Secret",
label: "Passphrase",
placeholder: "Enter it",
},
{ signal: expect.any(AbortSignal) }
);
expect(mocks.legacyAskString).not.toHaveBeenCalled();
});
it("uses Kit for untimed yes/no and typed selection while preserving dismissed results", async () => {
const { confirm, app } = createConfirm();
mocks.confirmAction.mockResolvedValueOnce("yes");
mocks.pickOne.mockResolvedValueOnce("Beta").mockResolvedValueOnce(null);
await expect(confirm.askYesNo("Continue?")).resolves.toBe("yes");
await expect(confirm.askSelectString("Target", ["Alpha", "Beta"])).resolves.toBe("Beta");
await expect(confirm.askSelectString("Target", ["Alpha"])).resolves.toBe("");
expect(mocks.confirmAction).toHaveBeenCalledWith(
app,
expect.objectContaining({
message: "Continue?",
actions: ["yes", "no"],
defaultAction: "no",
}),
{ signal: expect.any(AbortSignal) }
);
expect(mocks.pickOne).toHaveBeenCalledWith(
app,
expect.objectContaining({
items: ["Alpha", "Beta"],
getText: expect.any(Function),
}),
{ signal: expect.any(AbortSignal) }
);
expect(mocks.legacyAskYesNo).not.toHaveBeenCalled();
expect(mocks.legacyAskSelectString).not.toHaveBeenCalled();
});
it("uses Kit for untimed action dialogues and retains the legacy countdown path", async () => {
const { confirm, app, plugin } = createConfirm();
mocks.confirmAction.mockResolvedValueOnce("Apply").mockResolvedValueOnce("Yes");
mocks.legacyConfirm.mockResolvedValueOnce("Cancel");
mocks.legacyWideConfirm.mockResolvedValueOnce("No");
await expect(
confirm.confirmWithMessage("Review", "**Apply?**", ["Apply", "Cancel"], "Cancel", undefined, "vertical")
).resolves.toBe("Apply");
await expect(confirm.askYesNoDialog("Continue?", { title: "Question", defaultOption: "Yes" })).resolves.toBe(
"yes"
);
await expect(confirm.confirmWithMessage("Timed", "Wait", ["Apply", "Cancel"], "Cancel", 30)).resolves.toBe(
"Cancel"
);
await expect(confirm.askYesNoDialog("Timed?", { defaultOption: "No", timeout: 10 })).resolves.toBe("no");
expect(mocks.confirmAction).toHaveBeenNthCalledWith(
1,
app,
{
title: "Review",
message: "**Apply?**",
actions: ["Apply", "Cancel"],
actionLayout: "vertical",
defaultAction: "Cancel",
sourcePath: "/",
},
{ signal: expect.any(AbortSignal) }
);
expect(mocks.legacyConfirm).toHaveBeenCalledWith(plugin, "Timed", "Wait", ["Apply", "Cancel"], "Cancel", 30);
expect(mocks.legacyWideConfirm).toHaveBeenCalledWith(
plugin,
expect.any(String),
"Timed?",
expect.any(Array),
expect.any(String),
10
);
});
it("uses Kit for untimed multi-action selection and keeps timed wide actions on the countdown dialogue", async () => {
const { confirm, app, plugin } = createConfirm();
const actions = ["Apply now", "Review later"] as const;
mocks.confirmAction.mockResolvedValueOnce("Review later");
mocks.legacyWideConfirm.mockResolvedValueOnce("Apply now");
await expect(
confirm.askSelectStringDialogue("Choose the next step", actions, {
title: "Next step",
defaultAction: "Review later",
})
).resolves.toBe("Review later");
await expect(
confirm.askSelectStringDialogue("Choose before the timer expires", actions, {
title: "Timed step",
defaultAction: "Apply now",
timeout: 15,
})
).resolves.toBe("Apply now");
expect(mocks.confirmAction).toHaveBeenCalledWith(
app,
{
title: "Next step",
message: "Choose the next step",
actions,
defaultAction: "Review later",
sourcePath: "/",
},
{ signal: expect.any(AbortSignal) }
);
expect(mocks.legacyWideConfirm).toHaveBeenCalledWith(
plugin,
"Timed step",
"Choose before the timer expires",
actions,
"Apply now",
15
);
});
it("dismisses an open Kit dialogue when the plug-in unload event is emitted", async () => {
const { confirm, events } = createConfirm();
let observedSignal: AbortSignal | undefined;
mocks.confirmAction.mockImplementation(
(_app, _options, lifecycle: { signal: AbortSignal }) =>
new Promise<null>((resolve) => {
observedSignal = lifecycle.signal;
lifecycle.signal.addEventListener("abort", () => resolve(null), { once: true });
})
);
const result = confirm.confirmWithMessage("Review", "Message", ["OK"], "OK");
expect(observedSignal?.aborted).toBe(false);
events.emitEvent(EVENT_PLUGIN_UNLOADED);
await expect(result).resolves.toBe(false);
expect(observedSignal?.aborted).toBe(true);
});
it("closes an active Notice when the plug-in unload event is emitted", () => {
const { confirm, events } = createConfirm();
const popupKey = "popup-remote-size-exceeded";
const popup = {
hide: vi.fn(),
noticeEl: { isShown: vi.fn(() => true) },
};
memoObject(popupKey, popup);
(confirm as unknown as { popupKeys: Set<string> }).popupKeys.add(popupKey);
events.emitEvent(EVENT_PLUGIN_UNLOADED);
expect(popup.hide).toHaveBeenCalledOnce();
expect(retrieveMemoObject(popupKey)).toBe(false);
});
});