Own browser UI sessions and shared navigation

This commit is contained in:
vorotamoroz committed 2026-10-29 14:58:38 +00:00
1 parent cfdc698775
commit 273079e827
28 files changed
+1948 -121

No files matched your search

+10 -11
View File
@@ -1,11 +1,10 @@
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { BrowserUiNotifications, createBrowserUi } from "@vrtmrz/browser-ui-kit";
import { createNativeElement } from "@/apps/browserDom";
import { renderMessageMarkdownInto } from "./ui/renderMessageMarkdown";
import { UiInteractionsConfirm } from "./UiInteractionsConfirm";
import { createBrowserUiScope, type BrowserUiScope } from "./BrowserUiScope";
/**
* Compatibility facade consumed by Commonlib while browser presentation is
@@ -14,26 +13,26 @@ import { UiInteractionsConfirm } from "./UiInteractionsConfirm";
export class BrowserConfirm<T extends ServiceContext> extends UiInteractionsConfirm {
readonly context: T;
constructor(context: T) {
const dialogueController = new AbortController();
constructor(context: T, ui: BrowserUiScope = createBrowserUiScope(context)) {
const notifications = new BrowserUiNotifications({
document: _activeDocument,
document: ui.document,
});
super({
ui: createBrowserUi({
document: _activeDocument,
signal: dialogueController.signal,
document: ui.document,
signal: ui.signal,
renderMarkdown: ({ container, markdown }) => {
renderMessageMarkdownInto(container, markdown);
},
}),
notifications,
createActionAnchor: () => createNativeElement(_activeDocument, "a"),
createActionAnchor: () => createNativeElement(ui.document, "a"),
});
this.context = context;
context.events.onceEvent(EVENT_PLUGIN_UNLOADED, () => {
dialogueController.abort();
if (ui.signal.aborted) {
notifications.dispose();
});
} else {
ui.signal.addEventListener("abort", () => notifications.dispose(), { once: true });
}
}
}
+87 -26
View File
@@ -1,9 +1,19 @@
import { promiseWithResolvers, type PromiseWithResolvers } from "octagonal-wheels/promises";
import { fireAndForget, promiseWithResolvers, type PromiseWithResolvers } from "octagonal-wheels/promises";
import { createNativeElement } from "@/apps/browserDom";
import { mount } from "svelte";
import type { BrowserUiOptions } from "./BrowserUiScope";
import { mount, unmount } from "svelte";
import MenuView from "./ui/MenuView.svelte";
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
type MountedMenu = ReturnType<typeof mount>;
type MenuSession = {
waitingForClose: PromiseWithResolvers<void>;
element: HTMLElement;
component?: MountedMenu;
abortListener?: () => void;
closed: boolean;
};
export class MenuItem {
type = "item";
title = "";
@@ -29,7 +39,15 @@ export class Menu {
type = "menu";
items: (MenuItem | MenuSeparator)[] = [];
constructor() {}
private readonly document: Document;
private readonly signal?: AbortSignal;
private activeSession?: MenuSession;
constructor(options: BrowserUiOptions = {}) {
this.document = options.document ?? _activeDocument;
this.signal = options.signal;
}
addItem(callback: (item: MenuItem) => void) {
const item = new MenuItem();
callback(item);
@@ -41,32 +59,75 @@ export class Menu {
return this;
}
waitingForClose?: PromiseWithResolvers<void>;
showAtPosition(pos: { x: number; y: number }) {
const el = createNativeElement(_activeDocument, "div");
if (this.waitingForClose) {
this.waitingForClose.resolve();
showAtPosition(pos: { x: number; y: number }): Promise<void> {
if (this.activeSession) {
this.closeSession(this.activeSession);
}
this.waitingForClose = promiseWithResolvers<void>();
mount(MenuView, {
target: el,
props: {
items: this.items,
closeMenu: () => {
this.waitingForClose?.resolve();
this.waitingForClose = undefined;
if (this.signal?.aborted) {
this.waitingForClose = undefined;
return Promise.resolve();
}
const waitingForClose = promiseWithResolvers<void>();
const element = createNativeElement(this.document, "div");
const session: MenuSession = {
waitingForClose,
element,
closed: false,
};
this.activeSession = session;
this.waitingForClose = waitingForClose;
const closeMenu = () => this.closeSession(session);
session.abortListener = closeMenu;
this.signal?.addEventListener("abort", closeMenu, { once: true });
try {
session.component = mount(MenuView, {
target: element,
props: {
items: this.items,
closeMenu,
x: pos.x,
y: pos.y,
},
x: pos.x,
y: pos.y,
},
});
_activeDocument.body.appendChild(el);
void this.waitingForClose.promise.finally(() => {
el.remove();
});
return this.waitingForClose.promise;
});
if (!session.closed) {
this.document.body.appendChild(element);
} else if (session.component) {
fireAndForget(() => unmount(session.component!));
}
} catch (error) {
this.closeSession(session);
throw error;
}
return waitingForClose.promise;
}
private closeSession(session: MenuSession): void {
if (session.closed) return;
session.closed = true;
if (session.abortListener) {
this.signal?.removeEventListener("abort", session.abortListener);
session.abortListener = undefined;
}
if (this.activeSession === session) {
this.activeSession = undefined;
this.waitingForClose = undefined;
}
session.element.remove();
if (session.component) {
fireAndForget(() => unmount(session.component!));
}
session.waitingForClose.resolve();
}
hide() {
this.waitingForClose?.resolve();
this.waitingForClose = undefined;
if (this.activeSession) {
this.closeSession(this.activeSession);
}
}
}
+32 -9
View File
@@ -8,6 +8,7 @@ import type { SvelteDialogManagerDependencies } from "@vrtmrz/livesync-commonlib
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import DialogHost from "@/modules/services/LiveSyncUI/DialogHost.svelte";
import { SvelteDialogSession } from "@/modules/services/SvelteDialogSession";
import { createBrowserUiScope, type BrowserUiScope } from "./BrowserUiScope";
export class BrowserModal {
contentEl: HTMLElement;
@@ -15,14 +16,14 @@ export class BrowserModal {
modalEl: HTMLElement;
isOpen: boolean = false;
baseEl: HTMLElement;
constructor() {
const baseEl = createNativeElement(_activeDocument, "popup");
constructor(document: Document = _activeDocument) {
const baseEl = createNativeElement(document, "popup");
this.baseEl = baseEl;
this.contentEl = createNativeElement(_activeDocument, "div");
this.contentEl = createNativeElement(document, "div");
this.contentEl.className = "modal-content";
this.titleEl = createNativeElement(_activeDocument, "div");
this.titleEl = createNativeElement(document, "div");
this.titleEl.className = "modal-title";
this.modalEl = createNativeElement(_activeDocument, "div");
this.modalEl = createNativeElement(document, "div");
this.modalEl.className = "modal";
this.modalEl.hidden = true;
this.modalEl.appendChild(this.titleEl);
@@ -33,7 +34,7 @@ export class BrowserModal {
this.isOpen = true;
this.modalEl.hidden = false;
if (!this.baseEl.parentElement) {
_activeDocument.body.appendChild(this.baseEl);
this.baseEl.ownerDocument.body.appendChild(this.baseEl);
}
this.onOpen();
}
@@ -57,9 +58,10 @@ export class LiveSyncBrowserDialog<T, U, C extends ServiceContext = ServiceConte
context: C,
dependents: SvelteDialogManagerDependencies<C>,
component: ComponentHasResult<T, U>,
initialData?: U
initialData?: U,
private readonly ui?: BrowserUiScope
) {
super();
super(ui?.document ?? _activeDocument);
this.session = new SvelteDialogSession({
surface: this,
context,
@@ -67,9 +69,15 @@ export class LiveSyncBrowserDialog<T, U, C extends ServiceContext = ServiceConte
dialogHost: DialogHost,
component,
initialData,
signal: ui?.signal,
});
}
override open(): void {
if (this.ui?.signal.aborted) throw this.ui.signal.reason ?? new Error("Plug-in unloaded");
super.open();
}
override onOpen(): void {
this.session.onOpen();
}
@@ -83,11 +91,26 @@ export class LiveSyncBrowserDialog<T, U, C extends ServiceContext = ServiceConte
}
}
export class BrowserSvelteDialogManager<T extends ServiceContext> extends SvelteDialogManagerBase<T> {
constructor(
context: T,
dependencies: SvelteDialogManagerDependencies<T>,
private readonly ui: BrowserUiScope = createBrowserUiScope(context)
) {
super(context, dependencies);
}
override async openSvelteDialog<TT, TU>(
component: ComponentHasResult<TT, TU>,
initialData?: TU
): Promise<TT | undefined> {
const dialog = new LiveSyncBrowserDialog<TT, TU, T>(this.context, this.dependents, component, initialData);
if (this.ui.signal.aborted) throw this.ui.signal.reason ?? new Error("Plug-in unloaded");
const dialog = new LiveSyncBrowserDialog<TT, TU, T>(
this.context,
this.dependents,
component,
initialData,
this.ui
);
dialog.open();
return await dialog.waitForClose();
}
+38
View File
@@ -0,0 +1,38 @@
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
export interface BrowserUiOptions {
readonly document?: Document;
readonly signal?: AbortSignal;
}
/** Browser presentation target and lifetime owned by one application context. */
export interface BrowserUiScope {
readonly document: Document;
readonly signal: AbortSignal;
}
export function createBrowserUiScope(
context: Pick<ServiceContext, "events">,
options: BrowserUiOptions = {}
): BrowserUiScope {
const controller = new AbortController();
const parent = options.signal;
let unregisterUnload: (() => void) | undefined;
const end = (reason: unknown): void => {
if (controller.signal.aborted) return;
unregisterUnload?.();
unregisterUnload = undefined;
parent?.removeEventListener("abort", onParentAbort);
controller.abort(reason);
};
const onParentAbort = () => end(parent?.reason);
if (parent?.aborted) {
controller.abort(parent.reason);
} else {
unregisterUnload = context.events.onceEvent(EVENT_PLUGIN_UNLOADED, () => end(new Error("Plug-in unloaded")));
parent?.addEventListener("abort", onParentAbort, { once: true });
}
return { document: options.document ?? _activeDocument, signal: controller.signal };
}
+13 -8
View File
@@ -7,6 +7,7 @@ import type { InjectableAPIService } from "@vrtmrz/livesync-commonlib/compat/ser
import { UIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/UIService";
import DialogToCopy from "@/modules/services/LiveSyncUI/dialogues/DialogueToCopy.svelte";
import { BrowserSvelteDialogManager } from "./BrowserSvelteDialogManager";
import type { BrowserUiScope } from "./BrowserUiScope";
export interface LiveSyncBrowserUIServiceDependencies<T extends ServiceContext> {
API: InjectableAPIService<T>;
@@ -20,15 +21,19 @@ export class LiveSyncBrowserUIService<T extends ServiceContext> extends UIServic
override get dialogToCopy() {
return DialogToCopy;
}
constructor(context: T, dependents: LiveSyncBrowserUIServiceDependencies<T>) {
constructor(context: T, dependents: LiveSyncBrowserUIServiceDependencies<T>, ui?: BrowserUiScope) {
const browserConfirm = dependents.API.confirm;
const obsidianSvelteDialogManager = new BrowserSvelteDialogManager<T>(context, {
appLifecycle: dependents.appLifecycle,
config: dependents.config,
replicator: dependents.replicator,
confirm: browserConfirm,
control: dependents.control,
});
const obsidianSvelteDialogManager = new BrowserSvelteDialogManager<T>(
context,
{
appLifecycle: dependents.appLifecycle,
config: dependents.config,
replicator: dependents.replicator,
confirm: browserConfirm,
control: dependents.control,
},
ui
);
super(context, {
dialogManager: obsidianSvelteDialogManager,
APIService: dependents.API,
@@ -22,6 +22,7 @@ import { InjectableVaultServiceCompat } from "@vrtmrz/livesync-commonlib/compat/
import { setLang, translateLiveSyncMessage } from "@/common/translation";
import { BrowserConfirm } from "./BrowserConfirm";
import { createBrowserUiScope, type BrowserUiOptions, type BrowserUiScope } from "./BrowserUiScope";
import { createBrowserKeyValueDatabaseFactory } from "./BrowserKeyValueDatabase";
import { LiveSyncBrowserAPIService, type LiveSyncBrowserAPIServiceOptions } from "./LiveSyncBrowserAPIService";
import { LiveSyncBrowserUIService } from "./LiveSyncBrowserUIService";
@@ -41,6 +42,7 @@ export interface LiveSyncBrowserRestartPolicy {
export interface LiveSyncBrowserServiceHubOptions<T extends ServiceContext> {
deviceValues: ValueStore & ValueDeleter;
context?: T;
ui?: BrowserUiOptions;
getSystemVaultName?: () => string;
settings?: LiveSyncBrowserSettingsPersistence;
restart?: LiveSyncBrowserRestartPolicy;
@@ -56,15 +58,18 @@ class LiveSyncBrowserKeyValueDBService<T extends ServiceContext> extends KeyValu
/** LiveSync-owned service composition shared by WebApp and WebPeer. */
export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends InjectableServiceHub<T> {
readonly browserUi: BrowserUiScope;
constructor(options: LiveSyncBrowserServiceHubOptions<T>) {
const context =
options.context ??
(new ServiceContext({
translate: translateLiveSyncMessage,
}) as T);
const browserUi = createBrowserUiScope(context, options.ui);
const API = new LiveSyncBrowserAPIService(context, {
...options.API,
confirm: new BrowserConfirm(context),
confirm: new BrowserConfirm(context, browserUi),
getSystemVaultName: options.getSystemVaultName ?? (() => "livesync-browser"),
});
const conflict = new InjectableConflictService(context);
@@ -147,13 +152,17 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
APIService: API,
replicatorService: replicator,
});
const ui = new LiveSyncBrowserUIService(context, {
API,
appLifecycle,
config,
control,
replicator,
});
const ui = new LiveSyncBrowserUIService(
context,
{
API,
appLifecycle,
config,
control,
replicator,
},
browserUi
);
super(context, {
API,
@@ -175,6 +184,7 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
ui,
vault,
});
this.browserUi = browserUi;
}
}
+25
View File
@@ -0,0 +1,25 @@
import { stepCyclicIndex } from "octagonal-wheels/collection";
export function navigateDiffNodes(
root: ParentNode,
selector: string,
navigation: {
currentDiffIndex: number;
diffNavIndicator: { setText(text: string): void };
},
direction: "prev" | "next"
): void {
const diffElements = root.querySelectorAll<HTMLElement>(selector);
if (diffElements.length === 0) return;
const previousFocused = root.querySelector(".diff-focused");
if (previousFocused) {
previousFocused.classList.remove("diff-focused");
}
navigation.currentDiffIndex = stepCyclicIndex(navigation.currentDiffIndex, diffElements.length, direction);
const target = diffElements[navigation.currentDiffIndex];
target.classList.add("diff-focused");
target.scrollIntoView({ behavior: "smooth", block: "center" });
navigation.diffNavIndicator.setText(`${navigation.currentDiffIndex + 1}/${diffElements.length}`);
}
@@ -0,0 +1,321 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({
Modal: class Modal {},
TFile: class TFile {},
}));
vi.mock("@/main.ts", () => ({
default: class ObsidianLiveSyncPlugin {},
}));
vi.mock("@/common/utils.ts", () => ({
getPathFromTFile: (file: { path: string }) => file.path,
isValidPath: vi.fn(() => true),
}));
vi.mock("octagonal-wheels/concurrency/lock", () => ({
scheduleOnceIfDuplicated: vi.fn((_key: string, callback: () => unknown) => callback()),
}));
import { scheduleOnceIfDuplicated } from "octagonal-wheels/concurrency/lock";
import { DocumentHistoryModal } from "./DocumentHistoryModal.ts";
type NavigationTarget = {
classList: { add: ReturnType<typeof vi.fn>; remove: ReturnType<typeof vi.fn> };
scrollIntoView: ReturnType<typeof vi.fn>;
};
function createDiffView(count: number) {
const focused: { current: NavigationTarget | undefined } = { current: undefined };
const createTargets = (targetCount: number) =>
Array.from({ length: targetCount }, () => {
const target: NavigationTarget = {
classList: {
add: vi.fn(() => {
focused.current = target;
}),
remove: vi.fn(() => {
if (focused.current === target) focused.current = undefined;
}),
},
scrollIntoView: vi.fn(),
};
return target;
});
let targets = createTargets(count);
const contentView = {
querySelectorAll: vi.fn(() => targets),
querySelector: vi.fn(() => (targets.includes(focused.current as NavigationTarget) ? focused.current : null)),
};
return {
contentView,
targets,
replaceTargets(targetCount: number) {
focused.current = undefined;
targets = createTargets(targetCount);
return targets;
},
};
}
function createHistoryModal() {
return Object.create(DocumentHistoryModal.prototype) as DocumentHistoryModal;
}
describe("DocumentHistoryModal cyclic navigation", () => {
it("commits the selected index before scrolling, including when scrolling fails", () => {
const modal = createHistoryModal();
const view = createDiffView(2);
const indicator = { setText: vi.fn() };
const failure = new Error("fixture scroll failed");
Object.assign(modal, { contentView: view.contentView, currentDiffIndex: -1, diffNavIndicator: indicator });
view.targets[0].scrollIntoView.mockImplementation(() => {
expect(modal.currentDiffIndex).toBe(0);
throw failure;
});
expect(() => modal.navigateDiff("next")).toThrow(failure);
expect(modal.currentDiffIndex).toBe(0);
expect(indicator.setText).not.toHaveBeenCalled();
});
it("starts at the first diff for next and at the last diff for previous", () => {
const nextModal = createHistoryModal();
const nextView = createDiffView(3);
const nextIndicator = { setText: vi.fn() };
Object.assign(nextModal, {
contentView: nextView.contentView,
currentDiffIndex: -1,
diffNavIndicator: nextIndicator,
});
nextModal.navigateDiff("next");
expect(nextModal.currentDiffIndex).toBe(0);
expect(nextView.targets[0].classList.add).toHaveBeenCalledWith("diff-focused");
expect(nextView.targets[0].scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "center" });
expect(nextIndicator.setText).toHaveBeenCalledWith("1/3");
const previousModal = createHistoryModal();
const previousView = createDiffView(3);
const previousIndicator = { setText: vi.fn() };
Object.assign(previousModal, {
contentView: previousView.contentView,
currentDiffIndex: -1,
diffNavIndicator: previousIndicator,
});
previousModal.navigateDiff("prev");
expect(previousModal.currentDiffIndex).toBe(2);
expect(previousView.targets[2].classList.add).toHaveBeenCalledWith("diff-focused");
expect(previousView.targets[2].scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "center" });
expect(previousIndicator.setText).toHaveBeenCalledWith("3/3");
});
it("wraps in both directions while moving focus, scrolling, and updating the indicator", () => {
const modal = createHistoryModal();
const { contentView, targets } = createDiffView(3);
const indicator = { setText: vi.fn() };
Object.assign(modal, { contentView, currentDiffIndex: -1, diffNavIndicator: indicator });
modal.navigateDiff("next");
modal.navigateDiff("next");
modal.navigateDiff("next");
modal.navigateDiff("next");
modal.navigateDiff("prev");
expect(modal.currentDiffIndex).toBe(2);
expect(targets.map((target) => target.classList.add.mock.calls.length)).toEqual([2, 1, 2]);
expect(targets.map((target) => target.classList.remove.mock.calls.length)).toEqual([2, 1, 1]);
expect(targets.map((target) => target.scrollIntoView.mock.calls.length)).toEqual([2, 1, 2]);
expect(indicator.setText.mock.calls.map(([text]) => text)).toEqual(["1/3", "2/3", "3/3", "1/3", "3/3"]);
});
it("leaves the current navigation index and content view untouched when no diff exists", () => {
const modal = createHistoryModal();
const { contentView } = createDiffView(0);
const indicator = { setText: vi.fn() };
Object.assign(modal, { contentView, currentDiffIndex: 1, diffNavIndicator: indicator });
modal.navigateDiff("next");
expect(modal.currentDiffIndex).toBe(1);
expect(contentView.querySelector).not.toHaveBeenCalled();
expect(indicator.setText).not.toHaveBeenCalled();
});
it("looks up diff nodes again after the content view is replaced", () => {
const modal = createHistoryModal();
const view = createDiffView(2);
const indicator = { setText: vi.fn() };
Object.assign(modal, { contentView: view.contentView, currentDiffIndex: -1, diffNavIndicator: indicator });
modal.navigateDiff("next");
const replacementTargets = view.replaceTargets(3);
modal.navigateDiff("next");
expect(modal.currentDiffIndex).toBe(1);
expect(view.contentView.querySelectorAll).toHaveBeenCalledTimes(2);
expect(view.contentView.querySelector).toHaveBeenCalledTimes(2);
expect(replacementTargets[1].classList.add).toHaveBeenCalledWith("diff-focused");
expect(replacementTargets[1].scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "center" });
expect(indicator.setText.mock.calls.map(([text]) => text)).toEqual(["1/2", "2/3"]);
});
it("keeps navigation indices and focus independent between modal instances", () => {
const firstModal = createHistoryModal();
const secondModal = createHistoryModal();
const firstView = createDiffView(2);
const secondView = createDiffView(2);
Object.assign(firstModal, {
contentView: firstView.contentView,
currentDiffIndex: -1,
diffNavIndicator: { setText: vi.fn() },
});
Object.assign(secondModal, {
contentView: secondView.contentView,
currentDiffIndex: -1,
diffNavIndicator: { setText: vi.fn() },
});
firstModal.navigateDiff("next");
secondModal.navigateDiff("next");
firstModal.navigateDiff("next");
expect(firstModal.currentDiffIndex).toBe(1);
expect(secondModal.currentDiffIndex).toBe(0);
expect(firstView.targets[0].classList.remove).toHaveBeenCalledWith("diff-focused");
expect(secondView.targets[0].classList.remove).not.toHaveBeenCalled();
expect(secondView.targets[0].classList.add).toHaveBeenCalledWith("diff-focused");
});
it("resets the indicator and visibility according to showDiff", () => {
const modal = createHistoryModal();
const view = createDiffView(2);
const indicator = { setText: vi.fn() };
const diffNavContainer = { setCssStyles: vi.fn() };
const diffOnlyLabel = { setCssStyles: vi.fn() };
Object.assign(modal, {
contentView: view.contentView,
currentDiffIndex: 1,
diffNavIndicator: indicator,
diffNavContainer,
diffOnlyLabel,
showDiff: true,
});
modal.resetDiffNavigation();
expect(modal.currentDiffIndex).toBe(-1);
expect(view.contentView.querySelectorAll).toHaveBeenCalledWith(".history-added, .history-deleted");
expect(indicator.setText).toHaveBeenLastCalledWith("0/2");
expect(diffNavContainer.setCssStyles).toHaveBeenLastCalledWith({ display: "flex" });
expect(diffOnlyLabel.setCssStyles).toHaveBeenLastCalledWith({ display: "inline-block" });
view.replaceTargets(0);
modal.resetDiffNavigation();
expect(indicator.setText).toHaveBeenLastCalledWith("\u2014");
Object.assign(modal, { showDiff: false });
modal.resetDiffNavigation();
expect(view.contentView.querySelectorAll).toHaveBeenCalledTimes(2);
expect(indicator.setText).toHaveBeenLastCalledWith("\u2014");
expect(diffNavContainer.setCssStyles).toHaveBeenLastCalledWith({ display: "none" });
expect(diffOnlyLabel.setCssStyles).toHaveBeenLastCalledWith({ display: "none" });
});
it("updates visibility without an indicator", () => {
const modal = createHistoryModal();
const { contentView } = createDiffView(2);
const diffNavContainer = { setCssStyles: vi.fn() };
Object.assign(modal, { contentView, currentDiffIndex: 1, diffNavContainer, showDiff: true });
expect(() => modal.resetDiffNavigation()).not.toThrow();
expect(modal.currentDiffIndex).toBe(-1);
expect(contentView.querySelectorAll).not.toHaveBeenCalled();
expect(diffNavContainer.setCssStyles).toHaveBeenCalledWith({ display: "flex" });
});
it("maps selected search results to the revision slider and loads each wrapped selection", () => {
const modal = createHistoryModal();
const scheduleLoad = vi.mocked(scheduleOnceIfDuplicated);
scheduleLoad.mockClear();
const indicator = { setText: vi.fn() };
Object.assign(modal, {
searchResults: [
{ rev: "newest", index: 0, matchType: "Content" },
{ rev: "middle", index: 2, matchType: "Diff" },
{ rev: "oldest", index: 4, matchType: "Content" },
],
currentSearchIndex: -1,
revs_info: Array.from({ length: 5 }, (_, index) => ({ rev: `rev-${index}` })),
range: { value: "" },
loadRevs: vi.fn(),
searchResultIndicator: indicator,
searchPrevBtn: { disabled: true },
searchNextBtn: { disabled: true },
searchKeyword: "needle",
showDiff: false,
});
modal.navigateSearch("next");
expect(modal.currentSearchIndex).toBe(0);
expect(modal.range.value).toBe("4");
expect(modal.loadRevs).toHaveBeenCalledTimes(1);
expect(scheduleLoad).toHaveBeenCalledWith("loadRevs", expect.any(Function));
expect(indicator.setText).toHaveBeenLastCalledWith("1/3 matches");
modal.navigateSearch("next");
expect(modal.currentSearchIndex).toBe(1);
expect(modal.range.value).toBe("2");
expect(modal.loadRevs).toHaveBeenCalledTimes(2);
modal.navigateSearch("next");
expect(modal.currentSearchIndex).toBe(2);
expect(modal.range.value).toBe("0");
modal.navigateSearch("next");
expect(modal.currentSearchIndex).toBe(0);
expect(modal.range.value).toBe("4");
modal.navigateSearch("prev");
expect(modal.currentSearchIndex).toBe(2);
expect(modal.range.value).toBe("0");
expect(indicator.setText.mock.calls.map(([text]) => text)).toEqual([
"1/3 matches",
"2/3 matches",
"3/3 matches",
"1/3 matches",
"3/3 matches",
]);
});
it("leaves search state and controls untouched when there are no results", () => {
const modal = createHistoryModal();
const indicator = { setText: vi.fn() };
const searchPrevBtn = { disabled: true };
const searchNextBtn = { disabled: true };
Object.assign(modal, {
searchResults: [],
currentSearchIndex: -1,
range: { value: "unchanged" },
loadRevs: vi.fn(),
searchResultIndicator: indicator,
searchPrevBtn,
searchNextBtn,
searchKeyword: "needle",
showDiff: false,
});
modal.navigateSearch("next");
expect(modal.currentSearchIndex).toBe(-1);
expect(modal.range.value).toBe("unchanged");
expect(modal.loadRevs).not.toHaveBeenCalled();
expect(indicator.setText).not.toHaveBeenCalled();
expect(searchPrevBtn.disabled).toBe(true);
expect(searchNextBtn.disabled).toBe(true);
});
});
@@ -15,6 +15,8 @@ import { isErrorOfMissingDoc } from "@vrtmrz/livesync-commonlib/compat/pouchdb/u
import { fireAndForget, getDocData } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { isPlainText, stripPrefix } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import { scheduleOnceIfDuplicated } from "octagonal-wheels/concurrency/lock";
import { stepCyclicIndex } from "octagonal-wheels/collection";
import { navigateDiffNodes } from "@/common/diffNavigation.ts";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import {
@@ -375,26 +377,7 @@ export class DocumentHistoryModal extends Modal {
* Only effective when diff highlighting is enabled.
*/
navigateDiff(direction: "prev" | "next") {
const diffElements = this.contentView.querySelectorAll(".history-added, .history-deleted");
if (diffElements.length === 0) return;
// Remove previous focus highlight
const prevFocused = this.contentView.querySelector(".diff-focused");
if (prevFocused) {
prevFocused.classList.remove("diff-focused");
}
if (direction === "next") {
this.currentDiffIndex = (this.currentDiffIndex + 1) % diffElements.length;
} else {
this.currentDiffIndex = this.currentDiffIndex <= 0 ? diffElements.length - 1 : this.currentDiffIndex - 1;
}
const target = diffElements[this.currentDiffIndex];
target.classList.add("diff-focused");
target.scrollIntoView({ behavior: "smooth", block: "center" });
this.diffNavIndicator.setText(`${this.currentDiffIndex + 1}/${diffElements.length}`);
navigateDiffNodes(this.contentView, ".history-added, .history-deleted", this, direction);
}
/**
@@ -519,12 +502,7 @@ export class DocumentHistoryModal extends Modal {
navigateSearch(direction: "prev" | "next") {
if (this.searchResults.length === 0) return;
if (direction === "next") {
this.currentSearchIndex = (this.currentSearchIndex + 1) % this.searchResults.length;
} else {
this.currentSearchIndex =
this.currentSearchIndex <= 0 ? this.searchResults.length - 1 : this.currentSearchIndex - 1;
}
this.currentSearchIndex = stepCyclicIndex(this.currentSearchIndex, this.searchResults.length, direction);
const match = this.searchResults[this.currentSearchIndex];
this.range.value = `${this.revs_info.length - 1 - match.index}`;
@@ -7,6 +7,7 @@ import {
type FilePathWithPrefix,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { EVENT_CONFLICT_CANCELLED, EVENT_PLUGIN_UNLOADED, eventHub } from "@/common/events.ts";
import { navigateDiffNodes } from "@/common/diffNavigation.ts";
import { promiseWithResolvers } from "octagonal-wheels/promises";
import { POSTPONED, type MergeDialogResult } from "@/serviceFeatures/interactiveConflictResolution/types";
@@ -83,24 +84,7 @@ export class ConflictResolveModal extends Modal {
}
navigateDiff(direction: "prev" | "next") {
const diffElements = this.diffView.querySelectorAll(".added, .deleted");
if (diffElements.length === 0) return;
const prevFocused = this.diffView.querySelector(".diff-focused");
if (prevFocused) {
prevFocused.classList.remove("diff-focused");
}
if (direction === "next") {
this.currentDiffIndex = (this.currentDiffIndex + 1) % diffElements.length;
} else {
this.currentDiffIndex = this.currentDiffIndex <= 0 ? diffElements.length - 1 : this.currentDiffIndex - 1;
}
const target = diffElements[this.currentDiffIndex];
target.classList.add("diff-focused");
target.scrollIntoView({ behavior: "smooth", block: "center" });
this.diffNavIndicator.setText(`${this.currentDiffIndex + 1}/${diffElements.length}`);
navigateDiffNodes(this.diffView, ".added, .deleted", this, direction);
}
resetDiffNavigation() {
@@ -106,11 +106,7 @@ describe("ConflictResolveModal result lifecycle", () => {
});
it("closes and completes its result when the plug-in unloads", async () => {
const modal = new ConflictResolveModal(
{} as never,
"open-during-unload.md" as FilePathWithPrefix,
conflict
);
const modal = new ConflictResolveModal({} as never, "open-during-unload.md" as FilePathWithPrefix, conflict);
modal.onOpen();
eventHub.emitEvent(EVENT_PLUGIN_UNLOADED);
@@ -165,3 +161,186 @@ describe("ConflictResolveModal result lifecycle", () => {
expect(previousState).toBe("still-open");
});
});
type NavigationTarget = {
classList: { add: ReturnType<typeof vi.fn>; remove: ReturnType<typeof vi.fn> };
scrollIntoView: ReturnType<typeof vi.fn>;
};
function createDiffView(count: number) {
const focused: { current: NavigationTarget | undefined } = { current: undefined };
const createTargets = (targetCount: number) =>
Array.from({ length: targetCount }, () => {
const target: NavigationTarget = {
classList: {
add: vi.fn(() => {
focused.current = target;
}),
remove: vi.fn(() => {
if (focused.current === target) focused.current = undefined;
}),
},
scrollIntoView: vi.fn(),
};
return target;
});
let targets = createTargets(count);
const diffView = {
querySelectorAll: vi.fn(() => targets),
querySelector: vi.fn(() => (targets.includes(focused.current as NavigationTarget) ? focused.current : null)),
};
return {
diffView,
targets,
replaceTargets(targetCount: number) {
focused.current = undefined;
targets = createTargets(targetCount);
return targets;
},
};
}
describe("ConflictResolveModal cyclic navigation", () => {
it("commits the selected index before scrolling, including when scrolling fails", () => {
const modal = Object.create(ConflictResolveModal.prototype) as ConflictResolveModal;
const view = createDiffView(2);
const indicator = { setText: vi.fn() };
const failure = new Error("fixture scroll failed");
Object.assign(modal, { diffView: view.diffView, currentDiffIndex: -1, diffNavIndicator: indicator });
view.targets[0].scrollIntoView.mockImplementation(() => {
expect(modal.currentDiffIndex).toBe(0);
throw failure;
});
expect(() => modal.navigateDiff("next")).toThrow(failure);
expect(modal.currentDiffIndex).toBe(0);
expect(indicator.setText).not.toHaveBeenCalled();
});
it("starts at the first diff for next and at the last diff for previous", () => {
const nextModal = new ConflictResolveModal({} as never, "next.md" as FilePathWithPrefix, conflict);
const nextView = createDiffView(3);
const nextIndicator = { setText: vi.fn() };
Object.assign(nextModal, {
diffView: nextView.diffView,
currentDiffIndex: -1,
diffNavIndicator: nextIndicator,
});
nextModal.navigateDiff("next");
expect(nextModal.currentDiffIndex).toBe(0);
expect(nextView.targets[0].classList.add).toHaveBeenCalledWith("diff-focused");
expect(nextView.targets[0].scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "center" });
expect(nextIndicator.setText).toHaveBeenCalledWith("1/3");
const previousModal = new ConflictResolveModal({} as never, "previous.md" as FilePathWithPrefix, conflict);
const previousView = createDiffView(3);
const previousIndicator = { setText: vi.fn() };
Object.assign(previousModal, {
diffView: previousView.diffView,
currentDiffIndex: -1,
diffNavIndicator: previousIndicator,
});
previousModal.navigateDiff("prev");
expect(previousModal.currentDiffIndex).toBe(2);
expect(previousView.targets[2].classList.add).toHaveBeenCalledWith("diff-focused");
expect(previousView.targets[2].scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "center" });
expect(previousIndicator.setText).toHaveBeenCalledWith("3/3");
});
it("wraps in both directions while moving focus, scrolling, and updating the indicator", () => {
const modal = new ConflictResolveModal({} as never, "wrap.md" as FilePathWithPrefix, conflict);
const { diffView, targets } = createDiffView(3);
const indicator = { setText: vi.fn() };
Object.assign(modal, { diffView, currentDiffIndex: -1, diffNavIndicator: indicator });
modal.navigateDiff("next");
modal.navigateDiff("next");
modal.navigateDiff("next");
modal.navigateDiff("next");
modal.navigateDiff("prev");
expect(modal.currentDiffIndex).toBe(2);
expect(targets.map((target) => target.classList.add.mock.calls.length)).toEqual([2, 1, 2]);
expect(targets.map((target) => target.classList.remove.mock.calls.length)).toEqual([2, 1, 1]);
expect(targets.map((target) => target.scrollIntoView.mock.calls.length)).toEqual([2, 1, 2]);
expect(indicator.setText.mock.calls.map(([text]) => text)).toEqual(["1/3", "2/3", "3/3", "1/3", "3/3"]);
});
it("leaves the current navigation index and diff view untouched when no diff exists", () => {
const modal = new ConflictResolveModal({} as never, "empty.md" as FilePathWithPrefix, conflict);
const { diffView } = createDiffView(0);
const indicator = { setText: vi.fn() };
Object.assign(modal, { diffView, currentDiffIndex: 1, diffNavIndicator: indicator });
modal.navigateDiff("next");
expect(modal.currentDiffIndex).toBe(1);
expect(diffView.querySelector).not.toHaveBeenCalled();
expect(indicator.setText).not.toHaveBeenCalled();
});
it("looks up diff nodes again after the diff view is replaced", () => {
const modal = new ConflictResolveModal({} as never, "replace.md" as FilePathWithPrefix, conflict);
const view = createDiffView(2);
const indicator = { setText: vi.fn() };
Object.assign(modal, { diffView: view.diffView, currentDiffIndex: -1, diffNavIndicator: indicator });
modal.navigateDiff("next");
const replacementTargets = view.replaceTargets(3);
modal.navigateDiff("next");
expect(modal.currentDiffIndex).toBe(1);
expect(view.diffView.querySelectorAll).toHaveBeenCalledTimes(2);
expect(view.diffView.querySelector).toHaveBeenCalledTimes(2);
expect(replacementTargets[1].classList.add).toHaveBeenCalledWith("diff-focused");
expect(replacementTargets[1].scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "center" });
expect(indicator.setText.mock.calls.map(([text]) => text)).toEqual(["1/2", "2/3"]);
});
it("keeps navigation indices and focus independent between modal instances", () => {
const firstModal = new ConflictResolveModal({} as never, "first.md" as FilePathWithPrefix, conflict);
const secondModal = new ConflictResolveModal({} as never, "second.md" as FilePathWithPrefix, conflict);
const firstView = createDiffView(2);
const secondView = createDiffView(2);
Object.assign(firstModal, {
diffView: firstView.diffView,
currentDiffIndex: -1,
diffNavIndicator: { setText: vi.fn() },
});
Object.assign(secondModal, {
diffView: secondView.diffView,
currentDiffIndex: -1,
diffNavIndicator: { setText: vi.fn() },
});
firstModal.navigateDiff("next");
secondModal.navigateDiff("next");
firstModal.navigateDiff("next");
expect(firstModal.currentDiffIndex).toBe(1);
expect(secondModal.currentDiffIndex).toBe(0);
expect(firstView.targets[0].classList.remove).toHaveBeenCalledWith("diff-focused");
expect(secondView.targets[0].classList.remove).not.toHaveBeenCalled();
expect(secondView.targets[0].classList.add).toHaveBeenCalledWith("diff-focused");
});
it("always resets the indicator using the current diff count", () => {
const modal = new ConflictResolveModal({} as never, "reset.md" as FilePathWithPrefix, conflict);
const view = createDiffView(2);
const indicator = { setText: vi.fn() };
Object.assign(modal, { diffView: view.diffView, currentDiffIndex: 1, diffNavIndicator: indicator });
modal.resetDiffNavigation();
expect(modal.currentDiffIndex).toBe(-1);
expect(view.diffView.querySelectorAll).toHaveBeenCalledWith(".added, .deleted");
expect(indicator.setText).toHaveBeenLastCalledWith("0/2");
view.replaceTargets(0);
modal.resetDiffNavigation();
expect(indicator.setText).toHaveBeenLastCalledWith("\u2014");
});
});
+21 -7
View File
@@ -34,6 +34,7 @@ export interface SvelteDialogSessionOptions<TResult, TInitial, TContext extends
component: ComponentHasResult<TResult, TInitial>;
initialData?: TInitial;
renderer?: SvelteDialogRenderer;
signal?: AbortSignal;
}
const svelteDialogRenderer: SvelteDialogRenderer = {
@@ -46,11 +47,7 @@ const svelteDialogRenderer: SvelteDialogRenderer = {
};
/** Owns one Svelte dialogue result and mount lifecycle for a concrete host surface. */
export class SvelteDialogSession<
TResult,
TInitial,
TContext extends ServiceContext = ServiceContext,
> {
export class SvelteDialogSession<TResult, TInitial, TContext extends ServiceContext = ServiceContext> {
private readonly surface: SvelteDialogSurface;
private readonly context: TContext;
private readonly dependencies: SvelteDialogManagerDependencies<TContext>;
@@ -58,11 +55,13 @@ export class SvelteDialogSession<
private readonly component: ComponentHasResult<TResult, TInitial>;
private readonly initialData?: TInitial;
private readonly renderer: SvelteDialogRenderer;
private readonly signal?: AbortSignal;
private result?: TResult;
private pendingResult?: PromiseWithResolvers<TResult | undefined>;
private mountedDialog?: MountedDialog;
private unregisterUnload?: () => void;
private unregisterAbort?: () => void;
constructor(options: SvelteDialogSessionOptions<TResult, TInitial, TContext>) {
this.surface = options.surface;
@@ -72,10 +71,14 @@ export class SvelteDialogSession<
this.component = options.component;
this.initialData = options.initialData;
this.renderer = options.renderer ?? svelteDialogRenderer;
this.signal = options.signal;
}
onOpen(): void {
if (this.signal?.aborted) throw this.signal.reason ?? new Error("Plug-in unloaded");
this.surface.contentEl.replaceChildren();
this.unregisterAbort?.();
this.unregisterAbort = undefined;
this.unregisterUnload?.();
this.unregisterUnload = undefined;
if (this.pendingResult) {
@@ -85,14 +88,23 @@ export class SvelteDialogSession<
this.result = undefined;
const pendingResult = promiseWithResolvers<TResult | undefined>();
this.pendingResult = pendingResult;
this.unregisterUnload = this.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, () => {
const cancel = (reason: unknown) => {
if (this.pendingResult !== pendingResult) {
return;
}
this.pendingResult = undefined;
pendingResult.reject(new Error("Plug-in unloaded"));
pendingResult.reject(reason);
this.surface.close();
};
this.unregisterUnload = this.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, () => {
cancel(new Error("Plug-in unloaded"));
});
const signal = this.signal;
if (signal) {
const onAbort = () => cancel(signal.reason ?? new Error("Plug-in unloaded"));
signal.addEventListener("abort", onAbort, { once: true });
this.unregisterAbort = () => signal.removeEventListener("abort", onAbort);
}
this.mountedDialog = this.renderer.mount(this.dialogHost, this.surface.contentEl, {
onSetupContext: (props: DialogSvelteComponentBaseProps<TResult, TInitial>) => {
@@ -124,6 +136,8 @@ export class SvelteDialogSession<
}
onClose(): void {
this.unregisterAbort?.();
this.unregisterAbort = undefined;
this.unregisterUnload?.();
this.unregisterUnload = undefined;
this.pendingResult?.resolve(this.result);